fix: separate account registration from authenticated passkey addition

Bind enrollment purpose and allocated/current account to verifier state. Reject existing-handle registration, derive additions from live transport authentication, recheck revocation/expiry in the insert-only transaction, and consume challenges atomically across connections. Derive stable account WebAuthn handles without rewriting legacy credentials; reject implicit-user login and unsafe legacy ceremonies.

Align account callers, DTO/OpenAPI/generated artifacts and migration/recovery rationale. Preserve credential-less legacy identities rather than allowing anonymous reclaim.

Validation: 14 actual-verifier passkey tests; full workspace-server 1086 library + 11 binary passed (Drive ignored case explicitly selected separately and passed); server-api default 99 and TypeScript feature 108 passed; isolated auth/Runtime E2E 1+2 passed; Web 645 + component 394 passed; root cargo check, docs/doctests, Server/Runtime builds, fmt, naming and generated consistency passed. Software authenticator only; hosted CI/hardware login not claimed.
This commit is contained in:
2026-10-16 00:55:16 +09:00
parent b744efd104
commit 6218c27fb6
24 changed files with 3184 additions and 140 deletions
+177
View File
@@ -3,10 +3,20 @@ declare const Deno: {
};
import {
addPasskey,
loadWhoami,
loginWithPasskey,
readBoundedAuthResponseJson,
registerPasskey,
} from "../src/lib/workspace/auth/api.ts";
import {
authUser,
credentialFixture,
loginOptions,
registrationOptions,
} from "../src/lib/workspace/auth/api.test-fixtures.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
@@ -97,3 +107,170 @@ Deno.test("bounded auth response reader rejects invalid JSON without echoing con
sensitive,
);
});
async function withBrowserBoundary(
fetcher: typeof fetch,
run: (calls: { create: number; get: number }) => Promise<void>,
) {
const descriptors = new Map(
["fetch", "window", "navigator"].map((key) => [
key,
Object.getOwnPropertyDescriptor(globalThis, key),
]),
);
const calls = { create: 0, get: 0 };
Object.defineProperties(globalThis, {
fetch: { configurable: true, writable: true, value: fetcher },
window: {
configurable: true,
value: { location: { origin: "https://yoi.example" } },
},
navigator: {
configurable: true,
value: {
credentials: {
create: (options: CredentialCreationOptions) => {
calls.create++;
assertEquals([
...new Uint8Array(options.publicKey!.challenge as ArrayBuffer),
], [1, 2, 3]);
assertEquals([
...new Uint8Array(options.publicKey!.user.id as ArrayBuffer),
], [4, 5, 6]);
return Promise.resolve(credentialFixture());
},
get: (options: CredentialRequestOptions) => {
calls.get++;
assertEquals([
...new Uint8Array(
options.publicKey!.allowCredentials![0].id as ArrayBuffer,
),
], [7, 8, 9]);
return Promise.resolve(credentialFixture(true));
},
},
},
},
});
try {
await run(calls);
} finally {
for (const [key, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
}
for (const purpose of ["registration", "addition"] as const) {
Deno.test(`${purpose} adapter uses only its own options and complete routes with session credentials`, async () => {
const requests: { path: string; init: RequestInit }[] = [];
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? registrationOptions : { user: authUser },
);
}, async (calls) => {
const user = purpose === "registration"
? await registerPasskey("alice", "Alice")
: await addPasskey();
assertEquals(user, authUser);
assertEquals(calls, { create: 1, get: 0 });
});
assertEquals(requests.map(({ path }) => path), [
`/api/auth/passkeys/${purpose}/options`,
`/api/auth/passkeys/${purpose}/complete`,
]);
for (const { init } of requests) {
assertEquals(init.method, "POST");
assertEquals(init.credentials, "same-origin");
assertEquals(init.headers, { "content-type": "application/json" });
}
assertEquals(
JSON.parse(String(requests[0].init.body)),
purpose === "registration"
? {
handle: "alice",
display_name: "Alice",
browser_origin: "https://yoi.example",
}
: { browser_origin: "https://yoi.example" },
);
const complete = JSON.parse(String(requests[1].init.body));
assertEquals(Object.keys(complete).sort(), ["challenge_id", "credential"]);
assertEquals(complete.challenge_id, registrationOptions.challenge_id);
assertEquals(complete.credential.response.attestationObject, "AQID");
assertEquals(complete.credential.response.transports, ["internal"]);
});
}
Deno.test("addition rejects expired-session completion without falling back to registration", async () => {
const paths: string[] = [];
await withBrowserBoundary(async (input) => {
paths.push(String(input));
return paths.length === 1
? Response.json(registrationOptions)
: new Response(null, { status: 401 });
}, async () => {
await assertRejects(addPasskey(), "Auth request failed (401).");
});
assertEquals(paths, [
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
});
Deno.test("existing-handle registration rejection never starts WebAuthn or retries addition", async () => {
const paths: string[] = [];
await withBrowserBoundary(async (input) => {
paths.push(String(input));
return new Response(null, { status: 409 });
}, async (calls) => {
await assertRejects(registerPasskey("alice"), "Auth request failed (409).");
assertEquals(calls, { create: 0, get: 0 });
});
assertEquals(paths, ["/api/auth/passkeys/registration/options"]);
});
Deno.test("login requires a nonempty explicit handle before HTTP or WebAuthn", async () => {
await withBrowserBoundary(() => {
throw new Error("Unexpected HTTP request");
}, async (calls) => {
for (const handle of ["", " ", "\n\t"]) {
await assertRejects(
loginWithPasskey(handle),
"User handle is required for passkey login.",
);
}
assertEquals(calls, { create: 0, get: 0 });
});
});
Deno.test("login sends the explicit trimmed handle and serializes the assertion", async () => {
const requests: { path: string; init: RequestInit }[] = [];
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? loginOptions : { user: authUser },
);
}, async (calls) => {
assertEquals(await loginWithPasskey(" alice "), authUser);
assertEquals(calls, { create: 0, get: 1 });
});
assertEquals(requests.map(({ path }) => path), [
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
]);
assertEquals(JSON.parse(String(requests[0].init.body)), {
handle: "alice",
browser_origin: "https://yoi.example",
});
const complete = JSON.parse(String(requests[1].init.body));
assertEquals(complete.challenge_id, loginOptions.challenge_id);
assertEquals(complete.credential.response.signature, "AQID");
assertEquals(complete.credential.response.userHandle, null);
assertEquals(requests.map(({ init }) => init.credentials), [
"same-origin",
"same-origin",
]);
});