Link the admin's recovery-code notice to the flow the code was minted on #200

Merged
lilleman merged 6 commits from item-45 into main 2026-10-31 13:35:30 +01:00
3 changed files with 25 additions and 5 deletions
Showing only changes of commit 00fe8b0dd2 - Show all commits
+20
View File
@@ -138,6 +138,26 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0); 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 }) => { 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 // 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. // enforces it); pick the first option (a user) from the required picker.
+2 -2
View File
@@ -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 () => { 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); 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.equal(calls[0]!.method, "POST");
assert.match(calls[0]!.url, /\/admin\/recovery\/code$/); assert.match(calls[0]!.url, /\/admin\/recovery\/code$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID }); assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID });
+3 -3
View File
@@ -25,7 +25,7 @@ export interface ListOptions {
pageToken?: string; 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 { export interface RecoveryCode {
code: string; code: string;
link: string; link: string;
@@ -65,8 +65,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
return (await res.json()) as Identity; return (await res.json()) as Identity;
}, },
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the // Mint a recovery code for an identity (admin "trigger recovery"). Kratos mails nothing, so the
// user by Kratos; the code/link are also returned so an operator can hand them over directly. // operator hands the code and link to the user.
async createRecoveryCode(userId, opts = {}) { async createRecoveryCode(userId, opts = {}) {
const body: Record<string, unknown> = { identity_id: userId }; const body: Record<string, unknown> = { identity_id: userId };
if (opts.expiresIn) body.expires_in = opts.expiresIn; if (opts.expiresIn) body.expires_in = opts.expiresIn;