diff --git a/lambda/user-bot/README.md b/lambda/user-bot/README.md index 8e3530f..fde349a 100644 --- a/lambda/user-bot/README.md +++ b/lambda/user-bot/README.md @@ -24,7 +24,7 @@ When both hold, it: 1. reads the Slack bot token (see [The Slack token](#the-slack-token)). **If that fails, it stops here and the user's password is not touched;** 2. generates a 20-character password containing all four character classes; 3. sets it with `UpdateLoginProfile` and `PasswordResetRequired: true`, so the user must replace it at first sign-in; -4. DMs the user, as the Slack app, with the sign-in page, their IAM user name and the temporary password. +4. DMs the user, as the Slack app, with the sign-in page, their IAM user name, the temporary password, and a link straight to the **Assign MFA device** wizard for their own user (`MFA_SETUP_URL` in `src/message.ts`). That link only works once they are signed in. Step 1 comes before the reset on purpose. Reading the token at send time would mean a bad token leaves the user with a new password that nobody received. diff --git a/lambda/user-bot/src/message.ts b/lambda/user-bot/src/message.ts index 59c4cdf..94d93d6 100644 --- a/lambda/user-bot/src/message.ts +++ b/lambda/user-bot/src/message.ts @@ -1,6 +1,13 @@ // The incubator account's sign-in page, via its alias hfla-incubator. export const SIGN_IN_URL = "https://hfla-incubator.signin.aws.amazon.com/console"; +// Opens the "Assign MFA device" wizard for whoever is signed in, so one link works for +// every user. It only works once signed in: the route is in the #fragment, which the +// browser never sends to AWS, so a signed-out visit loses it on the way through sign-in +// and lands on the console home page. Hence the "once signed in" in the message. +// Tested by hand 2026-10-04; AWS does not document console routes and may change them. +export const MFA_SETUP_URL = "https://console.aws.amazon.com/iam/home#/security_credentials/mfa"; + // Slack mrkdwn. The user name and password are in inline code so that characters // such as * and _ are shown literally rather than read as formatting. export function buildWelcomeMessage(userName: string, password: string): string { @@ -12,8 +19,10 @@ export function buildWelcomeMessage(userName: string, password: string): string `*Temporary password:* \`${password}\``, "", "The first time you sign in you will be asked to replace this password with one of your own. " + - "Then set up multi-factor authentication (MFA) from *Security credentials* in the account " + - "menu: most of your access only works once MFA is set up.", + "Then, in the same browser, set up multi-factor authentication (MFA): most of your access " + + "only works once MFA is set up.", + "", + `*Set up MFA (once signed in):* ${MFA_SETUP_URL}`, "", "If the password does not work, ask the DevOps team on Slack for a new one.", ].join("\n"); diff --git a/lambda/user-bot/test/handler.test.ts b/lambda/user-bot/test/handler.test.ts index 6d44fe7..7775d03 100644 --- a/lambda/user-bot/test/handler.test.ts +++ b/lambda/user-bot/test/handler.test.ts @@ -8,7 +8,7 @@ import { mockClient } from "aws-sdk-client-mock"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createHandler, type Dependencies } from "../src/handler"; -import { SIGN_IN_URL } from "../src/message"; +import { MFA_SETUP_URL, SIGN_IN_URL } from "../src/message"; import { generatePassword } from "../src/password"; import type { DirectMessage, MessageSender } from "../src/senders/types"; import { captureLogger, createLoginProfileEvent, TEST_PASSWORD, TEST_SLACK_ID } from "./helpers"; @@ -58,6 +58,7 @@ describe("happy path", () => { expect(sent[0]!.text).toContain(TEST_PASSWORD); expect(sent[0]!.text).toContain("new.member"); expect(sent[0]!.text).toContain(SIGN_IN_URL); + expect(sent[0]!.text).toContain(MFA_SETUP_URL); expect(logger.text()).not.toContain(TEST_PASSWORD); }); diff --git a/lambda/user-bot/test/message.test.ts b/lambda/user-bot/test/message.test.ts new file mode 100644 index 0000000..3fa9336 --- /dev/null +++ b/lambda/user-bot/test/message.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { buildWelcomeMessage, MFA_SETUP_URL, SIGN_IN_URL } from "../src/message"; +import { TEST_PASSWORD } from "./helpers"; + +describe("buildWelcomeMessage", () => { + const text = buildWelcomeMessage("new.member", TEST_PASSWORD); + + it("includes the incubator sign-in page, the user name and the password", () => { + expect(SIGN_IN_URL).toBe("https://hfla-incubator.signin.aws.amazon.com/console"); + expect(text).toContain(`*Sign-in page:* ${SIGN_IN_URL}`); + expect(text).toContain("*IAM user name:* `new.member`"); + expect(text).toContain(`*Temporary password:* \`${TEST_PASSWORD}\``); + }); + + it("links straight to the signed-in user's Assign MFA device wizard", () => { + expect(MFA_SETUP_URL).toBe("https://console.aws.amazon.com/iam/home#/security_credentials/mfa"); + expect(text).toContain(`*Set up MFA (once signed in):* ${MFA_SETUP_URL}`); + }); + + it("gives the MFA link after the sign-in details, since it only works once signed in", () => { + expect(text.indexOf(MFA_SETUP_URL)).toBeGreaterThan(text.indexOf(TEST_PASSWORD)); + }); +});