fix(auth): confirm passkey operations and preserve safe legacy migration
Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions. Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
This commit is contained in:
@@ -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"
|
||||
},
|
||||
"imports": {
|
||||
|
||||
@@ -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,130 @@ 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"] : []),
|
||||
]);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import type {
|
||||
ActivationOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { readBoundedAuthResponseJson, loadWhoami } from "./api.ts";
|
||||
import { browserSession } from "./session-state.ts";
|
||||
import { normalizeActivationHandle } from "./hosting-accounts.ts";
|
||||
import {
|
||||
type AuthUser,
|
||||
@@ -19,6 +20,7 @@ export async function activateWithPasskey(
|
||||
code: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
try {
|
||||
handle = normalizeActivationHandle(handle);
|
||||
if (window.isSecureContext !== true || !code || code.length > 4096) {
|
||||
@@ -61,15 +63,19 @@ export async function activateWithPasskey(
|
||||
});
|
||||
if (!credential) throw new Error("Registration cancelled");
|
||||
signal.throwIfAborted();
|
||||
if (generation !== browserSession.authenticationGeneration) throw new Error("Activation superseded");
|
||||
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;
|
||||
if (result.user.handle !== handle || generation !== browserSession.authenticationGeneration) throw new Error("Activation target mismatch");
|
||||
const current = await loadWhoami(signal);
|
||||
signal.throwIfAborted();
|
||||
if (generation !== browserSession.authenticationGeneration || 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" },
|
||||
|
||||
@@ -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,224 @@ 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…";
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.operationGeneration = generation;
|
||||
try {
|
||||
const before = await loadWhoami(controller.signal);
|
||||
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);
|
||||
const after = await loadWhoami(controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
if (
|
||||
this.disposed ||
|
||||
generation !== browserSession.authenticationGeneration ||
|
||||
!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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,474 @@
|
||||
// @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 };
|
||||
}
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -18,6 +18,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" },
|
||||
|
||||
@@ -1,30 +1,68 @@
|
||||
<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 { 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;
|
||||
keysLoading = true; keysError = '';
|
||||
try {
|
||||
const before = await loadWhoami(controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
if (!active || read !== keyRead) return;
|
||||
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);
|
||||
controller.signal.throwIfAborted();
|
||||
if (!active || read !== keyRead) return;
|
||||
const after = await loadWhoami(controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
if (read !== keyRead) return;
|
||||
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 +75,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 +119,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;
|
||||
@@ -231,19 +240,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 +360,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 +389,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 +403,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 +505,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 +526,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 +576,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 +688,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 +701,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 +723,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 +762,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(
|
||||
|
||||
@@ -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,64 @@ 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();
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
@@ -333,6 +334,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,6 +13,7 @@
|
||||
let verifiedLogin = $state(false);
|
||||
let error = $state('');
|
||||
const controller = new AbortController();
|
||||
let loginController: AbortController | null = null;
|
||||
let active = true;
|
||||
|
||||
let verifiedGeneration: number | null = null;
|
||||
@@ -61,22 +63,40 @@
|
||||
error = '';
|
||||
sessionMutation();
|
||||
let generation = browserSession.authenticationGeneration;
|
||||
const operation = new AbortController();
|
||||
loginController = operation;
|
||||
let promptGeneration: number | null = generation;
|
||||
const unsubscribe = browserSession.subscribe(() => {
|
||||
if (promptGeneration !== null && promptGeneration !== browserSession.authenticationGeneration) operation.abort();
|
||||
});
|
||||
try {
|
||||
const signedIn = await loginWithPasskey(controller.signal);
|
||||
const initial = await loadWhoami(operation.signal);
|
||||
if (!isCurrent(generation)) throw new Error('Login superseded');
|
||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) {
|
||||
observeAuthOperation(operation.signal, {
|
||||
expected: initial, phase: () => {}, additionAttempt: () => {},
|
||||
verifySession: async () => {
|
||||
if (!isCurrent(generation)) operation.abort();
|
||||
operation.signal.throwIfAborted();
|
||||
const current = await loadWhoami(operation.signal);
|
||||
if (!isCurrent(generation) || 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)) 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;
|
||||
await browserSession.refresh();
|
||||
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified');
|
||||
if (!isCurrent(generation) || !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');
|
||||
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
|
||||
await browserSession.refresh();
|
||||
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded');
|
||||
if (!isCurrent(generation) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session superseded');
|
||||
verifiedGeneration = generation;
|
||||
verifiedLogin = true;
|
||||
notifySessionChanged();
|
||||
@@ -89,6 +109,7 @@
|
||||
await browserSession.refresh();
|
||||
}
|
||||
} finally {
|
||||
promptGeneration = null; unsubscribe(); loginController = null;
|
||||
if (active) busy = false;
|
||||
}
|
||||
}
|
||||
@@ -97,6 +118,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,36 @@ 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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -549,7 +549,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) => {
|
||||
@@ -577,6 +577,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",
|
||||
@@ -594,7 +595,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),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -431,7 +431,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");
|
||||
@@ -451,7 +451,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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -464,7 +478,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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,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();
|
||||
@@ -68,7 +69,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 = {
|
||||
|
||||
@@ -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), [
|
||||
|
||||
Reference in New Issue
Block a user