fix: integrate passkey results with current dogfooding shell

Preserve authenticated home/account navigation and guarded mobile sidebar swipes. Keep both browser task selections. Complete the new account fixture contract and synchronize lifecycle probes on visible registered-key results, without changing their success or security assertions.
This commit is contained in:
2026-10-26 09:50:13 +09:00
67 changed files with 5302 additions and 542 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check",
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts",
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts test/auth-browser/passkey-state.browser.test.ts",
"test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts test/auth-browser/sidebar-shell.browser.test.ts"
},
"imports": {
+23 -3
View File
@@ -21,13 +21,33 @@ export type RequestActor = { user_id: string, account_id: string, handle: string
export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", };
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, };
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState,
/**
* Non-secret comparison evidence, never an authentication credential.
*/
authentication_reference: string | null, };
export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, };
export type AuthUserResponse = { user: AuthenticatedUser, };
export type AuthUserResponse = { user: AuthenticatedUser,
/**
* Comparison evidence for the exact newly issued session, not a credential.
*/
authentication_reference: string, };
export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, };
export type PasskeyAdditionOptionsRequest = { expected_user_id: string, expected_account_id: string, expected_authentication_reference: string, browser_origin?: string | null, };
export type PasskeySummary = { key_reference: string, created_at: string, last_used_at: string | null, normal_login_verified_at: string | null, };
export type PasskeyListResponse = { user: AuthenticatedUser, keys: Array<PasskeySummary>, };
export type PasskeyAdditionResultRequest = { challenge_id: string, expected_user_id: string, expected_account_id: string,
/**
* Original options authentication reference; the current same-owner session may differ.
*/
expected_authentication_reference: string, };
export type PasskeyAdditionResultResponse = { "status": "unknown", } | { "status": "saved", user: AuthenticatedUser, key: PasskeySummary, };
export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, };
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -108,7 +108,7 @@
<details class="legacy-login" bind:open={legacyOpen}>
<summary>Use an older key with account ID</summary>
<form onsubmit={(event) => { event.preventDefault(); void submit(true); }}>
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> and add a discoverable passkey for ID-free login. Your existing keys are preserved.</p>
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> to inspect keys and verify migration. Adding with the same older-key authenticator may be excluded; use a different saved-key provider or authenticator. Keep older keys until normal ID-free login to the intended account is tested.</p>
<label>Account ID (handle)
<input bind:value={handle} required maxlength="128" autocomplete="username" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="legacy-id-help" />
</label>
@@ -131,6 +131,7 @@
{/if}
{/if}
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<nav aria-label="Authentication">
{#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor}
@@ -56,7 +56,7 @@
{#if !protectedPage}
{@render children()}
{:else}
{#if session.kind === 'loading'}
{#if session.kind === 'loading' && !admitted}
<div class="auth-page"><p role="status">Loading session…</p></div>
{:else if session.kind === 'error'}
<div class="auth-page">
@@ -66,7 +66,7 @@
</div>
{/if}
{#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted}
<!-- 再確認中は表示/操作を停止し、進行中のlogout等をunmountで取消さない。 -->
<div hidden={session.kind !== 'ready'} inert={session.kind !== 'ready'}>{@render children()}</div>
<!-- 再確認中は既存内容と操作進捗を可視に保ち、操作だけをinertで停止する。 -->
<div hidden={session.kind === 'error'} inert={session.kind !== 'ready'}>{@render children()}</div>
{/if}
{/if}
@@ -1,10 +1,18 @@
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
import { browserSession } from "./session-state.ts";
import { activateWithPasskey } from "./activation.ts";
import {
authUser,
credentialFixture,
registrationOptions,
} from "./api.test-fixtures.ts";
function confirmedSession() {
return {
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
};
}
function equal(a: unknown, b: unknown) {
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ");
}
@@ -52,9 +60,14 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
await boundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
String(input).endsWith("/options")
String(input) === "/api/auth/whoami"
? confirmedSession()
: String(input).endsWith("/options")
? registrationOptions
: { user: authUser },
: {
user: authUser,
authentication_reference: "fixture-authentication",
},
);
}, async (options) => {
equal(options.signal === signal, true);
@@ -67,16 +80,21 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
equal(requests.map((r) => r.path), [
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
for (const { init } of requests) {
equal(init.method, "POST");
for (const { path, init } of requests) {
equal(init.method ?? "GET", path === "/api/auth/whoami" ? "GET" : "POST");
equal(init.signal === signal, true);
equal(init.credentials, "same-origin");
equal(init.redirect, "error");
equal(init.mode, "same-origin");
equal(init.cache, "no-store");
equal(init.referrerPolicy, "no-referrer");
if (path !== "/api/auth/whoami") {
equal(init.referrerPolicy, "no-referrer");
}
equal(new Headers(init.headers).has("authorization"), false);
}
equal(requests[2].init.body, undefined);
equal(JSON.parse(String(requests[0].init.body)), {
handle: "alice",
code: "synthetic-input-only",
@@ -125,9 +143,10 @@ Deno.test("activation sanitizes HTTP transport malformed and mismatched completi
return Response.json({ unexpected: "synthetic-input-only" });
}
return Response.json(
String(input).endsWith("/options")
? registrationOptions
: { user: { ...authUser, handle: "other" } },
String(input).endsWith("/options") ? registrationOptions : {
user: { ...authUser, handle: "other" },
authentication_reference: "fixture-authentication",
},
);
},
async () => credentialFixture(),
@@ -173,3 +192,201 @@ Deno.test("activation invalid IDs and oversized code are rejected before network
},
);
});
for (
const mode of [
"anonymous",
"bob",
"account-mismatch",
"reference-mismatch",
"api-token",
"unavailable",
] as const
) {
Deno.test(
"activation rejects " + mode +
" whoami after successful completion without retry",
async () => {
const paths: string[] = [];
await boundary(
async (input) => {
const path = String(input);
paths.push(path);
if (path === "/api/auth/activation/options") {
return Response.json(registrationOptions);
}
if (path === "/api/auth/activation/complete") {
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path !== "/api/auth/whoami") {
throw new Error("Unexpected endpoint");
}
if (mode === "unavailable") {
return new Response("private whoami diagnostic", { status: 503 });
}
if (mode === "anonymous") {
return Response.json({
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
}
const confirmation = confirmedSession();
if (mode === "bob") {
confirmation.actor = {
...confirmation.actor,
user_id: "user-bob",
account_id: "account-bob",
handle: "bob",
display_name: "Bob",
};
}
if (mode === "account-mismatch") {
confirmation.actor.account_id = "account-bob";
}
if (mode === "reference-mismatch") {
confirmation.authentication_reference = "another-authentication";
}
if (mode === "api-token") {
confirmation.actor.auth_method = "api_token";
}
return Response.json(confirmation);
},
async () => credentialFixture(),
async () => {
await rejects(
activateWithPasskey(
"alice",
"synthetic-input-only",
new AbortController().signal,
),
);
equal(paths, [
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
},
);
},
);
}
for (const stage of ["prompt", "completion", "confirmation"] as const) {
Deno.test(
"activation session generation change during " + stage +
" fences stale work",
async () => {
const paths: string[] = [];
await boundary(async (input) => {
const path = String(input);
paths.push(path);
if (path === "/api/auth/activation/options") {
return Response.json(registrationOptions);
}
if (path === "/api/auth/activation/complete") {
if (stage === "completion") browserSession.invalidate();
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/whoami") {
if (stage === "confirmation") browserSession.invalidate();
return Response.json(confirmedSession());
}
throw new Error("Unexpected endpoint");
}, async () => {
if (stage === "prompt") browserSession.invalidate();
return credentialFixture();
}, async () => {
await rejects(
activateWithPasskey(
"alice",
"synthetic-input-only",
new AbortController().signal,
),
);
equal(paths, [
"/api/auth/activation/options",
...(stage === "prompt" ? [] : ["/api/auth/activation/complete"]),
...(stage === "confirmation" ? ["/api/auth/whoami"] : []),
]);
});
},
);
}
for (
const phase of ["options", "prompt", "complete", "confirmation"] as const
) {
for (const reason of ["logout", "authentication"] as const) {
Deno.test(`activation fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
const previous = Object.getOwnPropertyDescriptor(
globalThis,
"localStorage",
);
let stamp = "initial-operation-stamp";
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => stamp },
});
const generation = browserSession.authenticationGeneration;
const paths: string[] = [];
try {
await boundary(async (input) => {
const path = String(input);
paths.push(path);
if (
(phase === "options" && path.endsWith("/options")) ||
(phase === "complete" && path.endsWith("/complete")) ||
(phase === "confirmation" && path === "/api/auth/whoami")
) {
stamp = JSON.stringify({ id: "other-tab", reason });
}
return Response.json(
path.endsWith("/options")
? registrationOptions
: path.endsWith("/complete")
? {
user: authUser,
authentication_reference: "fixture-authentication",
}
: confirmedSession(),
);
}, async () => {
if (phase === "prompt") {
stamp = JSON.stringify({ id: "other-tab", reason });
}
return credentialFixture();
}, async () => {
await rejects(
activateWithPasskey(
"alice",
"synthetic-input-only",
new AbortController().signal,
),
);
equal(browserSession.authenticationGeneration, generation);
equal(stamp === "initial-operation-stamp", false);
equal(
paths.filter((path) => path.endsWith("/complete")).length,
phase === "complete" || phase === "confirmation" ? 1 : 0,
);
equal(paths.includes("/api/auth/whoami"), phase === "confirmation");
});
} finally {
if (previous) {
Object.defineProperty(
globalThis,
"localStorage",
previous,
);
} else Reflect.deleteProperty(globalThis, "localStorage");
}
});
}
}
@@ -2,7 +2,8 @@ import type {
ActivationOptionsRequest,
PasskeyRegistrationCompleteRequest,
} from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts";
import { loadWhoami, readBoundedAuthResponseJson } from "./api.ts";
import { browserSession } from "./session-state.ts";
import { normalizeActivationHandle } from "./hosting-accounts.ts";
import {
type AuthUser,
@@ -19,6 +20,16 @@ export async function activateWithPasskey(
code: string,
signal: AbortSignal,
): Promise<AuthUser> {
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
// 別tabのstorage event配送より先にcredential/応答が返っても送信・適用しない。
const assertCurrent = () => {
signal.throwIfAborted();
if (
generation !== browserSession.authenticationGeneration ||
stamp !== browserSession.authenticationStamp
) throw new Error("Activation superseded");
};
try {
handle = normalizeActivationHandle(handle);
if (window.isSecureContext !== true || !code || code.length > 4096) {
@@ -28,7 +39,7 @@ export async function activateWithPasskey(
path: "/api/auth/activation/options" | "/api/auth/activation/complete",
body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest,
): Promise<unknown> => {
signal.throwIfAborted();
assertCurrent();
const response = await fetch(path, {
method: "POST",
credentials: "same-origin",
@@ -44,7 +55,9 @@ export async function activateWithPasskey(
await response.body?.cancel();
throw new Error("Activation rejected");
}
return await readBoundedAuthResponseJson(response);
const result = await readBoundedAuthResponseJson(response);
assertCurrent();
return result;
};
const options = await request("/api/auth/activation/options", {
handle,
@@ -54,22 +67,33 @@ export async function activateWithPasskey(
code = "";
const publicKey = prepareRegistrationOptions(options);
const challengeId = (options as { challenge_id: string }).challenge_id;
signal.throwIfAborted();
assertCurrent();
const credential = await navigator.credentials.create({
publicKey,
signal,
});
if (!credential) throw new Error("Registration cancelled");
signal.throwIfAborted();
assertCurrent();
const result = parseAuthUserResponse(
await request("/api/auth/activation/complete", {
challenge_id: challengeId,
credential: registrationCredentialToJson(credential),
}),
).user;
signal.throwIfAborted();
if (result.handle !== handle) throw new Error("Activation target mismatch");
return result;
);
assertCurrent();
if (result.user.handle !== handle) {
throw new Error("Activation target mismatch");
}
const current = await loadWhoami(signal);
assertCurrent();
if (
current.session.kind !== "authenticated" ||
current.actor?.auth_method !== "browser_session" ||
current.actor.user_id !== result.user.user_id ||
current.actor.account_id !== result.user.account_id ||
current.authentication_reference !== result.authentication_reference
) throw new Error("Activation session unconfirmed");
return result.user;
} catch {
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
throw new Error("Activation could not be confirmed.");
@@ -67,6 +67,7 @@ export function credentialFixture(authentication = false): Credential {
export function whoamiPayload(actor: unknown = null) {
return {
actor,
authentication_reference: actor === null ? null : "fixture-authentication",
session: actor === null
? { kind: "anonymous" }
: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
+217 -88
View File
@@ -1,6 +1,7 @@
import type {
DeviceLoginApproveRequest,
PasskeyAdditionOptionsRequest,
PasskeyAdditionResultRequest,
PasskeyLoginCompleteRequest,
PasskeyLoginOptionsRequest,
PasskeyRegistrationCompleteRequest,
@@ -8,24 +9,38 @@ import type {
} from "#lib/generated/auth-api.ts";
import {
authenticationCredentialToJson,
type AuthUser,
AuthPayloadError,
type DeviceApprovalResponse,
parseAdditionResult,
parseAuthUserResponse,
parseDeviceApprovalResponse,
parseLogoutResponse,
parsePasskeyList,
parseWhoamiResponse,
type PasskeyUserResponse,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
type WhoamiResponse,
} from "#lib/workspace/auth/model.ts";
import {
authCheckpoint,
AuthOperationError,
type AuthStage,
classifyAuthFailure,
expectedAuthSession,
recordAdditionAttempt,
} from "./operation.ts";
export class AuthRequestError extends Error {
constructor(readonly status: number) {
constructor(readonly status: number, readonly code: string | null = null) {
super(`Auth request failed (${status}).`);
}
}
class AuthResponseError extends Error {}
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
export async function readBoundedAuthResponseJson(
@@ -39,13 +54,15 @@ export async function readBoundedAuthResponseJson(
parsed > MAX_AUTH_RESPONSE_BYTES
) {
await response.body?.cancel();
throw new Error(
throw new AuthResponseError(
"Invalid auth response: response body exceeds the size limit.",
);
}
}
if (response.body === null) {
throw new Error("Invalid auth response: response body is missing.");
throw new AuthResponseError(
"Invalid auth response: response body is missing.",
);
}
const reader = response.body.getReader();
@@ -58,7 +75,7 @@ export async function readBoundedAuthResponseJson(
totalBytes += value.byteLength;
if (totalBytes > MAX_AUTH_RESPONSE_BYTES) {
await reader.cancel();
throw new Error(
throw new AuthResponseError(
"Invalid auth response: response body exceeds the size limit.",
);
}
@@ -79,7 +96,9 @@ export async function readBoundedAuthResponseJson(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
) as unknown;
} catch {
throw new Error("Invalid auth response: response body is not valid JSON.");
throw new AuthResponseError(
"Invalid auth response: response body is not valid JSON.",
);
}
}
@@ -96,8 +115,27 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
},
});
if (!response.ok) {
await response.body?.cancel();
throw new AuthRequestError(response.status);
let code: string | null = null;
try {
const value = await readBoundedAuthResponseJson(response);
const message = typeof value === "object" && value !== null
? (value as { message?: unknown }).message
: null;
if (typeof message === "string") {
code = [
"auth_required",
"passkey_authentication_changed",
"passkey_registration_binding_mismatch",
"webauthn_registration_verification_failed",
"webauthn_login_verification_failed",
"invalid_passkey_challenge",
"expired_passkey_challenge",
"self_registration_disabled",
"passkey_activation_required",
].find((code) => message.startsWith(code + ":")) ?? null;
}
} catch { /* 本文・解析例外は保存しない。statusだけでも失敗として扱う。 */ }
throw new AuthRequestError(response.status, code);
}
return await readBoundedAuthResponseJson(response);
}
@@ -122,7 +160,7 @@ export async function registerPasskey(
handle: string,
displayName?: string,
signal?: AbortSignal,
): Promise<AuthUser> {
): Promise<PasskeyUserResponse> {
const optionsRequest: PasskeyRegistrationOptionsRequest = {
handle,
display_name: displayName ?? null,
@@ -131,8 +169,21 @@ export async function registerPasskey(
return await createPasskey("registration", optionsRequest, signal);
}
export async function addPasskey(signal?: AbortSignal): Promise<AuthUser> {
export async function addPasskey(
signal?: AbortSignal,
): Promise<PasskeyUserResponse> {
const expected = expectedAuthSession(signal) ?? await loadWhoami(signal);
if (!expected.actor || !expected.authentication_reference) {
throw new AuthOperationError(
"session-check",
"session-changed",
"not-sent",
);
}
const optionsRequest: PasskeyAdditionOptionsRequest = {
expected_user_id: expected.actor.user_id,
expected_account_id: expected.actor.account_id,
expected_authentication_reference: expected.authentication_reference,
browser_origin: window.location.origin,
};
return await createPasskey("addition", optionsRequest, signal);
@@ -144,51 +195,88 @@ async function createPasskey(
| PasskeyRegistrationOptionsRequest
| PasskeyAdditionOptionsRequest,
signal?: AbortSignal,
): Promise<AuthUser> {
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
method: "POST",
body: JSON.stringify(optionsRequest),
signal,
});
const optionsRecord = typeof options === "object" && options !== null
? options as Record<string, unknown>
: null;
const challengeId = optionsRecord?.challenge_id;
if (typeof challengeId !== "string" || challengeId.length === 0) {
throw new Error(
"Invalid auth payload: registration_options.challenge_id is required.",
);
}
signal?.throwIfAborted();
if (typeof navigator.credentials?.create !== "function") {
throw new DOMException("Passkeys unavailable", "NotSupportedError");
}
const credential = await navigator.credentials.create({
signal,
publicKey: prepareRegistrationOptions(options),
});
if (!credential) {
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted();
const completeRequest: PasskeyRegistrationCompleteRequest = {
challenge_id: challengeId,
credential: registrationCredentialToJson(credential),
};
const result = parseAuthUserResponse(
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
): Promise<PasskeyUserResponse> {
let stage: AuthStage = "options";
let sent = false;
try {
await authCheckpoint(signal, stage);
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
method: "POST",
body: JSON.stringify(completeRequest),
body: JSON.stringify(optionsRequest),
signal,
}),
);
return result.user;
});
const publicKey = prepareRegistrationOptions(options);
const challengeId = (options as { challenge_id: string }).challenge_id;
stage = "authenticator";
await authCheckpoint(signal, stage);
if (typeof navigator.credentials?.create !== "function") {
throw new DOMException("", "NotSupportedError");
}
const credential = await navigator.credentials.create({
signal,
publicKey,
});
if (!credential) throw new DOMException("", "NotAllowedError");
signal?.throwIfAborted();
stage = "conversion";
await authCheckpoint(signal, stage);
const completeRequest: PasskeyRegistrationCompleteRequest = {
challenge_id: challengeId,
credential: registrationCredentialToJson(credential),
};
stage = "before-complete";
await authCheckpoint(signal, stage);
stage = "complete";
await authCheckpoint(signal, stage);
if (purpose === "addition") {
const expected = optionsRequest as PasskeyAdditionOptionsRequest;
recordAdditionAttempt(signal, {
challenge_id: challengeId,
expected_user_id: expected.expected_user_id,
expected_account_id: expected.expected_account_id,
expected_authentication_reference:
expected.expected_authentication_reference,
});
}
sent = true;
const result = parseAuthUserResponse(
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
method: "POST",
body: JSON.stringify(completeRequest),
signal,
}),
);
signal?.throwIfAborted();
return result;
} catch (error) {
if (error instanceof AuthRequestError) {
throw new AuthOperationError(
stage,
error.code === "auth_required" ||
error.code === "passkey_authentication_changed" ||
error.code === "passkey_registration_binding_mismatch"
? "session-changed"
: "rejected",
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
error.status,
);
}
if (
error instanceof AuthResponseError || error instanceof AuthPayloadError
) {
throw new AuthOperationError(
stage,
stage === "conversion" ? "conversion" : "invalid-response",
sent ? "unknown" : "not-sent",
);
}
throw classifyAuthFailure(error, stage, sent);
}
}
export async function loginWithPasskey(
signal?: AbortSignal,
): Promise<AuthUser> {
): Promise<PasskeyUserResponse> {
return await authenticateWithPasskey({
browser_origin: window.location.origin,
}, signal);
@@ -197,7 +285,7 @@ export async function loginWithPasskey(
export async function loginWithLegacyPasskey(
handle: string,
signal?: AbortSignal,
): Promise<AuthUser> {
): Promise<PasskeyUserResponse> {
const loginHandle = handle.trim();
if (!loginHandle) {
throw new Error("User handle is required for legacy passkey login.");
@@ -211,52 +299,77 @@ export async function loginWithLegacyPasskey(
async function authenticateWithPasskey(
optionsRequest: PasskeyLoginOptionsRequest,
signal?: AbortSignal,
): Promise<AuthUser> {
const options = await requestJson("/api/auth/passkeys/login/options", {
method: "POST",
body: JSON.stringify(optionsRequest),
signal,
});
const optionsRecord = typeof options === "object" && options !== null
? options as Record<string, unknown>
: null;
const challengeId = optionsRecord?.challenge_id;
if (typeof challengeId !== "string" || challengeId.length === 0) {
throw new Error(
"Invalid auth payload: login_options.challenge_id is required.",
);
}
signal?.throwIfAborted();
if (typeof navigator.credentials?.get !== "function") {
throw new DOMException("Passkeys unavailable", "NotSupportedError");
}
const credential = await navigator.credentials.get({
signal,
publicKey: prepareLoginOptions(options),
});
if (!credential) {
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted();
const completeRequest: PasskeyLoginCompleteRequest = {
challenge_id: challengeId,
credential: authenticationCredentialToJson(credential),
};
const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/login/complete", {
): Promise<PasskeyUserResponse> {
let stage: AuthStage = "options";
let sent = false;
try {
await authCheckpoint(signal, stage);
const options = await requestJson("/api/auth/passkeys/login/options", {
method: "POST",
body: JSON.stringify(completeRequest),
body: JSON.stringify(optionsRequest),
signal,
}),
);
return result.user;
});
const publicKey = prepareLoginOptions(options);
const challengeId = (options as { challenge_id: string }).challenge_id;
stage = "authenticator";
await authCheckpoint(signal, stage);
if (typeof navigator.credentials?.get !== "function") {
throw new DOMException("", "NotSupportedError");
}
const credential = await navigator.credentials.get({ signal, publicKey });
if (!credential) throw new DOMException("", "NotAllowedError");
signal?.throwIfAborted();
stage = "conversion";
await authCheckpoint(signal, stage);
const completeRequest: PasskeyLoginCompleteRequest = {
challenge_id: challengeId,
credential: authenticationCredentialToJson(credential),
};
stage = "before-complete";
await authCheckpoint(signal, stage);
stage = "complete";
await authCheckpoint(signal, stage);
sent = true;
const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/login/complete", {
method: "POST",
body: JSON.stringify(completeRequest),
signal,
}),
);
signal?.throwIfAborted();
return result;
} catch (error) {
if (error instanceof AuthRequestError) {
throw new AuthOperationError(
stage,
error.code === "auth_required" ||
error.code === "passkey_authentication_changed" ||
error.code === "passkey_registration_binding_mismatch"
? "session-changed"
: "rejected",
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
error.status,
);
}
if (
error instanceof AuthResponseError || error instanceof AuthPayloadError
) {
throw new AuthOperationError(
stage,
stage === "conversion" ? "conversion" : "invalid-response",
sent ? "unknown" : "not-sent",
);
}
throw classifyAuthFailure(error, stage, sent);
}
}
export async function approveDeviceLogin(
userCode: string,
signal?: AbortSignal,
): Promise<DeviceApprovalResponse> {
await authCheckpoint(signal, "before-complete");
const request: DeviceLoginApproveRequest = { user_code: userCode };
return parseDeviceApprovalResponse(
await requestJson("/api/auth/device-login/approve", {
@@ -266,3 +379,19 @@ export async function approveDeviceLogin(
}),
);
}
export async function loadPasskeys(signal?: AbortSignal) {
return parsePasskeyList(await requestJson("/api/auth/passkeys", { signal }));
}
export async function readAdditionResult(
request: PasskeyAdditionResultRequest,
signal?: AbortSignal,
) {
return parseAdditionResult(
await requestJson("/api/auth/passkeys/addition/result", {
method: "POST",
body: JSON.stringify(request),
signal,
}),
);
}
@@ -1,11 +1,18 @@
import { AuthRequestError } from "./api.ts";
import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
import { AuthRequestError, loadWhoami, readAdditionResult } from "./api.ts";
import {
browserSession,
observeBrowserSession,
sessionMutation,
} from "./session-state.ts";
import { notifySessionChanged } from "./hosting.ts";
import type { RequestActor, WhoamiResponse } from "./model.ts";
import type { AuthUser, RequestActor, WhoamiResponse } from "./model.ts";
import {
AuthOperationError,
type AuthStage,
observeAuthOperation,
operationErrorMessage,
} from "./operation.ts";
export type BrowserSession =
| { kind: "loading" }
@@ -13,41 +20,60 @@ export type BrowserSession =
| ({ kind: "ready" } & WhoamiResponse);
export function authErrorMessage(error: unknown): string {
if (error instanceof AuthOperationError) return operationErrorMessage(error);
if (error instanceof AuthRequestError) {
if (error.status === 401) {
return "Authentication request was rejected. Check your session or restart the Passkey operation.";
}
if (error.status === 400 || error.status === 410) {
return "The request or authentication challenge is invalid or expired. Restart the operation.";
}
return error.message;
return `Authentication request was rejected (${error.status}). Check your session or restart the operation.`;
}
if (error instanceof DOMException && error.name === "NotSupportedError") {
return "This browser or key cannot complete this passkey operation. Use a supported device or key with saved passkeys and PIN or biometric verification. Completion is not confirmed.";
}
if (error instanceof DOMException && error.name === "NotAllowedError") {
return "Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.";
}
if (error instanceof DOMException && error.name === "AbortError") {
return "Operation stopped. Completion is not confirmed; check your session before retrying.";
}
// Browser/transport例外の本文を表示せず、credentialや外部応答を診断へ複写しない。
return "Authentication could not be confirmed. Check your session before retrying the operation.";
// 例外本文/causeや外部応答は表示にも診断にも複写しない。
return "Authentication could not be confirmed. Check your session and the operation result before retrying.";
}
function sameUser(a: AuthUser | null, b: AuthUser | null): boolean {
return a === null
? b === null
: b !== null && a.user_id === b.user_id && a.account_id === b.account_id;
}
function sameSession(a: WhoamiResponse, b: WhoamiResponse): boolean {
// anonymous/expiredはいずれも認証主体もbindingもない。別tabのlogoutでexpiredの
// 表示だけが古くなっても、新たな明示loginを拒否しない。認証済みへの変化は拒否する。
if (
!a.actor && !b.actor && a.session.kind !== "authenticated" &&
b.session.kind !== "authenticated"
) return true;
return sameUser(a.actor, b.actor) && a.session.kind === b.session.kind &&
(a.session.kind !== "authenticated" ||
a.authentication_reference === b.authentication_reference &&
b.session.kind === "authenticated" &&
a.session.expires_at === b.session.expires_at &&
a.actor?.auth_method === b.actor?.auth_method);
}
function returnedUser(value: unknown): AuthUser | null {
if (typeof value !== "object" || value === null) return null;
const user = (value as { user?: Partial<AuthUser> }).user;
return user && typeof user.user_id === "string" &&
typeof user.account_id === "string" && typeof user.handle === "string" &&
typeof user.display_name === "string"
? user as AuthUser
: null;
}
export class BrowserAuth {
session = $state<BrowserSession>({ kind: "loading" });
busy = $state(false);
error = $state("");
status = $state("");
additionAttempt = $state<PasskeyAdditionResultRequest | null>(null);
additionSaved = $state(false);
private unsubscribe: () => void;
private disposed = false;
private controller: AbortController | null = null;
private operationGeneration: number | null = null;
constructor() {
observeBrowserSession();
this.unsubscribe = browserSession.subscribe((snapshot) => {
// 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。
if (
this.operationGeneration !== null &&
this.operationGeneration !== browserSession.authenticationGeneration
) this.controller?.abort();
if (this.busy && snapshot.kind === "loading") return;
if (!this.disposed) {
this.session = snapshot.kind === "ready"
@@ -56,75 +82,239 @@ export class BrowserAuth {
}
});
}
get actor(): RequestActor | null {
return this.session.kind === "ready" ? this.session.actor : null;
}
async ensure(): Promise<void> {
if (this.busy || this.disposed) return;
await browserSession.ensure();
if (!this.busy && !this.disposed) await browserSession.ensure();
}
async refresh(): Promise<void> {
if (this.busy || this.disposed) return;
await browserSession.refresh();
if (!this.busy && !this.disposed) await browserSession.refresh();
}
cancel(): void {
this.controller?.abort();
}
async run(
pending: string,
success: string,
operation: (signal: AbortSignal) => Promise<unknown>,
intent: "authentication" | "logout" = "authentication",
intent: "authentication" | "logout" | "device-approval" = "authentication",
): Promise<boolean> {
if (
this.busy || this.disposed ||
(intent !== "logout" && this.session.kind !== "ready")
) {
return false;
}
) return false;
const mutationReason = intent === "logout" ? "logout" : "authentication";
const expected = this.session.kind === "ready" ? this.session : null;
this.busy = true;
sessionMutation(intent);
const authenticationGeneration = browserSession.authenticationGeneration;
sessionMutation(mutationReason, true);
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
let acceptedGeneration = generation;
let acceptedStamp = stamp;
let submittedAddition = false;
const controller = new AbortController();
this.controller = controller;
this.operationGeneration = generation;
this.error = "";
this.status = pending;
const verifySession = async () => {
if (stamp !== browserSession.authenticationStamp) controller.abort();
controller.signal.throwIfAborted();
let current: WhoamiResponse;
try {
current = await loadWhoami(controller.signal);
} catch {
throw new AuthOperationError(
"session-check",
"session-changed",
"not-sent",
);
}
if (stamp !== browserSession.authenticationStamp) controller.abort();
controller.signal.throwIfAborted();
if (!expected || !sameSession(expected, current)) {
throw new AuthOperationError(
"session-check",
"session-changed",
"not-sent",
);
}
};
observeAuthOperation(controller.signal, {
verifySession,
expected,
additionAttempt: (attempt) => {
submittedAddition = true;
this.additionAttempt = attempt;
this.additionSaved = false;
},
phase: (stage: AuthStage) => {
if (!this.disposed) {
this.status = stage === "authenticator"
? pending
: stage === "complete"
? "Confirming the operation with the server…"
: stage === "before-complete"
? "Checking the account before completion…"
: stage === "options"
? "Checking the account and requesting passkey options…"
: "Preparing the authenticator response…";
}
},
});
try {
await operation(controller.signal);
const superseded =
authenticationGeneration !== browserSession.authenticationGeneration;
sessionMutation(intent);
if (intent === "device-approval") await verifySession();
const result = await operation(controller.signal);
const user = returnedUser(result);
const issuedReference = typeof result === "object" && result !== null
? (result as { authentication_reference?: unknown })
.authentication_reference
: null;
controller.signal.throwIfAborted();
if (
this.disposed ||
generation !== browserSession.authenticationGeneration ||
stamp !== browserSession.authenticationStamp
) return false;
// 自身の再確認によるinvalidateと、他の操作が行ったinvalidateを混同しない。
this.operationGeneration = null;
sessionMutation(mutationReason, true);
const confirmationGeneration = browserSession.authenticationGeneration;
acceptedGeneration = confirmationGeneration;
const confirmationStamp = browserSession.authenticationStamp;
acceptedStamp = confirmationStamp;
this.status = "Confirming the expected browser session…";
await browserSession.refresh();
if (
this.disposed || superseded ||
confirmationGeneration !== browserSession.authenticationGeneration
this.disposed || controller.signal.aborted ||
confirmationGeneration !== browserSession.authenticationGeneration ||
confirmationStamp !== browserSession.authenticationStamp
) {
if (!this.disposed) this.status = "";
return false;
}
const snapshot = browserSession.snapshot;
const confirmed = snapshot.kind === "ready" &&
(intent === "logout"
? snapshot.value.actor === null &&
snapshot.value.session.kind !== "authenticated"
: intent === "device-approval"
? snapshot.value.session.kind === "authenticated" && user !== null &&
sameUser(user, expected?.actor ?? null) &&
sameUser(user, snapshot.value.actor) &&
snapshot.value.authentication_reference ===
expected?.authentication_reference &&
typeof result === "object" && result !== null &&
(result as { status?: unknown }).status === "approved"
: snapshot.value.session.kind === "authenticated" &&
snapshot.value.actor?.auth_method === "browser_session" &&
user !== null && typeof issuedReference === "string" &&
issuedReference === snapshot.value.authentication_reference &&
sameUser(user, snapshot.value.actor) &&
(!expected?.actor || sameUser(expected.actor, user)));
if (!confirmed) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"saved-session-unconfirmed",
);
}
notifySessionChanged();
const confirmed = this.session.kind === "ready";
this.status = confirmed ? success : "";
return confirmed;
if (submittedAddition) this.additionSaved = true;
this.status = success;
return true;
} catch (error) {
if (!this.disposed) {
this.error = authErrorMessage(error);
this.status = "";
}
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
sessionMutation(intent);
await browserSession.refresh();
// 古い処理から新しいログアウト/主体確認をinvalidateしない。
if (
!this.disposed &&
acceptedGeneration === browserSession.authenticationGeneration &&
acceptedStamp === browserSession.authenticationStamp
) {
this.operationGeneration = null;
sessionMutation(mutationReason, true);
await browserSession.refresh();
}
return false;
} finally {
this.operationGeneration = null;
this.controller = null;
if (!this.disposed) this.busy = false;
}
}
async checkAddition(): Promise<void> {
const attempt = this.additionAttempt;
if (!attempt || this.busy || this.disposed) return;
this.busy = true;
this.error = "";
this.status = "Checking the saved addition result…";
// 保存の履歴は失敗に戻さないが、過去の照合で再追加を許可しない。
this.additionSaved = false;
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
const controller = new AbortController();
this.controller = controller;
this.operationGeneration = generation;
const assertCurrent = () => {
controller.signal.throwIfAborted();
if (
this.disposed ||
generation !== browserSession.authenticationGeneration ||
stamp !== browserSession.authenticationStamp
) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"unknown",
);
}
};
try {
const before = await loadWhoami(controller.signal);
assertCurrent();
if (
!before.actor || before.actor.user_id !== attempt.expected_user_id ||
before.actor.account_id !== attempt.expected_account_id
) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"unknown",
);
}
const result = await readAdditionResult(attempt, controller.signal);
assertCurrent();
const after = await loadWhoami(controller.signal);
assertCurrent();
if (!sameSession(before, after)) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"unknown",
);
}
if (result.status === "saved" && sameUser(result.user, before.actor)) {
this.additionSaved = true;
this.status =
"This addition saved a key to your account. Your current session was checked; the session issued by completion is not confirmed. Test ID-free login with that key before retiring older-key access.";
} else {this.status =
"The addition result is still unknown. Completion may still be in flight. Do not add another key yet; check again or use the registered-key list and ID-free login to verify access.";}
} catch {
if (!this.disposed) {
this.busy = false;
this.controller = null;
this.error =
"The addition result could not be read with the current account/session. This does not prove failure. Return to the intended account and check its key list or ID-free login; do not recreate the original session or add blindly.";
this.status = "";
}
} finally {
this.operationGeneration = null;
this.controller = null;
if (!this.disposed) this.busy = false;
}
}
dispose(): void {
this.disposed = true;
this.unsubscribe();
@@ -1,7 +1,7 @@
import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts";
import { HostingRequestError } from "./hosting.ts";
import { parseAuthUserResponse } from "./model.ts";
import { parseAuthUser } from "./model.ts";
// Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。
export function normalizeActivationHandle(value: string): string {
@@ -57,7 +57,7 @@ export async function issueActivationCode(
typeof record.expires_at !== "string" ||
!Number.isFinite(Date.parse(record.expires_at))
) throw new Error();
const user = parseAuthUserResponse({ user: record.user }).user;
const user = parseAuthUser(record.user);
if (user.handle !== handle) throw new Error();
signal.throwIfAborted();
return { user, code: record.code, expires_at: record.expires_at };
@@ -7,6 +7,16 @@ import {
registrationOptions,
} from "./api.test-fixtures.ts";
const bearerIdentity = {
actor: { ...authUser, auth_method: "api_token" },
authentication_reference: "fixture-bearer-authentication",
session: { kind: "authenticated", expires_at: null },
};
const completion = {
user: authUser,
authentication_reference: "fixture-issued-authentication",
};
function equal(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Values differ");
@@ -65,9 +75,11 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
await boundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
String(input).endsWith("/options")
String(input).endsWith("/whoami")
? bearerIdentity
: String(input).endsWith("/options")
? registrationOptions
: { user: authUser },
: completion,
);
}, async () => {
equal(
@@ -75,11 +87,13 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
authUser,
);
equal(requests.map((request) => request.path), [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
for (const { init } of requests) {
equal(init.method, "POST");
for (const { path, init } of requests) {
equal(init.method, path.endsWith("/whoami") ? "GET" : "POST");
equal(new Headers(init.headers).has("cookie"), false);
equal(init.credentials, "omit");
equal(init.mode, "same-origin");
equal(init.redirect, "error");
@@ -91,10 +105,15 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
);
equal(String(init.body).includes("host-test-token"), false);
}
equal(JSON.parse(String(requests[0].init.body)), {
equal(requests[0].init.body, undefined);
equal(JSON.parse(String(requests[1].init.body)), {
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference:
bearerIdentity.authentication_reference,
browser_origin: "https://yoi.example",
});
const complete = JSON.parse(String(requests[1].init.body));
const complete = JSON.parse(String(requests[2].init.body));
equal(Object.keys(complete).sort(), ["challenge_id", "credential"]);
equal(complete.challenge_id, registrationOptions.challenge_id);
equal(complete.credential.response.attestationObject, "AQID");
@@ -151,29 +170,46 @@ Deno.test("host enrollment validates options before opening credential prompt",
}]
) {
let calls = 0;
await boundary(async () => Response.json(payload), async () => {
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(calls, 0);
}, async () => {
calls++;
return credentialFixture();
});
await boundary(
async (input) =>
Response.json(
String(input).endsWith("/whoami") ? bearerIdentity : payload,
),
async () => {
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(calls, 0);
},
async () => {
calls++;
return credentialFixture();
},
);
}
});
Deno.test("host enrollment cancellation and teardown prevent completion", async () => {
for (const abort of [false, true]) {
const controller = new AbortController();
let requests = 0;
await boundary(async () => {
requests++;
return Response.json(registrationOptions);
const requests: string[] = [];
let prompts = 0;
await boundary(async (input) => {
requests.push(String(input));
return Response.json(
String(input).endsWith("/whoami")
? bearerIdentity
: registrationOptions,
);
}, async () => {
await rejects(enrollHostingPasskey("host-test-token", controller.signal));
equal(requests, 1);
equal(requests, [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
]);
equal(prompts, 1);
}, async () => {
prompts++;
if (abort) {
controller.abort();
return credentialFixture();
@@ -188,6 +224,9 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
let calls = 0;
await boundary(async (input) => {
calls++;
if (String(input).endsWith("/whoami")) {
return Response.json(bearerIdentity);
}
if (String(input).endsWith("/options")) {
return Response.json(registrationOptions);
}
@@ -201,7 +240,7 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(calls, 2);
equal(calls, 3);
});
}
});
@@ -6,6 +6,7 @@ import { readBoundedAuthResponseJson } from "./api.ts";
import {
type AuthUser,
parseAuthUserResponse,
parseWhoamiResponse,
prepareRegistrationOptions,
registrationCredentialToJson,
} from "./model.ts";
@@ -26,13 +27,14 @@ export async function enrollHostingPasskey(
}
const request = async (
path:
| "/api/auth/whoami"
| "/api/auth/passkeys/addition/options"
| "/api/auth/passkeys/addition/complete",
body: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
body?: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
): Promise<unknown> => {
signal.throwIfAborted();
const response = await fetch(path, {
method: "POST",
method: path === "/api/auth/whoami" ? "GET" : "POST",
// Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、
// completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。
credentials: "omit",
@@ -53,7 +55,12 @@ export async function enrollHostingPasskey(
}
return await readBoundedAuthResponseJson(response);
};
const identity = parseWhoamiResponse(await request("/api/auth/whoami"));
if (!identity.actor || !identity.authentication_reference || identity.actor.auth_method !== "api_token") throw new Error("Setup identity unconfirmed");
const options = await request("/api/auth/passkeys/addition/options", {
expected_user_id: identity.actor.user_id,
expected_account_id: identity.actor.account_id,
expected_authentication_reference: identity.authentication_reference,
browser_origin: window.location.origin,
});
const publicKey = prepareRegistrationOptions(options);
@@ -4,6 +4,7 @@ declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import { AuthOperationError } from "./operation.ts";
import { loginWithLegacyPasskey, loginWithPasskey } from "./api.ts";
import {
authUser,
@@ -64,7 +65,12 @@ async function boundary(
Response.json(body.handle ? legacyLoginOptions : loginOptions),
);
}
return Promise.resolve(Response.json({ user: authUser }));
return Promise.resolve(
Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
}),
);
},
},
});
@@ -97,7 +103,7 @@ for (const legacy of [false, true]) {
await (legacy
? loginWithLegacyPasskey(" alice ", signal)
: loginWithPasskey(signal)),
authUser,
{ user: authUser, authentication_reference: "fixture-authentication" },
);
equal(state.requests.map(({ path }) => path), [
"/api/auth/passkeys/login/options",
@@ -131,8 +137,10 @@ for (const legacy of [false, true]) {
: loginWithPasskey(controller.signal));
throw new Error("Unexpected login success");
} catch (error) {
if (!(error instanceof DOMException)) throw error;
equal(error.name, late ? "AbortError" : "NotAllowedError");
if (!(error instanceof AuthOperationError)) throw error;
equal(error.stage, "authenticator");
equal(error.reason, late ? "stopped" : "cancelled-or-unavailable");
equal(error.outcome, "not-sent");
}
equal(state.requests.length, 1);
});
@@ -2,6 +2,7 @@ import {
authenticationCredentialToJson,
base64UrlToBuffer,
bufferToBase64Url,
parseAuthUserResponse,
parseDeviceLoginPollResponse,
parseDeviceLoginStartResponse,
parseWhoamiResponse,
@@ -119,12 +120,21 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
Deno.test("whoami rejects unknown auth methods and extra fields", () => {
assertEquals(
parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }),
{ actor: null, session: { kind: "anonymous" } },
parseWhoamiResponse({
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
}),
{
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
},
);
assertThrows(
() =>
parseWhoamiResponse({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: {
user_id: "user-1",
@@ -137,7 +147,12 @@ Deno.test("whoami rejects unknown auth methods and extra fields", () => {
"unknown auth method",
);
assertThrows(
() => parseWhoamiResponse({ actor: null, token: "secret" }),
() =>
parseWhoamiResponse({
actor: null,
authentication_reference: null,
token: "secret",
}),
"unexpected whoami field",
);
});
@@ -197,7 +212,11 @@ Deno.test("auth validation enforces cumulative budgets without echoing unknown k
const attackerKey = `secret-${"x".repeat(512)}`;
try {
parseWhoamiResponse({ actor: null, [attackerKey]: true });
parseWhoamiResponse({
actor: null,
authentication_reference: null,
[attackerKey]: true,
});
throw new Error("expected an error");
} catch (error) {
if (!(error instanceof Error) || error.message.includes(attackerKey)) {
@@ -248,23 +267,153 @@ Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", ()
};
for (
const payload of [
{ actor, session: { kind: "anonymous" } },
{ actor, session: { kind: "expired" } },
{ actor, authentication_reference: null, session: { kind: "anonymous" } },
{ actor, authentication_reference: null, session: { kind: "expired" } },
{
actor: null,
authentication_reference: null,
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
},
{ actor, session: { kind: "authenticated", expires_at: null } },
{ actor, session: { kind: "authenticated", expires_at: "tomorrow" } },
{ actor: null, session: { kind: "future" } },
{
actor,
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: null },
},
{
actor,
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "tomorrow" },
},
{
actor: null,
authentication_reference: null,
session: { kind: "future" },
},
{
actor: null,
authentication_reference: null,
session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" },
},
]
) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami");
assertEquals(
parseWhoamiResponse({ actor: null, session: { kind: "expired" } }),
{ actor: null, session: { kind: "expired" } },
parseWhoamiResponse({
actor: null,
authentication_reference: null,
session: { kind: "expired" },
}),
{
actor: null,
authentication_reference: null,
session: { kind: "expired" },
},
);
});
Deno.test("whoami and completion require explicit consistent authentication references", () => {
const user = {
user_id: "u",
account_id: "a",
handle: "alice",
display_name: "Alice",
};
const authenticated = {
actor: { ...user, auth_method: "browser_session" },
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
} as const;
for (const reference of [undefined, null, "", 42, "x".repeat(16385)]) {
assertThrows(
() =>
parseWhoamiResponse({
...authenticated,
authentication_reference: reference,
}),
"invalid authenticated reference",
);
assertThrows(
() =>
parseAuthUserResponse({ user, authentication_reference: reference }),
"invalid issuance reference",
);
}
for (const kind of ["anonymous", "expired"]) {
assertThrows(
() => parseWhoamiResponse({ actor: null, session: { kind } }),
"missing anonymous reference",
);
assertThrows(
() =>
parseWhoamiResponse({
actor: null,
authentication_reference: "issued",
session: { kind },
}),
"anonymous reference must be null",
);
}
const completion = { user, authentication_reference: "issued" };
assertEquals(parseAuthUserResponse(completion), completion);
assertEquals(
parseWhoamiResponse({
...authenticated,
authentication_reference: "issued",
}),
{
actor: authenticated.actor,
authentication_reference: "issued",
session: authenticated.session,
},
);
});
Deno.test("bounded future transports do not discard a created credential and required crypto bytes stay validated", () => {
const bytes = new Uint8Array([1, 2, 3]).buffer;
const credential = {
id: "AQID",
rawId: bytes,
type: "public-key",
getClientExtensionResults: () => ({
credProps: { rk: true },
prf: { results: { first: bytes } },
largeBlob: { blob: bytes },
}),
response: {
clientDataJSON: bytes,
attestationObject: bytes,
getTransports: () => ["internal", "hybrid", "future-bounded-transport"],
},
};
const converted = registrationCredentialToJson(credential) as {
response: { transports: string[] };
clientExtensionResults: unknown;
};
assertEquals(converted.response.transports, ["internal", "hybrid"]);
assertEquals(converted.clientExtensionResults, { credProps: { rk: true } });
assertThrows(
() =>
registrationCredentialToJson({
...credential,
response: { ...credential.response, clientDataJSON: "not-binary" },
}),
"required client data remains binary",
);
assertThrows(
() =>
registrationCredentialToJson({
...credential,
response: {
...credential.response,
getTransports: () => ["x".repeat(16385)],
},
}),
"transport bound remains enforced",
);
assertThrows(
() =>
registrationCredentialToJson({
...credential,
getClientExtensionResults: () => ({ credProps: { rk: bytes } }),
}),
"supported JSON extension never silently converts binary to an empty object",
);
});
+66 -22
View File
@@ -8,6 +8,9 @@ import type {
DeviceLoginPollStatus,
DeviceLoginStartResponse,
LogoutResponse,
PasskeyListResponse,
PasskeySummary,
PasskeyAdditionResultResponse,
PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse,
RequestActor,
@@ -21,6 +24,9 @@ export type {
AuthPublicConfig,
DeviceLoginPollResponse,
DeviceLoginStartResponse,
PasskeyListResponse,
PasskeySummary,
PasskeyAdditionResultResponse,
PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse,
RequestActor,
@@ -37,8 +43,10 @@ const MAX_DEVICE_LOGIN_SECONDS = 24 * 60 * 60;
const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000;
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/;
export class AuthPayloadError extends Error {}
function invalid(path: string, reason: string): never {
throw new Error(`Invalid auth payload: ${path} ${reason}.`);
throw new AuthPayloadError(`Invalid auth payload: ${path} ${reason}.`);
}
function asRecord(value: unknown, path: string): Record<string, unknown> {
@@ -119,6 +127,7 @@ function boundedJson(
if (budget.remainingNodes < 0) invalid(path, "has too many values");
if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested");
if (value === null || typeof value === "boolean") return;
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) invalid(path, "contains unsupported binary data");
if (typeof value === "string") {
boundedString(value, path, { allowEmpty: true });
budget.remainingStringUnits -= value.length;
@@ -205,10 +214,10 @@ export function bufferToBase64Url(value: ArrayBuffer): string {
function parseAuthenticatorTransport(
value: unknown,
path: string,
): AuthenticatorTransport {
): AuthenticatorTransport | null {
const transport = boundedString(value, path);
if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) {
invalid(path, "contains an unknown authenticator transport");
return null;
}
return transport as AuthenticatorTransport;
}
@@ -229,9 +238,10 @@ function parseCredentialDescriptor(
if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) {
invalid(`${path}.transports`, "has too many items");
}
return descriptor.transports.map((transport, index) =>
parseAuthenticatorTransport(transport, `${path}.transports[${index}]`)
);
return descriptor.transports.flatMap((transport, index) => {
const parsed = parseAuthenticatorTransport(transport, `${path}.transports[${index}]`);
return parsed === null ? [] : [parsed];
});
})();
return {
type: "public-key",
@@ -463,7 +473,8 @@ function parseActor(value: unknown, path: string): RequestActor {
export function parseWhoamiResponse(value: unknown): WhoamiResponse {
const response = asRecord(value, "whoami");
requireExactKeys(response, "whoami", ["actor", "session"]);
requireExactKeys(response, "whoami", ["actor", "session", "authentication_reference"]);
const authentication_reference = optionalString(response.authentication_reference, "whoami.authentication_reference");
const actor = response.actor === null
? null
: parseActor(response.actor, "whoami.actor");
@@ -487,7 +498,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
"must identify the server session expiry",
);
}
return { actor, session: { kind: "authenticated", expires_at: expiry } };
if (authentication_reference === null) invalid("whoami", "authenticated session requires comparison evidence");
return { actor, authentication_reference, session: { kind: "authenticated", expires_at: expiry } };
}
requireExactKeys(session, "whoami.session", ["kind"]);
if (
@@ -496,7 +508,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
) {
invalid("whoami", "inconsistent actor or session state");
}
return { actor: null, session: { kind: session.kind } };
if (authentication_reference !== null) invalid("whoami", "anonymous session cannot identify authentication");
return { actor: null, authentication_reference: null, session: { kind: session.kind } };
}
export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
@@ -537,10 +550,38 @@ export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
return result;
}
export function parseAuthUser(value: unknown): AuthUser {
return parseAuthenticatedUser(value, "auth_user.user");
}
export function parseAuthUserResponse(value: unknown): PasskeyUserResponse {
const response = asRecord(value, "auth_user");
requireExactKeys(response, "auth_user", ["user"]);
return { user: parseAuthenticatedUser(response.user, "auth_user.user") };
requireExactKeys(response, "auth_user", ["user", "authentication_reference"]);
return { user: parseAuthenticatedUser(response.user, "auth_user.user"), authentication_reference: boundedString(response.authentication_reference, "auth_user.authentication_reference") };
}
function parsePasskeySummary(value: unknown): PasskeySummary {
const key = asRecord(value, "passkey_summary");
requireExactKeys(key, "passkey_summary", ["key_reference", "created_at", "last_used_at", "normal_login_verified_at"]);
const date = (value: unknown): string => {
const text = boundedString(value, "passkey_summary.timestamp");
if (!Number.isFinite(Date.parse(text))) invalid("passkey_summary.timestamp", "must be a timestamp");
return text;
};
return { key_reference: boundedString(key.key_reference, "passkey_summary.key_reference"), created_at: date(key.created_at), last_used_at: key.last_used_at === null ? null : date(key.last_used_at), normal_login_verified_at: key.normal_login_verified_at === null ? null : date(key.normal_login_verified_at) };
}
export function parsePasskeyList(value: unknown): PasskeyListResponse {
const list = asRecord(value, "passkey_list");
requireExactKeys(list, "passkey_list", ["user", "keys"]);
if (!Array.isArray(list.keys) || list.keys.length > MAX_AUTH_ARRAY_LENGTH) invalid("passkey_list.keys", "must be a bounded array");
return { user: parseAuthenticatedUser(list.user, "passkey_list.user"), keys: list.keys.map(parsePasskeySummary) };
}
export function parseAdditionResult(value: unknown): PasskeyAdditionResultResponse {
const result = asRecord(value, "addition_result");
if (result.status === "unknown") { requireExactKeys(result, "addition_result", ["status"]); return { status: "unknown" }; }
requireExactKeys(result, "addition_result", ["status", "user", "key"]);
if (result.status !== "saved") invalid("addition_result", "contains an unknown status");
return { status: "saved", user: parseAuthenticatedUser(result.user, "addition_result.user"), key: parsePasskeySummary(result.key) };
}
export function prepareRegistrationOptions(
@@ -583,11 +624,14 @@ function clientExtensionResults(
const value = (credential.getClientExtensionResults as () => unknown).call(
credential,
);
boundedJson(value, "credential.clientExtensionResults");
return asRecord(
value,
"credential.clientExtensionResults",
) as AuthenticationExtensionsClientOutputs;
const output = asRecord(value, "credential.clientExtensionResults");
// 現Serverが消費するJSON拡張だけを転送する。PRF/largeBlob等の未要求binary
// outputsをJSON.stringifyで{}へ変えたり、未使用機能として有効化したりしない。
const supported = Object.fromEntries(
["appid", "credProps", "hmacSecret", "credProtect", "minPinLength"].filter(key => key in output).map(key => [key, output[key]]),
);
boundedJson(supported, "credential.clientExtensionResults");
return supported as AuthenticationExtensionsClientOutputs;
}
export function registrationCredentialToJson(credential: unknown): unknown {
@@ -605,12 +649,12 @@ export function registrationCredentialToJson(credential: unknown): unknown {
) {
invalid("credential.response.transports", "must be a bounded array");
}
const transports = transportsValue.map((transport, index) =>
parseAuthenticatorTransport(
transport,
`credential.response.transports[${index}]`,
)
);
// transportは補助hint。Serverのwebauthn-rsも未知値をUnknownとして無視する。
// boundedな新値を理由に、端末上で作成済みの暗号credential全体を捨てない。
const transports = transportsValue.flatMap((value, index) => {
const transport = boundedString(value, `credential.response.transports[${index}]`);
return ["ble", "hybrid", "internal", "nfc", "usb"].includes(transport) ? [transport] : [];
});
const authenticatorAttachment = optionalString(
core.authenticatorAttachment,
"credential.authenticatorAttachment",
@@ -0,0 +1,140 @@
import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
import type { WhoamiResponse } from "./model.ts";
export type AuthStage =
| "session-check"
| "options"
| "authenticator"
| "conversion"
| "before-complete"
| "complete"
| "confirmation";
export type AuthFailure =
| "cancelled-or-unavailable"
| "excluded"
| "unsupported"
| "environment"
| "conversion"
| "session-changed"
| "rejected"
| "transport"
| "invalid-response"
| "stopped";
export type AuthOutcome =
| "not-sent"
| "rejected"
| "unknown"
| "saved-session-unconfirmed";
// 診断は固定分類だけを持つ。cause、例外本文、HTTP body、credentialやceremony IDを保持しない。
export class AuthOperationError extends Error {
constructor(
readonly stage: AuthStage,
readonly reason: AuthFailure,
readonly outcome: AuthOutcome,
readonly httpStatus?: number,
) {
super(`Passkey ${stage}: ${reason} (${outcome}).`);
}
}
type Observer = {
phase: (stage: AuthStage) => void;
verifySession: () => Promise<void>;
expected: WhoamiResponse | null;
additionAttempt: (attempt: PasskeyAdditionResultRequest) => void;
};
const observers = new WeakMap<AbortSignal, Observer>();
export function observeAuthOperation(
signal: AbortSignal,
observer: Observer,
): void {
observers.set(signal, observer);
}
export function recordAdditionAttempt(
signal: AbortSignal | undefined,
attempt: PasskeyAdditionResultRequest,
): void {
if (signal) observers.get(signal)?.additionAttempt(attempt);
}
export function expectedAuthSession(
signal?: AbortSignal,
): WhoamiResponse | null {
return signal ? observers.get(signal)?.expected ?? null : null;
}
export async function authCheckpoint(
signal: AbortSignal | undefined,
stage: AuthStage,
): Promise<void> {
signal?.throwIfAborted();
const observer = signal ? observers.get(signal) : undefined;
observer?.phase(stage);
if (stage === "options" || stage === "before-complete") {
await observer?.verifySession();
}
signal?.throwIfAborted();
}
export function classifyAuthFailure(
error: unknown,
stage: AuthStage,
sent: boolean,
): AuthOperationError {
if (error instanceof AuthOperationError) return error;
const outcome = sent ? "unknown" : "not-sent";
if (
error instanceof DOMException &&
(stage === "authenticator" || error.name === "AbortError")
) {
switch (error.name) {
case "AbortError":
return new AuthOperationError(stage, "stopped", outcome);
case "InvalidStateError":
return new AuthOperationError(stage, "excluded", outcome);
case "NotAllowedError":
return new AuthOperationError(
stage,
"cancelled-or-unavailable",
outcome,
);
case "NotSupportedError":
return new AuthOperationError(stage, "unsupported", outcome);
case "SecurityError":
return new AuthOperationError(stage, "environment", outcome);
}
}
return new AuthOperationError(
stage,
stage === "conversion" ? "conversion" : "transport",
outcome,
);
}
export function operationErrorMessage(error: AuthOperationError): string {
const reason: Record<AuthFailure, string> = {
"cancelled-or-unavailable":
"The browser did not complete verification. It may have been cancelled, timed out, or found no usable key; the browser does not distinguish these reasons.",
excluded:
"The authenticator reported an existing key (duplicate exclusion). Keep the older key and choose a different device, security key, or passkey provider; do not delete your only working key.",
unsupported:
"This browser or key cannot complete this passkey operation. Use a supported device that can save passkeys and verify with a PIN or biometrics.",
environment:
"The browser blocked this operation because of an origin, security, or environment constraint. Check that you are using the intended secure site; the exact cause is not known.",
conversion:
"The device may have created a key, but its response could not be converted for the server. Registration was not sent. Keep existing keys; a device-side entry alone does not mean it was saved to this account.",
"session-changed":
"The account or session changed, or could not be confirmed. The result cannot be applied to the previously displayed account.",
rejected:
"The server rejected the request or verification. A challenge may be invalid or expired. Check your account and the operation result before starting a new ceremony.",
transport: "Communication could not be confirmed.",
"invalid-response": "The server response could not be confirmed.",
stopped: "Operation stopped.",
};
const outcome = error.outcome === "not-sent"
? "Completion was not sent."
: error.outcome === "rejected"
? "The server did not accept completion."
: error.outcome === "saved-session-unconfirmed"
? "The server confirmed the operation, but the expected browser session is not confirmed."
: "Completion may already have reached the server. Its result is unknown; check the operation result before adding another key. Stopping the browser does not undo a submitted request.";
return `${reason[error.reason]} ${outcome} [${error.stage}/${error.reason}${
error.httpStatus ? `/HTTP-${error.httpStatus}` : ""
}]`;
}
@@ -59,7 +59,7 @@ for (const kind of ["anonymous", "expired"] as const) {
await browserSession.ensure();
expect(fetcher).toHaveBeenCalledTimes(1);
fetcher.mockResolvedValueOnce(
Response.json({ actor: null, session: { kind } }),
Response.json({ authentication_reference: null, actor: null, session: { kind } }),
);
await expect(requirePageSession(target)).rejects.toMatchObject({
status: 307,
@@ -0,0 +1,624 @@
// @vitest-environment happy-dom
// BrowserAuth→本番adapterの競合/結果判定。HTTPと認証器のみをgate付きで代替し、
// Server検証/保存の根拠にはしない。秘密の本文は診断へ保存しない。
import { afterEach, expect, test, vi } from "vitest";
import { BrowserAuth } from "./browser-session.svelte.ts";
import { browserSession, sessionMutation } from "./session-state.ts";
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
import {
authUser,
credentialFixture,
loginOptions,
registrationOptions,
} from "./api.test-fixtures.ts";
const owners: BrowserAuth[] = [];
afterEach(() => {
for (const owner of owners.splice(0)) owner.dispose();
browserSession.invalidate();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function identity(user: typeof authUser | null) {
return Response.json({
authentication_reference: user ? "fixture-authentication" : null,
actor: user ? { ...user, auth_method: "browser_session" } : null,
session: user
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
});
}
function harness() {
const state = { user: authUser as typeof authUser | null, completed: false };
const create = vi.fn<CredentialsContainer["create"]>().mockResolvedValue(
credentialFixture(),
);
const get = vi.fn<CredentialsContainer["get"]>().mockResolvedValue(
credentialFixture(true),
);
vi.stubGlobal("navigator", { credentials: { create, get } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") return identity(state.user);
if (path.endsWith("/options")) {
return Response.json(
path.includes("login") ? loginOptions : registrationOptions,
);
}
if (path.endsWith("/complete")) {
state.completed = true;
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/logout") {
state.user = null;
return Response.json({ status: "logged_out" });
}
throw new Error("Unexpected synthetic endpoint");
});
vi.stubGlobal("fetch", fetcher);
const auth = new BrowserAuth();
owners.push(auth);
return { state, create, get, fetcher, auth };
}
function savedAddition() {
return Response.json({
status: "saved",
user: authUser,
key: {
key_reference: "safe-opaque-reference",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
},
});
}
async function recoveryHarness() {
const context = harness();
await context.auth.refresh();
context.auth.additionAttempt = {
challenge_id: "synthetic-addition",
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference: "original-operation-reference",
};
return context;
}
for (const phase of ["initial-whoami", "receipt", "final-whoami"] as const) {
for (const reason of ["logout", "authentication"] as const) {
test(`recovery ${phase} rejects a shared ${reason} stamp before event delivery`, async () => {
const { auth, fetcher } = await recoveryHarness();
const started = gate<void>(), response = gate<Response>();
let whoami = 0, receiptReads = 0;
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") {
++whoami;
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
started.resolve();
return response.promise;
}
return identity(authUser);
}
if (path.endsWith("/addition/result")) {
++receiptReads;
expect(
JSON.parse(String(init?.body)).expected_authentication_reference,
)
.toBe("original-operation-reference");
if (phase === "receipt") {
started.resolve();
return response.promise;
}
return savedAddition();
}
throw new Error("Unexpected recovery endpoint");
});
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
const checking = auth.checkAddition();
await started.promise;
await auth.checkAddition(); // busy recovery cannot issue a second read
const changed = JSON.stringify({
id: `recovery-${phase}-${reason}`,
reason,
});
localStorage.setItem("yoi.browser-auth-generation", changed);
expect(browserSession.authenticationStamp).not.toBe(stamp);
expect(browserSession.authenticationGeneration).toBe(generation);
response.resolve(
phase === "receipt" ? savedAddition() : identity(authUser),
);
await checking;
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt?.expected_authentication_reference)
.toBe("original-operation-reference");
expect(auth.status).not.toContain("current session was checked");
expect(auth.error).toContain("does not prove failure");
expect(receiptReads).toBe(phase === "initial-whoami" ? 0 : 1);
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
)
.toBe(false);
});
}
}
for (const phase of ["receipt", "final-whoami"] as const) {
for (const invalidation of ["cancel", "dispose", "generation"] as const) {
test(`recovery ${phase} ignores a delayed saved response after ${invalidation}`, async () => {
const { auth, fetcher } = await recoveryHarness();
const started = gate<void>(), response = gate<Response>();
let whoami = 0;
fetcher.mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") {
if (++whoami === 2 && phase === "final-whoami") {
started.resolve();
return response.promise;
}
return identity(authUser);
}
if (path.endsWith("/addition/result")) {
if (phase === "receipt") {
started.resolve();
return response.promise;
}
return savedAddition();
}
throw new Error("Unexpected recovery endpoint");
});
const checking = auth.checkAddition();
await started.promise;
if (invalidation === "cancel") auth.cancel();
else if (invalidation === "dispose") auth.dispose();
else sessionMutation();
response.resolve(
phase === "receipt" ? savedAddition() : identity(authUser),
);
await checking;
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.status).not.toContain("current session was checked");
expect(whoami).toBe(phase === "receipt" ? 1 : 2);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
)
.toBe(false);
});
}
}
test("failed recheck cannot retain repeat-add permission from an older saved confirmation", async () => {
const { auth, fetcher } = await recoveryHarness();
auth.additionSaved = true;
fetcher.mockResolvedValue(identity(null));
await auth.checkAddition();
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.error).toContain("does not prove failure");
});
test("unknown receipt retains the unresolved attempt and never permits blind repetition", async () => {
const { auth, fetcher } = await recoveryHarness();
fetcher.mockImplementation(async (input) =>
String(input).endsWith("/addition/result")
? Response.json({ status: "unknown" })
: identity(authUser)
);
await auth.checkAddition();
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.status).toContain("still unknown");
expect(auth.error).toBe("");
});
for (const outcome of ["anonymous", "other-actor", "unavailable"] as const) {
test(`addition response is not Added when confirming whoami is ${outcome}`, async () => {
const { auth, state, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
if (state.completed && String(input) === "/api/auth/whoami") {
if (outcome === "unavailable") {
throw new TypeError("private network detail");
}
return identity(
outcome === "anonymous" ? null : {
...authUser,
user_id: "bob-user",
account_id: "bob-account",
handle: "bob",
},
);
}
return await request(input, init);
});
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.status).not.toBe("Added");
expect(auth.error).not.toContain("private network detail");
});
}
for (const invalidation of ["logout", "dispose", "account-change"] as const) {
test(`${invalidation} during native prompt never posts a delayed login credential`, async () => {
const { auth, state, get, fetcher } = harness();
state.user = null;
await auth.refresh();
const prompted = gate<void>();
const credential = gate<Credential | null>();
get.mockImplementation(() => {
prompted.resolve();
return credential.promise;
});
const run = auth.run("Waiting", "Logged in", loginWithPasskey);
await prompted.promise;
if (invalidation === "dispose") auth.dispose();
else if (invalidation === "account-change") sessionMutation();
else {
const signout = new BrowserAuth();
owners.push(signout);
await signout.run("Logging out", "Logged out", logout, "logout");
}
credential.resolve(credentialFixture(true));
expect(await run).toBe(false);
expect(
fetcher.mock.calls.some(([path]) =>
String(path).endsWith("/login/complete")
),
).toBe(false);
expect(state.completed).toBe(false);
});
}
const bob = {
...authUser,
user_id: "bob-user",
account_id: "bob-account",
handle: "bob",
};
for (const changeAt of ["start", "before-complete"] as const) {
test(`stale Alice with Bob cookie at ${changeAt} cannot add to Bob`, async () => {
const { auth, state, create, fetcher } = harness();
await auth.refresh();
if (changeAt === "start") state.user = bob;
else {create.mockImplementationOnce(async () => {
state.user = bob;
return credentialFixture();
});}
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(state.completed).toBe(false);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
if (changeAt === "start") expect(create).not.toHaveBeenCalled();
expect(auth.error).toContain("session-changed");
});
}
test("same owner but different authentication binding before complete is not the expected session", async () => {
const { auth, create, fetcher } = harness();
await auth.refresh();
let changed = false;
create.mockImplementationOnce(async () => {
changed = true;
return credentialFixture();
});
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami" && changed
? Response.json({
...(await identity(authUser).json()),
authentication_reference: "different-binding",
})
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
});
test("returned owner alone does not prove the issued browser session", async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input).endsWith("/complete")
? Response.json({
user: authUser,
authentication_reference: "issued-but-not-current",
})
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).toContain("expected browser session is not confirmed");
});
for (
const reason of [
"InvalidStateError",
"NotAllowedError",
"SecurityError",
"NotSupportedError",
] as const
) {
test(`native ${reason} has a safe stage-specific classification without raw exceptions`, async () => {
const { auth, create } = harness();
await auth.refresh();
create.mockRejectedValueOnce(
new DOMException("RAW-CREDENTIAL-TOKEN-COOKIE", reason),
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).not.toContain("RAW-CREDENTIAL-TOKEN-COOKIE");
expect(auth.error).toContain("authenticator/");
expect(auth.error).toContain("Completion was not sent");
if (reason === "InvalidStateError") {
expect(auth.error).toContain("duplicate exclusion");
}
if (reason === "NotAllowedError") {
expect(auth.error).toContain("does not distinguish");
}
});
}
test("device creation followed by conversion failure never sends complete or claims registration", async () => {
const { auth, create, fetcher } = harness();
await auth.refresh();
create.mockResolvedValueOnce(
{
...credentialFixture(),
rawId: "private-invalid-value",
} as unknown as Credential,
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).toContain("conversion");
expect(auth.error).toContain("device may have created");
expect(auth.error).not.toContain("private-invalid-value");
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
});
test("duplicate operations are fenced and explicit cancellation stops late credentials before submission", async () => {
const { auth, create, fetcher } = harness();
await auth.refresh();
const started = gate<void>(), credential = gate<Credential | null>();
create.mockImplementationOnce(() => {
started.resolve();
return credential.promise;
});
const first = auth.run("Waiting", "Added", addPasskey);
await started.promise;
const duplicate = vi.fn(async () => ({}));
expect(await auth.run("Duplicate", "Wrong", duplicate)).toBe(false);
expect(duplicate).not.toHaveBeenCalled();
auth.cancel();
credential.resolve(credentialFixture());
expect(await first).toBe(false);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
});
test("abort after complete dispatch remains an unknown submitted addition with a recoverable operation", async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const started = gate<void>(), response = gate<Response>();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input).endsWith("/complete")) {
started.resolve();
return response.promise;
}
return request(input, init);
});
const run = auth.run("Waiting", "Added", addPasskey);
await started.promise;
auth.cancel();
response.resolve(
Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
}),
);
expect(await run).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.error).toContain("result is unknown");
expect(auth.error).not.toContain("Completion was not sent");
});
for (
const current of [
"original-session",
"cookie-updated",
"anonymous",
"bob",
"unavailable",
] as const
) {
test(`lost completion body with ${current} distinguishes saved, session evidence and unavailable authority`, async () => {
const { auth, state, fetcher } = harness();
await auth.refresh();
let dispatched = false, resultReads = 0;
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path.endsWith("/complete")) {
dispatched = true;
throw new TypeError("RAW-HTTP-BODY-TOKEN");
}
if (path === "/api/auth/whoami" && dispatched) {
if (current === "unavailable") throw new TypeError("RAW-WHOAMI-ERROR");
if (current === "anonymous") return identity(null);
if (current === "bob") return identity(bob);
if (current === "cookie-updated") {
return Response.json({
...(await identity(authUser).json()),
authentication_reference: "new-live-session",
});
}
}
if (path.endsWith("/addition/result")) {
++resultReads;
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
.toBe("fixture-authentication");
return Response.json({
status: "saved",
user: authUser,
key: {
key_reference: "safe-opaque-reference",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
},
});
}
return request(input, init);
});
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.error).not.toContain("RAW-");
expect(auth.error).toContain("result is unknown");
await auth.checkAddition();
if (current === "original-session" || current === "cookie-updated") {
expect(auth.additionSaved).toBe(true);
expect(auth.status).toContain("saved a key");
expect(auth.status).toContain(
"session issued by completion is not confirmed",
);
expect(resultReads).toBe(1);
} else {
expect(auth.additionSaved).toBe(false);
expect(resultReads).toBe(0);
expect(auth.error).toContain("does not prove failure");
}
expect(state.completed).toBe(false); // synthetic boundary never acts as the real store
});
}
test("storage mutation arriving before the storage event fences prompt completion even if whoami stays anonymous", async () => {
const { auth, state, get, fetcher } = harness();
state.user = null;
await auth.refresh();
get.mockImplementationOnce(async () => {
localStorage.setItem(
"yoi.browser-auth-generation",
JSON.stringify({ id: "another-tab", reason: "logout" }),
);
return credentialFixture(true);
});
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
expect(
fetcher.mock.calls.some(([path]) =>
String(path).endsWith("/login/complete")
),
).toBe(false);
});
for (const phase of ["options", "complete"] as const) {
test(`malformed ${phase} response records the stage without leaking body or inventing completion`, async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input).endsWith(`/${phase}`)
? Promise.resolve(
new Response("RAW-CHALLENGE-COOKIE", {
headers: { "content-type": "application/json" },
}),
)
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).toContain(`${phase}/invalid-response`);
expect(auth.error).not.toContain("RAW-CHALLENGE-COOKIE");
expect(auth.additionAttempt === null).toBe(phase === "options");
if (phase === "options") {
expect(auth.error).toContain("Completion was not sent");
} else expect(auth.error).toContain("result is unknown");
});
}
for (
const code of [
"passkey_authentication_changed",
"webauthn_registration_verification_failed",
] as const
) {
test(`HTTP rejection classifies allowlisted ${code} without retaining HTTP body`, async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input).endsWith("/complete")
? Promise.resolve(
Response.json({
message: `${code}: RAW-EXCEPTION-BODY`,
secret: "RAW-TOKEN",
}, { status: 401 }),
)
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).not.toContain("RAW-");
expect(auth.error).toContain(
code === "passkey_authentication_changed"
? "session-changed"
: "rejected",
);
expect(auth.error).toContain("HTTP-401");
expect(auth.error).not.toContain("Completion was not sent");
});
}
test("successful logout never marks an earlier unknown addition as saved", async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input).endsWith("/complete")
? Promise.reject(new TypeError("response lost"))
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.additionSaved).toBe(false);
expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe(
true,
);
expect(auth.additionSaved).toBe(false);
});
test("explicit login from stale expired display remains allowed after another tab cleared the cookie", async () => {
const { auth, state, fetcher } = harness();
state.user = null;
fetcher.mockResolvedValueOnce(
Response.json({
actor: null,
authentication_reference: null,
session: { kind: "expired" },
}),
);
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
if (String(input).endsWith("/complete")) state.user = authUser;
return await request(input, init);
});
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(true);
expect(auth.status).toBe("Logged in");
});
test("explicit login from expired display does not replace another tab's authenticated account", async () => {
const { auth, state, fetcher, get } = harness();
fetcher.mockResolvedValueOnce(
Response.json({
actor: null,
authentication_reference: null,
session: { kind: "expired" },
}),
);
await auth.refresh();
state.user = bob;
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
expect(get).not.toHaveBeenCalled();
expect(state.completed).toBe(false);
});
@@ -43,7 +43,7 @@ test("active expired state removes private content and preserves browser query a
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({ actor: null, session: { kind: "expired" } })
Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
),
);
await browserSession.refresh();
@@ -62,7 +62,7 @@ test("navigation admission does not redirect to the old page return URL", async
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({ actor: null, session: { kind: "expired" } })
Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
),
);
await browserSession.refresh();
@@ -13,13 +13,14 @@ afterEach(() => {
localStorage.clear();
});
const anonymous: WhoamiResponse = {
actor: null,
actor: null, authentication_reference: null,
session: { kind: "anonymous" },
};
const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } };
const expired: WhoamiResponse = { actor: null, authentication_reference: null, session: { kind: "expired" } };
const authenticated = (
expires_at: string | null = "2099-01-01T00:00:00Z",
): WhoamiResponse => ({
authentication_reference: "fixture-authentication",
actor: { ...authUser, auth_method: "browser_session" },
session: { kind: "authenticated", expires_at },
});
@@ -17,6 +17,10 @@ export class SessionState {
private listeners = new Set<(state: SessionSnapshot) => void>();
private stale = true;
get authenticationStamp(): string | null {
return storageStamp();
}
get authenticationGeneration(): number {
return this.generation;
}
@@ -38,7 +42,7 @@ export class SessionState {
++this.generation;
this.pending = null;
this.stale = true;
if (!preserveSnapshot) this.publish({ kind: "loading" });
this.publish(preserveSnapshot ? this.snapshot : { kind: "loading" });
}
// Page load owns freshness. Components mounted after it reuse that result.
@@ -100,8 +104,9 @@ export const browserSession = new SessionState();
export function sessionMutation(
reason: "authentication" | "logout" = "authentication",
preserveSnapshot = false,
): void {
browserSession.invalidate();
browserSession.invalidate(preserveSnapshot);
const message = JSON.stringify({ id: crypto.randomUUID(), reason });
try {
localStorage.setItem(GENERATION_KEY, message);
@@ -35,6 +35,7 @@ function session() {
function whoami(authenticated: boolean) {
return Response.json({
authentication_reference: authenticated ? "fixture-authentication" : null,
session: authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -75,7 +76,10 @@ function boundary(authenticated = false) {
return new Response(null, { status: state.completeStatus });
}
state.authenticated = true;
return Response.json({ user: authUser });
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
throw new Error(`Unexpected API: ${path}`);
});
@@ -106,6 +110,7 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
});
@@ -162,6 +167,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
oldResponse.resolve(whoami(true));
@@ -169,6 +175,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
expect(auth.status).toBe("Logged out");
@@ -183,26 +190,34 @@ test("busy login rejects duplicate operations and refresh until its post-complet
const confirmingStarted = gate<void>();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") {
if (String(input) === "/api/auth/passkeys/login/options") {
return options.promise;
}
if (
String(input) === "/api/auth/whoami" &&
paths(fetcher).includes("/api/auth/passkeys/login/complete")
) {
confirmingStarted.resolve();
return confirming.promise;
}
return request(input, init);
});
fetcher.mockReturnValueOnce(options.promise);
const run = auth.run(
"Waiting",
"Logged in",
(signal) => loginWithPasskey(signal),
);
expect(auth.busy).toBe(true);
expect(auth.status).toBe("Waiting");
expect(auth.status).toMatch(/Checking the account/);
const duplicate = vi.fn(async () => {});
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh();
expect(duplicate).not.toHaveBeenCalled();
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
// Preflight is asynchronous; wait for the options stage, not an HTTP ordinal.
await vi.waitFor(() =>
expect(paths(fetcher)).toContain("/api/auth/passkeys/login/options")
);
expect(paths(fetcher).filter((path) => path !== "/api/auth/whoami")).toEqual([
"/api/auth/passkeys/login/options",
]);
options.resolve(Response.json(loginOptions));
@@ -212,15 +227,17 @@ test("busy login rejects duplicate operations and refresh until its post-complet
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh();
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2);
.toHaveLength(4);
confirming.resolve(whoami(true));
expect(await run).toBe(true);
expect(auth.busy).toBe(false);
expect(auth.actor?.handle).toBe("alice");
expect(auth.status).toBe("Logged in");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/complete",
"/api/auth/whoami",
]);
@@ -236,16 +253,16 @@ for (const status of [400, 401, 410]) {
expect(result).toBe(false);
expect(auth.busy).toBe(false);
expect(auth.status).toBe("");
expect(auth.error).toMatch(
status === 401
? /Authentication request was rejected/
: /The request or authentication challenge is invalid or expired/,
);
expect(auth.error).toMatch(/server rejected the request or verification/);
expect(auth.error).toContain(`[complete/rejected/HTTP-${status}]`);
expect(auth.error).toContain("server did not accept completion");
expect(auth.session.kind).toBe("ready");
expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
"/api/auth/whoami",
"/api/auth/passkeys/addition/complete",
"/api/auth/whoami",
]);
@@ -278,22 +295,25 @@ for (
).toBe(false);
expect(auth.error).toMatch(
name === "NotAllowedError"
? /cancelled or timed out/
? /cancelled, timed out, or found no usable key/
: name === "NotSupportedError"
? /Completion is not confirmed/
? /supported device/
: name === "AbortError"
? /Completion is not confirmed/
: /Authentication could not be confirmed/,
? /Operation stopped/
: /Communication could not be confirmed/,
);
expect(auth.error).toContain("Completion was not sent.");
expect(auth.error).not.toContain("secret credential detail");
expect(auth.status).toBe("");
expect(auth.busy).toBe(false);
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
@@ -314,14 +334,21 @@ test("completion does not confirm authentication when the subsequent whoami fail
const { fetcher } = boundary();
const auth = session();
await auth.refresh();
let completed = false;
fetcher.mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") return new Response(null, { status: 503 });
if (path === "/api/auth/whoami") {
return completed ? new Response(null, { status: 503 }) : whoami(false);
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return Response.json({ user: authUser });
completed = true;
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
throw new Error(`Unexpected API: ${path}`);
});
@@ -385,14 +412,15 @@ test("dispose aborts WebAuthn without accepting late credentials, while shared s
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
expect(auth.status).toBe("Waiting");
expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
]);
expect(
dispatch.mock.calls.filter(([event]) =>
@@ -409,6 +437,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not
const started = gate<AbortSignal>();
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") return whoami(false);
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
@@ -427,16 +456,22 @@ test("dispose ignores a completion response even when the HTTP boundary does not
const signal = await started.promise;
auth.dispose();
expect(signal.aborted).toBe(true);
response.resolve(Response.json({ user: authUser }));
response.resolve(
Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
}),
);
expect(await run).toBe(false);
expect(auth.actor).toBeNull();
expect(auth.status).toBe("Waiting");
expect(auth.status).toBe("Confirming the operation with the server…");
expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/complete",
]);
expect(
dispatch.mock.calls.filter(([event]) =>
@@ -451,15 +486,21 @@ test("dispose during the post-operation whoami prevents its late actor from beco
await auth.refresh();
const response = gate<Response>();
const started = gate<void>();
let completed = false;
fetcher.mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return Response.json({ user: authUser });
completed = true;
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/whoami") {
if (!completed) return whoami(false);
started.resolve();
return await response.promise;
}
@@ -489,7 +530,10 @@ for (const newer of ["logout", "another-actor"] as const) {
const started = gate<void>();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") {
if (
String(input) === "/api/auth/whoami" &&
paths(fetcher).includes("/api/auth/passkeys/login/complete")
) {
started.resolve();
return confirming.promise;
}
@@ -509,6 +553,7 @@ for (const newer of ["logout", "another-actor"] as const) {
sessionMutation(newer === "logout" ? "logout" : "authentication");
fetcher.mockResolvedValueOnce(
newer === "logout" ? whoami(false) : Response.json({
authentication_reference: "other-authentication",
actor: {
...authUser,
user_id: "other-user",
@@ -552,7 +597,8 @@ for (const purpose of ["login", "addition"] as const) {
purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal),
),
).toBe(false);
expect(auth.error).toMatch(/cancelled or timed out/);
expect(auth.error).toMatch(/cancelled, timed out, or found no usable key/);
expect(auth.error).toContain("Completion was not sent.");
expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe(
false,
);
@@ -20,6 +20,7 @@ for (
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
if (String(input) === "/api/auth/whoami") {
return Response.json({
authentication_reference: actor ? "fixture-authentication" : null,
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
+90 -10
View File
@@ -1,30 +1,72 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { addPasskey, logout } from '#lib/workspace/auth/api.ts';
import type { PasskeyListResponse } from '#lib/generated/auth-api.ts';
import { addPasskey, logout, loadPasskeys, loadWhoami } from '#lib/workspace/auth/api.ts';
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
import { browserSession } from '#lib/workspace/auth/session-state.ts';
import { loginUrl } from '#lib/workspace/auth/routes.ts';
import '#lib/workspace/auth/auth-page.css';
const auth = new BrowserAuth();
async function refresh() {
await auth.refresh();
let keys = $state<PasskeyListResponse | null>(null);
let keysError = $state('');
let keysLoading = $state(false);
let keyRead = 0;
let active = true;
let keyController: AbortController | null = null;
function time(value: string | null) { return value ? new Date(value).toLocaleString() : 'Not recorded'; }
async function refreshKeys() {
const actor = auth.actor;
if (!actor || !active) return;
keyController?.abort();
const controller = new AbortController();
keyController = controller;
const read = ++keyRead;
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
const assertCurrent = () => {
controller.signal.throwIfAborted();
if (!active || read !== keyRead || generation !== browserSession.authenticationGeneration || stamp !== browserSession.authenticationStamp) throw new Error('Session changed');
};
keysLoading = true; keysError = '';
try {
const before = await loadWhoami(controller.signal);
assertCurrent();
if (before.actor?.user_id !== actor.user_id || before.actor.account_id !== actor.account_id) throw new Error('Account changed');
const result = await loadPasskeys(controller.signal);
assertCurrent();
const after = await loadWhoami(controller.signal);
assertCurrent();
if (result.user.user_id !== actor.user_id || result.user.account_id !== actor.account_id ||
after.actor?.user_id !== actor.user_id || after.actor.account_id !== actor.account_id ||
before.authentication_reference !== after.authentication_reference) throw new Error('Session changed');
keys = result;
} catch {
if (active && read === keyRead && !controller.signal.aborted) { keys = null; keysError = 'Registered keys could not be confirmed for this account. Refresh your session before trying again.'; }
} finally { if (read === keyRead) keysLoading = false; }
}
async function refresh() { await auth.refresh(); await refreshKeys(); }
async function add() {
if (!auth.actor) return;
await auth.run('Waiting for passkey addition…', 'Passkey added to your account.', addPasskey);
if (!auth.actor || auth.additionAttempt && !auth.additionSaved) return;
await auth.run('Waiting for passkey addition…', 'Passkey added to your account; the expected session was confirmed.', addPasskey);
await refreshKeys();
}
async function checkAddition() { await auth.checkAddition(); await refreshKeys(); }
async function signOut() {
if (!auth.actor) return;
const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
if (confirmed && !auth.actor) await goto('/', { replaceState: true });
}
onMount(() => { void auth.ensure(); return () => auth.dispose(); });
onMount(() => { void auth.ensure().then(refreshKeys); return () => { active = false; ++keyRead; keyController?.abort(); auth.dispose(); }; });
</script>
<svelte:head><title>Account · Yoi</title></svelte:head>
<div class="auth-page" data-sveltekit-preload-data="off">
<h1>Account</h1>
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
{#if auth.session.kind === 'loading'}
<p class="muted" role="status">Loading session…</p>
{:else if auth.session.kind === 'error'}
@@ -37,8 +79,41 @@
</dl>
<section aria-label="Your Passkeys">
<h2>Passkeys</h2>
<p class="muted">Add a discoverable passkey for login without an account ID. Use a device or key that can save passkeys and verify you with a PIN or biometrics. Existing keys are preserved; do not remove them before confirming the new key works.</p>
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button>
<p class="muted">Add a passkey for login without an account ID. Use a device or key that saves passkeys and verifies you with a PIN or biometrics. Existing keys are preserved.</p>
<p>If an older key is in the same authenticator or saved-key provider, duplicate exclusion can block addition there. Choose a different device, security key, or passkey provider. A different provider on the same device may work only if it does not contain an excluded key. Do not delete an older key to bypass this check.</p>
<button type="button" disabled={auth.busy || !!auth.additionAttempt && !auth.additionSaved} onclick={add}>Add passkey to {auth.actor.handle}</button>
{#if auth.additionAttempt}
<button type="button" disabled={auth.busy} onclick={checkAddition}>Check addition result</button>
<p class="muted">A saved result confirms this addition's server record, not that ID-free login works. If the session changed, result access may be unavailable; do not restore an old session or add blindly.</p>
{/if}
{#if keysLoading}<p role="status">Checking registered keys…</p>{/if}
{#if keysError}<p role="alert" class="error">{keysError}</p>{/if}
{#if keys && keys.user.user_id === auth.actor.user_id && keys.user.account_id === auth.actor.account_id}
<ul class="passkey-list" aria-label="Registered keys">
{#each keys.keys as key (key.key_reference)}
<li>
<dl>
<div><dt>Created</dt><dd>{time(key.created_at)}</dd></div>
<div><dt>Last used</dt><dd>{time(key.last_used_at)}</dd></div>
<div><dt>ID-free login</dt><dd>{key.normal_login_verified_at ? `Verified ${time(key.normal_login_verified_at)}` : 'Not yet verified — storage alone does not prove support'}</dd></div>
</dl>
<details><summary>Key reference</summary><code>{key.key_reference}</code></details>
</li>
{/each}
</ul>
{#if keys.keys.length === 0}<p>No registered keys were returned for this account.</p>{/if}
{/if}
<button type="button" disabled={auth.busy || keysLoading} onclick={refreshKeys}>Refresh registered keys</button>
<details>
<summary>Verify migration before retiring older-key login</summary>
<ol>
<li>Keep this working session and every working older key. If addition is excluded, use a different saved-key provider or authenticator; removing your last key first can lock you out.</li>
<li>In a separate browser profile or private window, open <a href="/login" target="_blank" rel="noopener">normal passkey login</a>. Do not open the older-key/ID form. Select the newly saved key and complete verification.</li>
<li>Confirm the intended account ID in that window's Account page. Refresh the key list here and check that the chosen key now records verified ID-free login. Repeat for every key and device you intend to rely on; a stored key or resident-key hint alone is not proof.</li>
<li>Retain a tested independent way back in before ceasing to rely on an older key. If any required device cannot complete normal login, keep the older-key path available. Do not delete old keys as a prerequisite.</li>
</ol>
<p>Removing the older-key entry for the service is not safe based on one account's success. The operator must first confirm migration and a working alternative for every affected account/device, keep a rollback path to the older-key entry, and verify normal login before retiring that entry. This page does not delete keys or disable compatibility login.</p>
</details>
</section>
<div class="actions">
<button type="button" disabled={auth.busy} onclick={signOut}>Log out</button>
@@ -48,7 +123,12 @@
<p>No browser session is active.</p>
<a href={loginUrl('/account')}>Log in to your account</a>
{/if}
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<a href="/dashboard">Dashboard / Workspaces</a>
</div>
<style>
.passkey-list { padding-inline-start: var(--space-5); display: grid; gap: var(--space-4); }
.passkey-list li { min-width: 0; }
code { overflow-wrap: anywhere; }
ol { padding-inline-start: var(--space-5); }
li + li { margin-block-start: var(--space-2); }
</style>
@@ -63,6 +63,9 @@ function boundary({
return new Response(null, { status: state.whoamiStatus });
}
return Response.json({
authentication_reference: state.authenticated
? "fixture-authentication"
: null,
session: state.authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -71,6 +74,9 @@ function boundary({
: null,
});
}
if (path === "/api/auth/passkeys") {
return Response.json({ user: authUser, keys: [] });
}
if (path === "/api/auth/registration-policy") {
if (state.policy === "loading") return await policyResponse.promise;
if (state.policy === "error") return new Response(null, { status: 503 });
@@ -97,7 +103,10 @@ function boundary({
return new Response(null, { status: state.completeStatus });
}
state.authenticated = true;
return Response.json({ user: authUser });
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/logout") {
state.authenticated = false;
@@ -149,6 +158,143 @@ async function submit(name: string) {
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
}
for (const phase of ["initial-whoami", "list", "final-whoami"] as const) {
for (const reason of ["logout", "authentication"] as const) {
test(`registered-key ${phase} confirmation rejects shared ${reason} before event delivery`, async () => {
const { fetcher } = boundary({ authenticated: true });
render(Account);
await screen.findByText(
"No registered keys were returned for this account.",
);
const request = fetcher.getMockImplementation()!;
const started = gate<void>(), response = gate<Response>();
let whoami = 0, listReads = 0;
const metadata = () =>
Response.json({
user: authUser,
keys: [{
key_reference: "delayed-key-metadata",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
}],
});
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") {
++whoami;
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
started.resolve();
return response.promise;
}
}
if (path === "/api/auth/passkeys") {
++listReads;
if (phase === "list") {
started.resolve();
return response.promise;
}
return metadata();
}
return request(input, init);
});
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
await fireEvent.click(
screen.getByRole("button", { name: "Refresh registered keys" }),
);
await started.promise;
const changed = JSON.stringify({ id: `keys-${phase}-${reason}`, reason });
localStorage.setItem("yoi.browser-auth-generation", changed);
expect(browserSession.authenticationStamp).not.toBe(stamp);
expect(browserSession.authenticationGeneration).toBe(generation);
response.resolve(
phase === "list" ? metadata() : await request("/api/auth/whoami"),
);
await screen.findByText(/Registered keys could not be confirmed/);
expect(screen.queryByText("delayed-key-metadata")).toBeNull();
expect(listReads).toBe(phase === "initial-whoami" ? 0 : 1);
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
expect(posted(fetcher)).toEqual([]);
});
}
}
for (const reason of ["logout", "authentication"] as const) {
test(`stale receipt confirmation after shared ${reason} leaves visible repeat-add disabled`, async () => {
const { fetcher } = boundary({ authenticated: true });
render(Account);
await screen.findByText(
"No registered keys were returned for this account.",
);
const request = fetcher.getMockImplementation()!;
const started = gate<void>(), response = gate<Response>();
let receiptRead = false;
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path.endsWith("/addition/complete")) {
throw new TypeError("lost response");
}
if (path.endsWith("/addition/result")) {
receiptRead = true;
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
.toBe("fixture-authentication");
return Response.json({
status: "saved",
user: authUser,
key: {
key_reference: "saved-but-session-unconfirmed",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
},
});
}
if (path === "/api/auth/whoami" && receiptRead) {
receiptRead = false;
started.resolve();
return response.promise;
}
return request(input, init);
});
const add = screen.getByRole("button", {
name: /Add passkey to/,
}) as HTMLButtonElement;
await fireEvent.click(add);
await screen.findByText(/result is unknown/);
await waitFor(() =>
expect(screen.queryByText("Checking registered keys…")).toBeNull()
);
expect(add.disabled).toBe(true);
const checking = screen.getByRole("button", {
name: "Check addition result",
});
await waitFor(() =>
expect((checking as HTMLButtonElement).disabled).toBe(false)
);
await fireEvent.click(checking);
await started.promise;
const generation = browserSession.authenticationGeneration;
localStorage.setItem(
"yoi.browser-auth-generation",
JSON.stringify({ id: `visible-${reason}`, reason }),
);
expect(browserSession.authenticationGeneration).toBe(generation);
response.resolve(await request("/api/auth/whoami"));
await screen.findByText(/The addition result could not be read/);
await waitFor(() =>
expect((checking as HTMLButtonElement).disabled).toBe(false)
);
expect(screen.queryByText(/Your current session was checked/)).toBeNull();
expect(add.disabled).toBe(true);
await fireEvent.click(add);
expect(
posted(fetcher).filter(({ path }) => path.endsWith("/addition/complete")),
).toHaveLength(1);
});
}
test("normal login needs no account ID and links separate registration and code activation", async () => {
const { fetcher } = boundary({ policy: true });
page.url = new URL("https://example.test/login?returnTo=%2Faccount");
@@ -231,19 +377,23 @@ for (
expect(get).toHaveBeenCalledOnce();
expect(create).not.toHaveBeenCalled();
expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2);
.toHaveLength(4);
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
});
}
test("successful login completion without confirmed whoami never navigates", async () => {
const { state, get } = boundary();
const { state, fetcher } = boundary();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
const response = await request(input, init);
if (String(input) === "/api/auth/passkeys/login/complete") {
state.whoamiStatus = 503;
}
return response;
});
render(Login);
await enterIdentity();
get.mockImplementationOnce(async () => {
state.whoamiStatus = 503;
return credentialFixture(true);
});
await submit("Log in with passkey");
await screen.findByText(/Your session could not be loaded/);
expect(goto).not.toHaveBeenCalled();
@@ -347,7 +497,7 @@ test("policy disabled during WebAuthn rejects registration, reloads policy and n
await waitFor(() => expect(create).toHaveBeenCalledOnce());
state.completeStatus = 403;
credential.resolve(credentialFixture());
await screen.findByText("Auth request failed (403).");
await screen.findByText(/\[complete\/rejected\/HTTP-403\]/);
await screen.findByText(/New account registration is disabled/);
expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull();
@@ -376,7 +526,9 @@ test("account displays the current identity without editable target fields and a
await fireEvent.click(
screen.getByRole("button", { name: "Add passkey to alice" }),
);
await screen.findByText("Passkey added to your account.");
await screen.findByText(
"Passkey added to your account; the expected session was confirmed.",
);
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement)
@@ -388,7 +540,12 @@ test("account displays the current identity without editable target fields and a
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
expect(requests[0].body).toEqual({ browser_origin: window.location.origin });
expect(requests[0].body).toEqual({
browser_origin: window.location.origin,
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference: "fixture-authentication",
});
expect(Object.keys(requests[1].body).sort()).toEqual([
"challenge_id",
"credential",
@@ -485,11 +642,18 @@ test("logout confirms the server session is cleared and returns to the public ho
expect(posted(fetcher)).toEqual([
{ path: "/api/auth/logout", body: {}, credentials: "same-origin" },
]);
expect(apiPaths(fetcher)).toEqual([
const paths = apiPaths(fetcher);
expect(paths[0]).toBe("/api/auth/whoami");
expect(paths.slice(paths.indexOf("/api/auth/logout") + 1)).toContain(
"/api/auth/whoami",
"/api/auth/logout",
"/api/auth/whoami",
]);
);
expect(
paths.every((path) =>
["/api/auth/whoami", "/api/auth/logout", "/api/auth/passkeys"].includes(
path,
)
),
).toBe(true);
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
});
@@ -499,10 +663,14 @@ test("expired addition revokes stale identity without silently retrying as regis
await fireEvent.click(
await screen.findByRole("button", { name: "Add passkey to alice" }),
);
await screen.findByText(/Authentication request was rejected/);
await screen.findByText(/server rejected the request or verification/);
await screen.findByRole("link", { name: "Log in to your account" });
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
expect(screen.queryByText("Passkey added to your account.")).toBeNull();
expect(
screen.queryByText(
"Passkey added to your account; the expected session was confirmed.",
),
).toBeNull();
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
@@ -545,7 +713,13 @@ test("WebAuthn cancellation is visible, sends no completion, and allows an expli
await enterIdentity();
await submit("Log in with passkey");
await screen.findByText(
"Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.",
/cancelled, timed out, or found no usable key/,
);
expect(screen.getByRole("alert").textContent).toContain(
"Completion was not sent.",
);
expect(screen.getByRole("alert").textContent).toContain(
"[authenticator/cancelled-or-unavailable]",
);
await waitFor(() =>
expect(
@@ -651,7 +825,12 @@ test("device approval locks its code and rejects a second submit while the serve
name: "Approve device login",
});
const response = gate<Response>();
fetcher.mockReturnValueOnce(response.promise);
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input) === "/api/auth/device-login/approve"
? response.promise
: request(input, init)
);
const form = approve.closest("form")!;
await fireEvent.submit(form);
await screen.findByText("Approving device login…");
@@ -659,9 +838,11 @@ test("device approval locks its code and rejects a second submit while the serve
expect((screen.getByLabelText("User code") as HTMLInputElement).disabled)
.toBe(true);
await fireEvent.submit(form);
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/device-login/approve",
]);
await waitFor(() =>
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/device-login/approve",
])
);
response.resolve(Response.json({ status: "approved", user: authUser }));
await screen.findByText("Device login approved. You can return to the CLI.");
expect(posted(fetcher)).toHaveLength(1);
@@ -679,11 +860,8 @@ for (const status of [401, 410]) {
(await screen.findByRole("button", { name: "Approve device login" }))
.closest("form")!,
);
await screen.findByText(
status === 401
? /Authentication request was rejected/
: /The request or authentication challenge is invalid or expired/,
);
await screen.findByText(/Authentication request was rejected/);
expect(screen.getByRole("alert").textContent).toContain("(" + status + ")");
expect(
screen.queryByText("Device login approved. You can return to the CLI."),
).toBeNull();
@@ -721,7 +899,7 @@ test("compatibility login keeps its selected form and ID after cancellation and
target: { value: "alice" },
});
await submit("Log in with older key");
await screen.findByText(/cancelled or timed out/);
await screen.findByText(/cancelled, timed out, or found no usable key/);
await screen.findByText("Use an older key with account ID");
expect(
(screen.getByText("Use an older key with account ID").closest(
@@ -3,6 +3,7 @@
import { activateWithPasskey } from '#lib/workspace/auth/activation.ts';
import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
import { notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
import { browserSession } from '#lib/workspace/auth/session-state.ts';
import type { AuthUser } from '#lib/workspace/auth/model.ts';
let handle = $state('');
@@ -19,12 +20,15 @@
try { handle = normalizeActivationHandle(handle); }
catch { error = 'Check the account ID supplied by your administrator.'; return; }
const current = ++generation;
const authGeneration = browserSession.authenticationGeneration;
const authStamp = browserSession.authenticationStamp;
controller = new AbortController(); busy = true; error = '';
const pending = activateWithPasskey(handle, code, controller.signal);
code = '';
try {
const confirmed = await pending;
if (current !== generation) return;
if (authGeneration !== browserSession.authenticationGeneration || authStamp !== browserSession.authenticationStamp) throw new Error('Activation superseded');
user = confirmed;
notifySessionChanged();
} catch {
@@ -7,6 +7,11 @@ import {
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import {
browserSession,
sessionMutation,
} from "#lib/workspace/auth/session-state.ts";
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
import Activate from "./+page.svelte";
import {
authUser,
@@ -15,9 +20,34 @@ import {
} from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => {
cleanup();
browserSession.invalidate();
vi.unstubAllGlobals();
});
function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function confirmedSession() {
return {
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
};
}
function boundary(
mode:
| "success"
| "pending"
| "cancel"
| "unknown"
| "anonymous"
| "bob"
| "reference-mismatch"
| "unavailable",
) {
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: true,
@@ -34,7 +64,36 @@ function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
}
if (String(input) === "/api/auth/activation/complete") {
if (mode === "unknown") throw new Error("private diagnostic");
return Response.json({ user: authUser });
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (String(input) === "/api/auth/whoami") {
if (mode === "unavailable") {
return new Response("private diagnostic", { status: 503 });
}
if (mode === "anonymous") {
return Response.json({
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
}
const confirmation = confirmedSession();
if (mode === "bob") {
confirmation.actor = {
...confirmation.actor,
user_id: "user-bob",
account_id: "account-bob",
handle: "bob",
display_name: "Bob",
};
}
if (mode === "reference-mismatch") {
confirmation.authentication_reference = "another-authentication";
}
return Response.json(confirmation);
}
throw new Error("Unexpected endpoint");
});
@@ -54,12 +113,29 @@ async function submit() {
}
test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => {
const { fetcher } = boundary("success");
const confirmation = gate<Response>();
const started = gate<void>();
const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") {
started.resolve();
return confirmation.promise;
}
return original(input, init);
});
render(Activate);
await submit();
await started.promise;
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
expect((screen.getByLabelText("Activation code") as HTMLInputElement).value)
.toBe("");
confirmation.resolve(Response.json(confirmedSession()));
await screen.findByText(/registration and activation confirmed/);
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
expect(screen.queryByLabelText("Activation code")).toBeNull();
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe(
@@ -99,3 +175,106 @@ for (const mode of ["cancel", "unknown"] as const) {
.toBe("");
});
}
for (
const mode of [
"anonymous",
"bob",
"reference-mismatch",
"unavailable",
] as const
) {
test(
"activation " + mode +
" confirmation never claims signed in after successful completion",
async () => {
const { fetcher, create } = boundary(mode);
const changed = vi.fn();
window.addEventListener(SESSION_CHANGED_EVENT, changed);
try {
render(Activate);
await submit();
await screen.findByRole("alert");
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
expect(create).toHaveBeenCalledOnce();
expect(screen.getByRole("alert").textContent).toContain(
"Passkey login",
);
expect(screen.getByRole("alert").textContent).not.toContain(
"private diagnostic",
);
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
expect(
(screen.getByLabelText("Activation code") as HTMLInputElement).value,
).toBe("");
expect(changed).not.toHaveBeenCalled();
} finally {
window.removeEventListener(SESSION_CHANGED_EVENT, changed);
}
},
);
}
test("logout during the activation prompt fences a late credential before completion", async () => {
const { fetcher, create } = boundary("success");
const credential = gate<Credential>();
create.mockReturnValueOnce(credential.promise);
render(Activate);
await submit();
await waitFor(() => expect(create).toHaveBeenCalledOnce());
sessionMutation("logout");
credential.resolve(credentialFixture());
await screen.findByRole("alert");
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options",
]);
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
});
for (const phase of ["prompt", "confirmation"] as const) {
for (const reason of ["logout", "authentication"] as const) {
test(`activation UI rejects shared ${reason} stamp during ${phase} before event delivery`, async () => {
const { create, fetcher } = boundary("success");
const started = gate<void>();
const pending = gate<void>();
const request = fetcher.getMockImplementation()!;
if (phase === "prompt") {
create.mockImplementation(async () => {
started.resolve();
await pending.promise;
return credentialFixture();
});
} else {fetcher.mockImplementation(async (input, init) => {
if (String(input) === "/api/auth/whoami") {
started.resolve();
await pending.promise;
}
return await request(input, init);
});}
render(Activate);
await submit();
await started.promise;
const generation = browserSession.authenticationGeneration;
localStorage.setItem(
"yoi.browser-auth-generation",
JSON.stringify({ id: crypto.randomUUID(), reason }),
);
expect(browserSession.authenticationGeneration).toBe(generation);
pending.resolve();
await screen.findByRole("alert");
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(
fetcher.mock.calls.filter(([path]) =>
path === "/api/auth/activation/complete"
),
).toHaveLength(phase === "prompt" ? 0 : 1);
});
}
}
@@ -53,6 +53,7 @@ function boundary() {
if (state.sessionGate) return state.sessionGate.promise;
return state.whoamiStatus === 200
? Response.json({
authentication_reference: state.signedIn ? "fixture-authentication" : null,
session: state.signedIn
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -326,6 +327,7 @@ test("pending create remains single during an explicit authentication change and
expect(goto).not.toHaveBeenCalled();
state.sessionGate.resolve(
Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "browser_session" },
}),
@@ -22,6 +22,7 @@ function boundary(
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
if (input === "/api/auth/whoami") {
return Response.json({
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -27,6 +27,7 @@ function boundary(
const path = String(input);
if (path === "/api/auth/whoami") {
return Response.json({
authentication_reference: persona !== "anonymous" ? "fixture-authentication" : null,
session: persona !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -1,7 +1,8 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
import { loginWithPasskey } from '#lib/workspace/auth/api.ts';
import { loginWithPasskey, loadWhoami } from '#lib/workspace/auth/api.ts';
import { observeAuthOperation } from '#lib/workspace/auth/operation.ts';
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
import type { AuthUser } from '#lib/workspace/auth/model.ts';
@@ -12,10 +13,12 @@
let verifiedLogin = $state(false);
let error = $state('');
const controller = new AbortController();
let loginController: AbortController | null = null;
let active = true;
let verifiedGeneration: number | null = null;
const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration;
let verifiedStamp: string | null = null;
const isCurrent = (generation: number, stamp: string | null) => active && generation === browserSession.authenticationGeneration && stamp === browserSession.authenticationStamp;
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
function matchesSession(expected: AuthUser): boolean {
const snapshot = browserSession.snapshot;
@@ -28,9 +31,10 @@
return browserSession.subscribe(() => {
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) {
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || verifiedStamp !== browserSession.authenticationStamp || !matchesSession(user)) {
verifiedLogin = false;
verifiedGeneration = null;
verifiedStamp = null;
}
});
});
@@ -61,34 +65,56 @@
error = '';
sessionMutation();
let generation = browserSession.authenticationGeneration;
let stamp = browserSession.authenticationStamp;
const operation = new AbortController();
loginController = operation;
let promptGeneration: number | null = generation;
const unsubscribe = browserSession.subscribe(() => {
if (promptGeneration !== null && !isCurrent(promptGeneration, stamp)) operation.abort();
});
try {
const signedIn = await loginWithPasskey(controller.signal);
if (!isCurrent(generation)) throw new Error('Login superseded');
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) {
const initial = await loadWhoami(operation.signal);
if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
observeAuthOperation(operation.signal, {
expected: initial, phase: () => {}, additionAttempt: () => {},
verifySession: async () => {
if (!isCurrent(generation, stamp)) operation.abort();
operation.signal.throwIfAborted();
const current = await loadWhoami(operation.signal);
if (!isCurrent(generation, stamp) || initial.actor?.user_id !== current.actor?.user_id || initial.actor?.account_id !== current.actor?.account_id || initial.authentication_reference !== current.authentication_reference) throw new Error('Session changed');
},
});
const signedIn = await loginWithPasskey(operation.signal);
if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
if (signedIn.user.user_id !== expected.user_id || signedIn.user.account_id !== expected.account_id) {
throw new Error('Unexpected login actor');
}
// Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。
promptGeneration = null;
sessionMutation();
generation = browserSession.authenticationGeneration;
stamp = browserSession.authenticationStamp;
await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified');
if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session not verified');
const access = await loadHostingAccess();
if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified');
if (!isCurrent(generation, stamp) || !access.is_admin) throw new Error('Access not verified');
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded');
if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session superseded');
verifiedGeneration = generation;
verifiedStamp = stamp;
verifiedLogin = true;
notifySessionChanged();
} catch {
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
// 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
if (isCurrent(generation)) {
if (isCurrent(generation, stamp)) {
sessionMutation();
await browserSession.refresh();
}
} finally {
promptGeneration = null; unsubscribe(); loginController = null;
if (active) busy = false;
}
}
@@ -97,6 +123,7 @@
active = false;
token = '';
controller.abort();
loginController?.abort();
});
</script>
@@ -36,37 +36,56 @@ function boundary(
const create = vi.fn(async () => credentialFixture());
const get = vi.fn(async () => credentialFixture(true));
vi.stubGlobal("navigator", { credentials: { create, get } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") {
return Response.json({
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
});
}
if (path === "/api/hosting/access") {
return Response.json({ is_admin: admin });
}
if (path === "/api/auth/passkeys/addition/options") {
return status === 200
? Response.json(registrationOptions)
: new Response("host-test-token", { status });
}
if (path === "/api/auth/passkeys/addition/complete") {
return Response.json({ user: authUser });
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return failLogin
? new Response(null, { status: 401 })
: Response.json({ user: authUser });
}
throw new Error(`Unexpected API: ${path}`);
});
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async (input, init) => {
const path = String(input);
if (
path === "/api/auth/whoami" &&
new Headers(init?.headers).has("authorization")
) {
return status === 200
? Response.json({
actor: { ...authUser, auth_method: "api_token" },
authentication_reference: "fixture-bearer-authentication",
session: { kind: "authenticated", expires_at: null },
})
: new Response("host-test-token", { status });
}
if (path === "/api/auth/whoami") {
return Response.json({
authentication_reference: actor ? "fixture-authentication" : null,
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
});
}
if (path === "/api/hosting/access") {
return Response.json({ is_admin: admin });
}
if (path === "/api/auth/passkeys/addition/options") {
return status === 200
? Response.json(registrationOptions)
: new Response("host-test-token", { status });
}
if (path === "/api/auth/passkeys/addition/complete") {
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return failLogin ? new Response(null, { status: 401 }) : Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
throw new Error(`Unexpected API: ${path}`);
},
);
vi.stubGlobal("fetch", fetcher);
return { fetcher, create, get };
}
@@ -117,8 +136,10 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
for (const [path, init] of requests) {
expect(String(path)).not.toContain("host-test-token");
expect(String(init?.body)).not.toContain("host-test-token");
expect(init?.credentials).toBe("omit");
expect(new Headers(init?.headers).has("cookie")).toBe(false);
expect(new Headers(init?.headers).get("authorization")).toBe(
String(path).includes("/addition/") ? "Bearer host-test-token" : null,
"Bearer host-test-token",
);
}
await fireEvent.click(
@@ -130,7 +151,7 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
expect(settings.getAttribute("href")).toBe("/hosting/settings");
expect(get).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
.toHaveLength(2);
.toHaveLength(6);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
.toHaveLength(1);
expect(
@@ -179,8 +200,10 @@ test("a different cookie actor cannot be accepted as the enrolled administrator"
const { fetcher } = boundary();
const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami"
String(input) === "/api/auth/whoami" &&
!new Headers(init?.headers).has("authorization")
? Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: {
...authUser,
@@ -221,7 +244,12 @@ test("leaving during the passkey prompt aborts enrollment and prevents later com
unmount();
expect(signal.aborted).toBe(true);
resolve(credentialFixture());
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(fetcher.mock.calls.map(([path]) => path)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
])
);
const { unmount: unmountAgain } = render(Setup);
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe("");
@@ -248,7 +276,7 @@ test("completion response loss does not claim success and points to login before
);
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe("");
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher).toHaveBeenCalledTimes(3);
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
.toBeNull();
});
@@ -259,6 +287,7 @@ test("API token identity is not mistaken for a verified browser session", async
fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami"
? Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "api_token" },
})
@@ -292,6 +321,7 @@ const replacedActor = {
function identityResponse(actor: typeof setupActor | null) {
return Response.json({
actor,
authentication_reference: actor ? "fixture-authentication" : null,
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -317,15 +347,25 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
const started = gate<void>();
const pending = gate<Response>();
let current: typeof setupActor | null = setupActor;
let reads = 0;
let loginCompleted = false;
let accessRead = false;
let held = false;
fetcher.mockImplementation((input, init) => {
const path = String(input);
// Bearer identity resolution belongs to enrollment, never the cookie-login gates.
if (new Headers(init?.headers).has("authorization")) {
return original(input, init);
}
if (path === "/api/auth/passkeys/login/complete") loginCompleted = true;
if (path === "/api/hosting/access") accessRead = true;
if (path === "/api/auth/whoami") {
++reads;
if (
(phase === "whoami" && reads === 1) ||
(phase === "confirmation" && reads === 2)
!held && loginCompleted && (
(phase === "whoami" && !accessRead) ||
(phase === "confirmation" && accessRead)
)
) {
held = true;
started.resolve();
return pending.promise;
}
@@ -357,7 +397,10 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
phase === "access"
? Response.json({ is_admin: true })
: phase === "login"
? Response.json({ user: authUser })
? Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
})
: identityResponse(setupActor),
);
await waitFor(() =>
@@ -421,3 +464,154 @@ for (const replacement of [null, replacedActor]) {
).toBe(false);
});
}
test("setup logout during native login prompt aborts and never submits the late credential", async () => {
const { get, fetcher } = boundary();
const prompted = gate<void>();
const credential = gate<Credential>();
get.mockImplementation(() => {
prompted.resolve();
return credential.promise;
});
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await prompted.promise;
await changedInAnotherTab(null);
const signal = (get.mock.calls[0] as unknown as [CredentialRequestOptions])[0]
.signal!;
expect(signal.aborted).toBe(true);
credential.resolve(credentialFixture(true));
await screen.findByRole("alert");
expect(
fetcher.mock.calls.some(([path]) =>
path === "/api/auth/passkeys/login/complete"
),
).toBe(false);
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
});
test("setup final confirmation rejects replacement session for the same owner", async () => {
const { fetcher } = boundary();
const request = fetcher.getMockImplementation()!;
let accessRead = false;
fetcher.mockImplementation(async (input, init) => {
if (String(input) === "/api/hosting/access") accessRead = true;
if (String(input) === "/api/auth/whoami" && accessRead) {
return Response.json({
actor: setupActor,
authentication_reference: "same-owner-different-session",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
}
return await request(input, init);
});
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await screen.findByRole("alert");
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
});
for (
const phase of [
"initial",
"options",
"prompt",
"pre-complete",
"complete",
"whoami",
"access",
"confirmation",
] as const
) {
for (const reason of ["logout", "authentication"] as const) {
test(`setup fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
const { fetcher, get } = boundary();
const request = fetcher.getMockImplementation()!;
const started = gate<void>();
const pending = gate<void>();
let optionsRead = false,
promptReturned = false,
completionSent = false,
accessRead = false,
held = false;
const hold = async () => {
held = true;
started.resolve();
await pending.promise;
};
get.mockImplementation(async () => {
if (phase === "prompt") await hold();
promptReturned = true;
return credentialFixture(true);
});
fetcher.mockImplementation(async (input, init) => {
if (new Headers(init?.headers).has("authorization")) {
return await request(input, init);
}
const path = String(input);
const point = path === "/api/auth/whoami"
? !optionsRead
? "initial"
: !completionSent && promptReturned
? "pre-complete"
: accessRead
? "confirmation"
: "whoami"
: path.endsWith("/login/options")
? "options"
: path.endsWith("/login/complete")
? "complete"
: path === "/api/hosting/access"
? "access"
: null;
if (point === "options") optionsRead = true;
if (point === "complete") completionSent = true;
if (point === "access") accessRead = true;
const response = await request(input, init);
if (!held && point === phase) await hold();
return response;
});
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await started.promise;
const generation = browserSession.authenticationGeneration;
const stamp = JSON.stringify({ id: crypto.randomUUID(), reason });
// 同一documentのsetItemはstorage eventを配送しない。generationを直接変えず順序を固定する。
localStorage.setItem("yoi.browser-auth-generation", stamp);
expect(browserSession.authenticationGeneration).toBe(generation);
pending.resolve();
await screen.findByRole("alert");
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Sign in with new passkey",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(
fetcher.mock.calls.filter(([path]) =>
path === "/api/auth/passkeys/login/complete"
),
).toHaveLength(
["complete", "whoami", "access", "confirmation"].includes(phase)
? 1
: 0,
);
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
// 古いcatchが新tabのstampを自身のsessionMutationで上書きしない。
expect(browserSession.authenticationStamp).toBe(stamp);
});
}
}
@@ -13,11 +13,12 @@
async function approve() {
if (auth.busy || !auth.actor || approved || !userCode.trim()) return;
const code = userCode.trim().toUpperCase();
await auth.run('Approving device login…', 'Device login approved. You can return to the CLI.', async (signal) => {
const confirmed = await auth.run('Approving device login…', 'Device login approved. You can return to the CLI.', async (signal) => {
const result = await approveDeviceLogin(code, signal);
if (result.status !== 'approved') throw new Error('Device approval not confirmed');
approved = true;
});
return result;
}, 'device-approval');
if (confirmed) approved = true;
}
onMount(() => {
userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? '';
@@ -57,7 +57,7 @@ beforeEach(() => {
"fetch",
vi.fn(async (input) => {
if (String(input) === "/api/auth/whoami") {
return Response.json({ session: { kind: "anonymous" }, actor: null });
return Response.json({ session: { kind: "anonymous" }, authentication_reference: null, actor: null });
}
throw new Error(`Unexpected request ${input}`);
}),
@@ -41,6 +41,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (path === "/api/workspaces") return new Response(null, { status: 503 });
if (path === "/api/auth/whoami") {
return Response.json({
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -89,7 +90,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (mode === "activation-unknown") {
return new Response(null, { status: 503 });
}
return Response.json({ user });
return Response.json({ user, authentication_reference: "fixture-authentication" });
}
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
const file = join(root, decodeURIComponent(path));
@@ -34,6 +34,11 @@ const register = async (page: Page) => {
await page.getByRole("button", { name: "Register new account", exact: true })
.click();
};
const additionConfirmed = (page: Page) =>
page.getByRole("status").filter({
hasText:
/^Passkey added to your account; the expected session was confirmed\.$/,
});
const count = (mutations: Mutation[], path: string) =>
mutations.filter((entry) => entry.path === path).length;
const at = async (page: Page, path: string) => {
@@ -151,7 +156,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
readyUrl: `${origin}/health`,
}],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${root}/target/web-ux/t767-validation/auth-interactions/processes`,
`${root}/target/t783/web-shell-final/processes`,
[],
);
let browser: Browser | undefined;
@@ -229,9 +234,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
await additionConfirmed(page).waitFor();
const additions = mutations.filter((entry) =>
entry.path.includes("/addition/")
);
@@ -239,7 +242,12 @@ Deno.test("standalone auth separates login, registration, own account and explic
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
assertEquals(additions[0].keys, ["browser_origin"]);
assertEquals(additions[0].keys, [
"browser_origin",
"expected_account_id",
"expected_authentication_reference",
"expected_user_id",
]);
assertEquals(additions[1].keys, ["challenge_id", "credential"]);
assertEquals(
mutations.some((entry) => entry.path.includes("/registration/")),
@@ -357,8 +365,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({ hasText: /added/i })
.waitFor();
await additionConfirmed(page).waitFor();
assertEquals(
count(mutations, "/api/auth/passkeys/addition/complete"),
1,
@@ -784,9 +791,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
.__authFixtureRelease()
);
if (purpose === "addition") {
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
await additionConfirmed(page).waitFor();
} else await at(page, "/dashboard");
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/options`),
@@ -813,8 +818,18 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Approve device login",
exact: true,
}).click();
await page.getByRole("alert").filter({ hasText: /invalid|expired/i })
.waitFor();
await page.getByRole("alert").filter({
hasText:
/^Authentication request was rejected \(400\)\. Check your session or restart the operation\.$/,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/login/device");
assertEquals(
await page.getByText(
"Device login approved. You can return to the CLI.",
{ exact: true },
).count(),
0,
);
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
}),
);
@@ -554,7 +554,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
},
);
await step(
"SPA page access checks once without hiding the admitted page",
"SPA page access checks once and fences key-list reads without hiding the admitted page",
async (page) => {
let checks = 0;
page.on("request", (request) => {
@@ -582,6 +582,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
await page.getByRole("link", { name: "Account", exact: true })
.click();
await requested;
assertEquals(checks, 2);
assertEquals(
await page.getByRole("heading", {
name: "Dashboard",
@@ -599,7 +600,10 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
exact: true,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/account");
assertEquals(checks, 2);
await page.getByRole("list", { name: "Registered keys" })
.waitFor();
// page入場の一回に加え、本人鍵一覧は読取りの前後で本人bindingを確認する。
assertEquals(checks, 4);
} finally {
release();
}
@@ -158,9 +158,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
return new Response(null, { status: 503 });
}
if (mode === "session-malformed") {
return Response.json({ actor: { handle: "untrusted" } });
return Response.json({
authentication_reference: state.signedIn
? "fixture-authentication"
: null,
actor: { handle: "untrusted" },
});
}
return Response.json({
authentication_reference: state.signedIn
? "fixture-authentication"
: null,
actor: state.signedIn
? { ...user, auth_method: "browser_session" }
: null,
@@ -168,11 +176,25 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
? {
kind: "authenticated",
expires_at: state.expiresAt ??
new Date(Date.now() + 86_400_000).toISOString(),
"2099-01-01T00:00:00Z",
}
: { kind: state.expired ? "expired" : "anonymous" },
});
}
if (path === "/api/auth/passkeys") {
return Response.json({
user,
keys: [{
key_reference: "synthetic-safe-key-reference",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
}],
});
}
if (path === "/api/auth/passkeys/addition/result") {
return Response.json({ status: "unknown" });
}
if (path === "/api/auth/registration-policy") {
if (mode === "policy-loading") return await new Promise<Response>(() => {});
if (mode === "policy-error") return new Response(null, { status: 503 });
@@ -227,7 +249,11 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
}
if (path === "/api/auth/device-login/approve") {
if (!state.signedIn) return new Response(null, { status: 401 });
if (mode === "device-expired") return new Response(null, { status: 410 });
if (mode === "device-expired") {
return Response.json({
message: "expired_device_login: device login code expired",
}, { status: 400 });
}
return Response.json({ status: "approved", user });
}
const passkey = path.match(
@@ -263,7 +289,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
state.expired = false;
state.cookieSession = false;
state.expiresAt = null;
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/activation/options") {
return Response.json(options(false));
@@ -273,7 +302,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
state.expired = false;
state.cookieSession = false;
state.expiresAt = null;
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
if (workspaceFixture && path.startsWith("/api/w/")) {
@@ -0,0 +1,208 @@
/// <reference lib="dom" />
// 本番shellと可視状態の試験。HTTP/認証器を合成fixtureへ置換するため、暗号/保存の成功根拠ではない。
// native CTAP2 + 実Backend経路はtests/e2eの別suiteが所有する。
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { type Browser, chromium } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
Deno.test("account addition keeps pending and recovery visible at desktop and mobile widths", async (t) => {
const root = resolve(import.meta.dirname!, "../../../..");
const reservation = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (reservation.addr as Deno.NetAddr).port;
reservation.close();
const origin = `http://127.0.0.1:${port}`;
const out = `${root}/target/web-ux/t783-after`;
const processes = await startOwnedProcesses(
[{
id: "passkey-state",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
String(port),
"web/workspace/build",
],
readyUrl: `${origin}/health`,
}],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${out}/processes`,
[],
);
let browser: Browser | undefined;
const records: unknown[] = [];
try {
browser = await chromium.launch();
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const passed = await t.step(
`${theme}/${width}: visible pending, stop and migration metadata`,
async () => {
const context = await browser!.newContext({
viewport: { width, height: 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
page.setDefaultTimeout(5000);
const pageErrors: string[] = [];
const statuses: number[] = [];
const consoleErrors: string[] = [];
const requestErrors: string[] = [];
page.on(
"requestfailed",
() => requestErrors.push("request-failed"),
);
page.on("pageerror", () => pageErrors.push("page-error"));
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push("console-error");
}
});
page.on("response", (response) => {
if (response.status() >= 400) statuses.push(response.status());
});
await context.route(
"**/*",
(route) =>
new URL(route.request().url()).origin === origin
? route.continue()
: route.abort(),
);
await page.request.get(`${origin}/?fixture=addition-pending`);
const documentResponse = await page.goto(`${origin}/account`);
const add = page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
});
await add.waitFor();
await page.getByRole("list", { name: "Registered keys" })
.waitFor();
await page.screenshot({
path: `${out}/account-${theme}-${width}.png`,
fullPage: true,
});
await add.click();
const pending = page.getByRole("status").filter({
hasText: "Waiting for passkey addition",
});
await pending.waitFor({ state: "visible" });
const box = await pending.boundingBox();
assert(
box && box.y >= 0 && box.y + box.height <= 900,
"pending must be in the initial visible viewport",
);
assertEquals(await add.isDisabled(), true);
assertEquals(
await page.getByText("Loading session…", { exact: true })
.isVisible(),
false,
);
await page.screenshot({
path: `${out}/pending-${theme}-${width}.png`,
fullPage: true,
});
const pendingAccessibility = await page.locator("main")
.ariaSnapshot();
await page.getByRole("button", {
name: "Stop waiting",
exact: true,
}).click();
await page.evaluate(() =>
(window as unknown as { __authFixtureRelease: () => void })
.__authFixtureRelease()
);
await page.getByRole("alert").filter({
hasText: "Completion was not sent",
}).waitFor();
await page.getByRole("button", {
name: "Refresh registered keys",
exact: true,
}).click();
await page.getByText(
"Not yet verified — storage alone does not prove support",
{ exact: true },
).waitFor();
await page.getByText(
"Verify migration before retiring older-key login",
{ exact: true },
).click();
await page.screenshot({
path: `${out}/migration-${theme}-${width}.png`,
fullPage: true,
});
assertEquals(
await page.evaluate(() =>
document.documentElement.scrollWidth > innerWidth
),
false,
);
assertEquals(documentResponse?.status(), 200);
assertEquals(pageErrors, []);
assertEquals(consoleErrors, []);
assertEquals(statuses, []);
assertEquals(requestErrors, []);
const images = [];
for (
const name of [
`account-${theme}-${width}.png`,
`pending-${theme}-${width}.png`,
`migration-${theme}-${width}.png`,
]
) {
const digest = await crypto.subtle.digest(
"SHA-256",
await Deno.readFile(`${out}/${name}`),
);
images.push({
name,
sha256: Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join(""),
});
}
records.push({
route: "/account",
actor: "synthetic Alice",
theme,
width,
documentStatus: documentResponse?.status(),
pageErrors,
consoleErrors,
requestErrors,
statuses,
pendingAccessibility,
visible: await page.locator("main").innerText(),
images,
});
} finally {
await context.close();
}
},
);
if (!passed) {
throw new Error(
"Stop on the first failed visibility condition; investigate before repeating the matrix",
);
}
}
}
} finally {
await browser?.close();
await stopOwnedProcesses(processes);
await Deno.writeTextFile(
`${out}/review-context.json`,
JSON.stringify(records, null, 2),
);
}
});
@@ -239,6 +239,10 @@ export async function sessionNavigation(
name: "Add passkey to alice",
exact: true,
}).waitFor();
// Accountの初期metadata確認はwhoamiを前後で読む。完了を可視結果で
// 待ち、短命cookieの観測へ初期requestを混入させない。
await page.getByRole("list", { name: "Registered keys", exact: true })
.waitFor();
const armed = await control(page, "arm-cookie");
assert(armed.headers()["set-cookie"].includes("Max-Age=3"));
const cookie = (await page.context().cookies()).find((c) =>
@@ -361,6 +365,10 @@ export async function sessionNavigation(
await go("/account?tab=keys#stale", "account");
await page.getByRole("button", { name: "Log out", exact: true })
.waitFor();
// 保留するのは明示refresh。初期metadata requestは離脱時にabortされる
// 別のlifetimeなので、先に完了させる。
await page.getByRole("list", { name: "Registered keys", exact: true })
.waitFor();
let started!: () => void;
let failed!: (error: unknown) => void;
let release!: () => void;
@@ -433,7 +441,7 @@ export async function sessionNavigation(
},
);
await step(
"challenge 401 leaves an authenticated account in place and permits retry",
"challenge 401 leaves an authenticated account in place but requires result recovery before another addition",
async ({ page, go }) => {
const target = "/account?tab=keys#challenge";
await go(target, "addition-expired");
@@ -453,7 +461,21 @@ export async function sessionNavigation(
name: "Add passkey to alice",
exact: true,
}).isEnabled(),
true,
false,
);
await page.getByRole("button", {
name: "Check addition result",
exact: true,
}).click();
await page.getByRole("status").filter({
hasText: "addition result is still unknown",
}).waitFor();
assertEquals(
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).isEnabled(),
false,
);
},
);
@@ -466,7 +488,11 @@ export async function sessionNavigation(
await at(page, "/");
const wire = await (await page.request.get(`${origin}/api/auth/whoami`))
.json();
assertEquals(wire, { actor: null, session: { kind: "anonymous" } });
assertEquals(wire, {
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
@@ -487,6 +513,9 @@ export async function sessionNavigation(
name: "Add passkey to alice",
exact: true,
}).waitFor();
// 初期metadata確認のwhoamiをlifecycle event由来と誤計数しない。
await page.getByRole("list", { name: "Registered keys", exact: true })
.waitFor();
await control(page, "expire");
let checks = 0;
page.on("request", (request) => {
@@ -19,7 +19,7 @@ const mime: Record<string, string> = {
};
const policies = new Map<string, boolean>();
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。
// 送らないので、Bearer有無と直前の合成page条件で応答を選ぶ。header値は読まない。
let setupMode: string | null = null;
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url);
@@ -28,19 +28,27 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) {
setupMode = selected;
}
const tokenRequest = request.headers.has("authorization");
const mode = selected ??
(tokenRequest ? setupMode : null) ??
request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
(url.pathname.startsWith("/api/auth/passkeys/addition/")
? setupMode
: null) ??
"admin-on";
if (selected) policies.delete(mode);
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
const setup = setupProjectionResponse(
url.pathname,
mode,
selected !== null,
tokenRequest,
);
if (setup) return setup;
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
const ordinary = mode.startsWith("ordinary");
if (url.pathname === "/api/auth/whoami") {
return Response.json({
authentication_reference: anonymous ? null : "fixture-authentication",
session: !anonymous
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -53,6 +61,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
},
});
}
if (url.pathname === "/api/auth/passkeys") {
return Response.json({
user: {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
},
keys: [],
});
}
if (url.pathname === "/api/workspaces") return Response.json([]);
if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: !ordinary && !anonymous });
@@ -19,6 +19,7 @@ export function setupProjectionResponse(
path: string,
mode: string,
reset: boolean,
tokenRequest = false,
): Response | null {
if (!mode.startsWith("setup-")) return null;
if (reset) stages.delete(mode);
@@ -28,7 +29,17 @@ export function setupProjectionResponse(
});
}
if (path === "/api/auth/whoami") {
if (tokenRequest) {
return Response.json({
actor: { ...user, auth_method: "api_token" },
authentication_reference: "fixture-token-binding",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
}
return Response.json({
authentication_reference: stages.get(mode) === "login"
? "fixture-authentication"
: null,
session: stages.get(mode) === "login"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -56,7 +67,10 @@ export function setupProjectionResponse(
}
if (path === "/api/auth/passkeys/addition/complete") {
stages.set(mode, "enrolled");
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json({
@@ -66,7 +80,10 @@ export function setupProjectionResponse(
}
if (path === "/api/auth/passkeys/login/complete") {
stages.set(mode, "login");
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
return null;
}
+7 -1
View File
@@ -26,6 +26,7 @@ Deno.test("authentication and hosting shells never require Workspace catalog aut
display_name: "Alice",
auth_method: "browser_session",
},
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
browserSession.invalidate();
@@ -60,6 +61,7 @@ Deno.test("account loads its sidebar catalog without making catalog failure an a
display_name: "Alice",
auth_method: "browser_session",
},
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
browserSession.invalidate();
@@ -106,7 +108,11 @@ Deno.test("root admission never starts private catalog while anonymous, expired,
if (condition === "loading") await pending;
return condition === "error" || condition === "loading"
? new Response(null, { status: 503 })
: Response.json({ actor: null, session: { kind: condition } });
: Response.json({
actor: null,
authentication_reference: null,
session: { kind: condition },
});
};
let privateCalls = 0;
const event = {
+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), [