fix(auth): confirm passkey operations and preserve safe legacy migration

Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions.

Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
This commit is contained in:
2026-10-26 07:18:23 +09:00
parent b284934e34
commit f17b5212c7
66 changed files with 4716 additions and 532 deletions
+56 -12
View File
@@ -168,35 +168,62 @@ for (const purpose of ["registration", "addition"] as const) {
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? registrationOptions : { user: authUser },
String(input) === "/api/auth/whoami"
? {
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: {
kind: "authenticated",
expires_at: "2099-01-01T00:00:00Z",
},
}
: String(input).endsWith("/options")
? registrationOptions
: {
user: authUser,
authentication_reference: "fixture-authentication",
},
);
}, async (calls) => {
const user = purpose === "registration"
? await registerPasskey("alice", "Alice")
: await addPasskey();
assertEquals(user, authUser);
assertEquals(user, {
user: authUser,
authentication_reference: "fixture-authentication",
});
assertEquals(calls, { create: 1, get: 0 });
});
assertEquals(requests.map(({ path }) => path), [
...(purpose === "addition" ? ["/api/auth/whoami"] : []),
`/api/auth/passkeys/${purpose}/options`,
`/api/auth/passkeys/${purpose}/complete`,
]);
for (const { init } of requests) {
assertEquals(init.method, "POST");
for (const { path, init } of requests) {
assertEquals(
init.method ?? "GET",
path.endsWith("/whoami") ? "GET" : "POST",
);
assertEquals(init.credentials, "same-origin");
assertEquals(init.headers, { "content-type": "application/json" });
}
const ceremony = requests.filter(({ path }) => !path.endsWith("/whoami"));
assertEquals(
JSON.parse(String(requests[0].init.body)),
JSON.parse(String(ceremony[0].init.body)),
purpose === "registration"
? {
handle: "alice",
display_name: "Alice",
browser_origin: "https://yoi.example",
}
: { browser_origin: "https://yoi.example" },
: {
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference: "fixture-authentication",
browser_origin: "https://yoi.example",
},
);
const complete = JSON.parse(String(requests[1].init.body));
const complete = JSON.parse(String(ceremony[1].init.body));
assertEquals(Object.keys(complete).sort(), ["challenge_id", "credential"]);
assertEquals(complete.challenge_id, registrationOptions.challenge_id);
assertEquals(complete.credential.response.attestationObject, "AQID");
@@ -208,13 +235,21 @@ Deno.test("addition rejects expired-session completion without falling back to r
const paths: string[] = [];
await withBrowserBoundary(async (input) => {
paths.push(String(input));
return paths.length === 1
if (String(input).endsWith("/whoami")) {
return Response.json({
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
}
return String(input).endsWith("/options")
? Response.json(registrationOptions)
: new Response(null, { status: 401 });
}, async () => {
await assertRejects(addPasskey(), "Auth request failed (401).");
await assertRejects(addPasskey(), "Passkey complete: rejected (rejected).");
});
assertEquals(paths, [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
@@ -226,7 +261,10 @@ Deno.test("existing-handle registration rejection never starts WebAuthn or retri
paths.push(String(input));
return new Response(null, { status: 409 });
}, async (calls) => {
await assertRejects(registerPasskey("alice"), "Auth request failed (409).");
await assertRejects(
registerPasskey("alice"),
"Passkey options: rejected (not-sent).",
);
assertEquals(calls, { create: 0, get: 0 });
});
assertEquals(paths, ["/api/auth/passkeys/registration/options"]);
@@ -251,10 +289,16 @@ Deno.test("login sends the explicit trimmed handle and serializes the assertion"
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? loginOptions : { user: authUser },
requests.length === 1 ? loginOptions : {
user: authUser,
authentication_reference: "fixture-authentication",
},
);
}, async (calls) => {
assertEquals(await loginWithLegacyPasskey(" alice "), authUser);
assertEquals(await loginWithLegacyPasskey(" alice "), {
user: authUser,
authentication_reference: "fixture-authentication",
});
assertEquals(calls, { create: 0, get: 1 });
});
assertEquals(requests.map(({ path }) => path), [