diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 3ae774e..3118329 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -138,6 +138,26 @@ test.describe.serial("authenticated admin journey", () => { await expect(page.locator("tr", { hasText: email })).toHaveCount(0); }); + // Kratos accepts an admin-minted code only in the flow it was minted on, which the notice's link opens. + test("recovery code: the notice's link opens the flow that accepts the code, and the code signs the user in", async () => { + test.setTimeout(90_000); + const email = `admin-recovery-${suffix}@plainpages.local`; + await createIdentity(email); + await page.goto("/admin/users"); + const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/admin/users/"]').first().getAttribute("href"); + await page.goto(editHref!); + await page.getByRole("button", { name: "Generate recovery code" }).click(); + const code = await page.locator(".recovery-code code").innerText(); + const link = await page.locator(".alert-body a").getAttribute("href"); + + const user = await watchedPage(await browser.newContext()); + await user.goto(link!); + await user.fill('input[name="code"]', code); + await user.getByRole("button", { name: "Continue" }).click(); + await expect(user).toHaveURL(/\/settings/); + await user.context().close(); + }); + test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async ({ browser }) => { // A Keto set exists only while it has ≥1 member, so create needs a first member (the form // enforces it); pick the first option (a user) from the required picker. diff --git a/src/auth/kratos-admin.test.ts b/src/auth/kratos-admin.test.ts index 69a7278..b9c6d4f 100644 --- a/src/auth/kratos-admin.test.ts +++ b/src/auth/kratos-admin.test.ts @@ -100,9 +100,9 @@ test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it nev }); test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => { - const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f&code=123456" })); + const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f" })); const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID); - assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f&code=123456" }); + assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f" }); assert.equal(calls[0]!.method, "POST"); assert.match(calls[0]!.url, /\/admin\/recovery\/code$/); assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID }); diff --git a/src/auth/kratos-admin.ts b/src/auth/kratos-admin.ts index 1ef631a..52c67dc 100644 --- a/src/auth/kratos-admin.ts +++ b/src/auth/kratos-admin.ts @@ -25,7 +25,7 @@ export interface ListOptions { pageToken?: string; } -// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery"). +// A one-time recovery code + the URL of the recovery flow it was minted on, the only flow that accepts it. export interface RecoveryCode { code: string; link: string; @@ -65,8 +65,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof return (await res.json()) as Identity; }, - // Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the - // user by Kratos; the code/link are also returned so an operator can hand them over directly. + // Mint a recovery code for an identity (admin "trigger recovery"). Kratos mails nothing, so the + // operator hands the code and link to the user. async createRecoveryCode(userId, opts = {}) { const body: Record = { identity_id: userId }; if (opts.expiresIn) body.expires_in = opts.expiresIn;