chore: merge develop into hare/develop

This commit is contained in:
2026-10-16 05:52:36 +09:00
85 changed files with 21016 additions and 329 deletions
@@ -15,6 +15,8 @@ export type AuthBootstrapUserRequest = { handle: string, display_name?: string |
export type AuthUserResponse = { user: AuthenticatedUser, };
export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, };
export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, };
export type PasskeyRegistrationOptionsResponse = { challenge_id: string, public_key: unknown, };
@@ -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:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
// 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:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
// 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:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
// 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:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
// 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-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_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:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
// 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
@@ -0,0 +1,50 @@
// HTTP caller試験は本番adapterとWebAuthn変換を接続し、fetchとbrowser credential境界だけを代替する。
// 認証・署名の検証はBackend suiteが所有し、このfixtureでは保証しない。
export const authUser = {
user_id: "user-alice",
account_id: "account-alice",
handle: "alice",
display_name: "Alice",
};
export const registrationOptions = {
challenge_id: "create-challenge",
public_key: {
publicKey: {
challenge: "AQID",
rp: { id: "localhost", name: "Yoi" },
user: { id: "BAUG", name: "alice", displayName: "Alice" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
},
};
export const loginOptions = {
challenge_id: "login-challenge",
public_key: {
publicKey: {
challenge: "AQID",
allowCredentials: [{ type: "public-key", id: "BwgJ" }],
},
},
};
export function credentialFixture(authentication = false): Credential {
const bytes = new Uint8Array([1, 2, 3]).buffer;
return {
id: "AQID",
rawId: bytes,
type: "public-key",
authenticatorAttachment: "platform",
getClientExtensionResults: () => ({}),
response: authentication
? {
clientDataJSON: bytes,
authenticatorData: bytes,
signature: bytes,
userHandle: null,
}
: {
clientDataJSON: bytes,
attestationObject: bytes,
getTransports: () => ["internal"],
},
} as unknown as Credential;
}
+25 -4
View File
@@ -1,5 +1,6 @@
import type {
DeviceLoginApproveRequest,
PasskeyAdditionOptionsRequest,
PasskeyLoginCompleteRequest,
PasskeyLoginOptionsRequest,
PasskeyRegistrationCompleteRequest,
@@ -114,7 +115,23 @@ export async function registerPasskey(
display_name: displayName ?? null,
browser_origin: window.location.origin,
};
const options = await requestJson("/api/auth/passkeys/registration/options", {
return await createPasskey("registration", optionsRequest);
}
export async function addPasskey(): Promise<AuthUser> {
const optionsRequest: PasskeyAdditionOptionsRequest = {
browser_origin: window.location.origin,
};
return await createPasskey("addition", optionsRequest);
}
async function createPasskey(
purpose: "registration" | "addition",
optionsRequest:
| PasskeyRegistrationOptionsRequest
| PasskeyAdditionOptionsRequest,
): Promise<AuthUser> {
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
method: "POST",
body: JSON.stringify(optionsRequest),
});
@@ -137,7 +154,7 @@ export async function registerPasskey(
credential: registrationCredentialToJson(credential),
};
const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/registration/complete", {
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
method: "POST",
body: JSON.stringify(completeRequest),
}),
@@ -145,9 +162,13 @@ export async function registerPasskey(
return result.user;
}
export async function loginWithPasskey(handle?: string): Promise<AuthUser> {
export async function loginWithPasskey(handle: string): Promise<AuthUser> {
const loginHandle = handle.trim();
if (!loginHandle) {
throw new Error("User handle is required for passkey login.");
}
const optionsRequest: PasskeyLoginOptionsRequest = {
handle: handle ?? null,
handle: loginHandle,
browser_origin: window.location.origin,
};
const options = await requestJson("/api/auth/passkeys/login/options", {
@@ -1284,9 +1284,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
const devicePage = await Deno.readTextFile(
new URL("./../../../routes/login/device/+page.svelte", import.meta.url),
);
const authApi = await Deno.readTextFile(
new URL("../auth/api.ts", import.meta.url),
);
const rootLayout = await Deno.readTextFile(
new URL("./../../../routes/+layout.svelte", import.meta.url),
);
@@ -1339,14 +1336,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
devicePage.includes("Approve device login"),
"Device login page should approve CLI login without DevTools console",
);
assert(
authApi.includes("/api/auth/whoami") &&
authApi.includes("/api/auth/passkeys/registration/options") &&
authApi.includes("/api/auth/passkeys/login/complete") &&
authApi.includes("/api/auth/logout") &&
authApi.includes("/api/auth/device-login/approve"),
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
);
assert(
rootLayout.includes("SIDEBAR_CONTEXT") &&
rootLayout.includes("GlobalSidebar") &&
+19 -9
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
addPasskey,
loadWhoami,
loginWithPasskey,
logout,
@@ -13,16 +14,19 @@
let handle = $state('local');
let displayName = $state('Local User');
let loading = $state(true);
let sessionReady = $state(false);
let busy = $state(false);
let status = $state('');
let error = $state('');
async function refreshWhoami() {
loading = true;
sessionReady = false;
error = '';
try {
const whoami = await loadWhoami();
actor = whoami.actor;
sessionReady = true;
if (actor?.handle) {
handle = actor.handle;
displayName = actor.display_name;
@@ -35,12 +39,18 @@
}
async function register() {
if (busy || !sessionReady) return;
const adding = actor !== null;
busy = true;
status = 'Waiting for passkey registration…';
status = adding ? 'Waiting for passkey addition…' : 'Waiting for new account registration…';
error = '';
try {
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
status = 'Passkey registered.';
if (adding) {
await addPasskey();
} else {
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
}
status = adding ? 'Passkey added to your account.' : 'New account registered.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
@@ -138,16 +148,16 @@
<h2>Passkey</h2>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
<label>
User handle
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
{actor ? 'Current user handle' : 'User handle (new registration or login)'}
<input bind:value={handle} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="username webauthn" placeholder="local" />
</label>
<label>
Display name
<input bind:value={displayName} autocomplete="name" placeholder="Local User" />
Display name (new account only)
<input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="name" placeholder="Local User" />
</label>
<div class="settings-action-row">
<button type="button" disabled={busy || !handle.trim()} onclick={register}>Register passkey</button>
<button type="button" disabled={busy || !handle.trim()} onclick={login}>Log in with passkey</button>
<button type="button" disabled={busy || loading || !sessionReady || (!actor && !handle.trim())} onclick={register}>{actor ? `Add passkey to ${actor.handle}` : 'Register new account'}</button>
<button type="button" disabled={busy || loading || !sessionReady || actor !== null || !handle.trim()} onclick={login}>Log in with passkey</button>
</div>
</form>
</section>
@@ -0,0 +1,180 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import Account from "./+page.svelte";
import {
authUser,
credentialFixture,
registrationOptions,
} from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// UIから本番HTTP adapterまでを接続し、fetch/ブラウザーcredential境界だけを代替する。
function boundary(authenticated: boolean, completeStatus = 200) {
const create = vi.fn(async () => credentialFixture());
vi.stubGlobal("navigator", { credentials: { create } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") {
return Response.json({
actor: authenticated
? { ...authUser, auth_method: "browser_session" }
: null,
});
}
if (path.endsWith("/options")) return Response.json(registrationOptions);
if (path.endsWith("/complete")) {
if (completeStatus !== 200) {
return new Response(null, { status: completeStatus });
}
authenticated = true;
return Response.json({ user: authUser });
}
throw new Error(`Unexpected API: ${path}`);
});
vi.stubGlobal("fetch", fetcher);
return { fetcher, create };
}
function posted(fetcher: ReturnType<typeof boundary>["fetcher"]) {
return fetcher.mock.calls.filter(([, init]) => init?.method === "POST").map((
[path, init],
) => ({
path: String(path),
body: JSON.parse(String(init!.body)),
credentials: init!.credentials,
}));
}
test("anonymous existing Account page registers a new account via registration endpoints", async () => {
const { fetcher, create } = boundary(false);
render(Account);
const button = await screen.findByRole("button", {
name: "Register new account",
});
await waitFor(() =>
expect((button as HTMLButtonElement).disabled).toBe(false)
);
await fireEvent.input(
screen.getByLabelText("User handle (new registration or login)"),
{ target: { value: " alice " } },
);
await fireEvent.input(
screen.getByLabelText("Display name (new account only)"),
{ target: { value: "Alice" } },
);
await fireEvent.click(button);
await screen.findByText("New account registered.");
const requests = posted(fetcher);
expect(requests.map((request) => request.path)).toEqual([
"/api/auth/passkeys/registration/options",
"/api/auth/passkeys/registration/complete",
]);
expect(requests[0].body).toEqual({
handle: "alice",
display_name: "Alice",
browser_origin: window.location.origin,
});
expect(requests[1].body.challenge_id).toBe(registrationOptions.challenge_id);
expect(create).toHaveBeenCalledOnce();
});
test("authenticated Account locks identity fields and adds only through self-only addition endpoints", async () => {
const { fetcher, create } = boundary(true);
render(Account);
const button = await screen.findByRole("button", {
name: "Add passkey to alice",
});
expect(
(screen.getByLabelText("Current user handle") as HTMLInputElement).disabled,
).toBe(true);
expect(
(screen.getByLabelText(
"Display name (new account only)",
) as HTMLInputElement).disabled,
).toBe(true);
expect(
(screen.getByRole("button", {
name: "Log in with passkey",
}) as HTMLButtonElement).disabled,
).toBe(true);
await fireEvent.click(button);
await screen.findByText("Passkey added to your account.");
const requests = posted(fetcher);
expect(requests.map((request) => request.path)).toEqual([
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
expect(requests[0].body).toEqual({ browser_origin: window.location.origin });
expect(Object.keys(requests[1].body).sort()).toEqual([
"challenge_id",
"credential",
]);
expect(requests.map((request) => request.credentials)).toEqual([
"same-origin",
"same-origin",
]);
expect(create).toHaveBeenCalledOnce();
});
test("addition completion rejection is visible and never retries as new registration", async () => {
const { fetcher } = boundary(true, 401);
render(Account);
await fireEvent.click(
await screen.findByRole("button", { name: "Add passkey to alice" }),
);
await screen.findByText("Auth request failed (401).");
expect(posted(fetcher).map((request) => request.path)).toEqual([
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
});
test("blank handle disables anonymous registration and login", async () => {
boundary(false);
render(Account);
await screen.findByText("No browser session is active.");
await fireEvent.input(
screen.getByLabelText("User handle (new registration or login)"),
{ target: { value: " " } },
);
expect(
(screen.getByRole("button", {
name: "Register new account",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(
(screen.getByRole("button", {
name: "Log in with passkey",
}) as HTMLButtonElement).disabled,
).toBe(true);
});
test("unknown session never enables registration or addition", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 503 })),
);
render(Account);
await screen.findByText("Auth request failed (503).");
expect(
(screen.getByRole("button", {
name: "Register new account",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(
(screen.getByLabelText(
"User handle (new registration or login)",
) as HTMLInputElement).disabled,
).toBe(true);
});
@@ -0,0 +1,42 @@
// production shellを既存static fixtureから配信し、Account表示用の境界だけを置換する。
// 実account/session/credentialは作成せず、queryで撮影条件を固定する。
const port = Number(Deno.args[0]);
const upstream = Deno.args[1];
if (!Number.isInteger(port) || !upstream) {
throw new Error("usage: server <port> <upstream>");
}
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
if (url.pathname === "/api/auth/whoami") {
return Response.json({
actor:
request.headers.get("cookie")?.includes("fixture-actor=authenticated")
? {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
auth_method: "browser_session",
}
: null,
});
}
if (url.pathname === "/api/workspaces") return Response.json([]);
if (url.pathname.startsWith("/api/")) {
return new Response("unexpected fixture API", { status: 404 });
}
const response = await fetch(new URL(url.pathname + url.search, upstream));
const headers = new Headers(response.headers);
if (url.pathname === "/account") {
headers.set(
"set-cookie",
`fixture-actor=${
url.searchParams.get("actor") === "authenticated"
? "authenticated"
: "anonymous"
}; Path=/; SameSite=Strict`,
);
}
return new Response(response.body, { status: response.status, headers });
});
@@ -0,0 +1,76 @@
{
"schemaVersion": 1,
"id": "account-passkeys",
"title": "Existing Account registration and self-only passkey addition",
"baseUrl": "http://127.0.0.1:15371",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "synthetic",
"label": "Synthetic browser session; no real credentials",
"auth": { "kind": "anonymous" }
}
],
"viewports": [
{ "label": "desktop", "width": 1440, "height": 900 },
{ "label": "narrow", "width": 768, "height": 900 },
{ "label": "mobile", "width": 390, "height": 844 }
],
"routes": [
{
"id": "anonymous",
"label": "New registration or explicit-handle login",
"path": "/account",
"goal": "Identify registration as a new-account operation, distinct from logging into an existing handle.",
"dataState": "No browser session; existing Account page.",
"ready": {
"kind": "selector",
"selector": ".account-panel:has-text('No browser session is active.')"
},
"capturePoints": [
{ "id": "initial", "label": "Anonymous Account", "fullPage": true }
]
},
{
"id": "authenticated",
"label": "Add only to current user",
"path": "/account?actor=authenticated",
"goal": "Identify the authenticated actor as the passkey addition target; identity fields cannot retarget addition.",
"dataState": "Synthetic passkey actor alice; existing Account page.",
"ready": { "kind": "selector", "selector": ".account-details" },
"capturePoints": [
{ "id": "initial", "label": "Authenticated Account", "fullPage": true }
]
}
],
"processes": [
{
"id": "static-fixture",
"command": "deno",
"cwd": "../../../../tools/web-ux",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/workspace_home_fixture_server.ts",
"15372",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15372/health"
},
{
"id": "account-fixture",
"command": "deno",
"cwd": "../..",
"args": [
"run",
"--allow-net",
"test/visual-review/account-passkeys-fixture-server.ts",
"15371",
"http://127.0.0.1:15372"
],
"readyUrl": "http://127.0.0.1:15371/health"
}
]
}
+177
View File
@@ -3,10 +3,20 @@ declare const Deno: {
};
import {
addPasskey,
loadWhoami,
loginWithPasskey,
readBoundedAuthResponseJson,
registerPasskey,
} from "../src/lib/workspace/auth/api.ts";
import {
authUser,
credentialFixture,
loginOptions,
registrationOptions,
} from "../src/lib/workspace/auth/api.test-fixtures.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
@@ -97,3 +107,170 @@ Deno.test("bounded auth response reader rejects invalid JSON without echoing con
sensitive,
);
});
async function withBrowserBoundary(
fetcher: typeof fetch,
run: (calls: { create: number; get: number }) => Promise<void>,
) {
const descriptors = new Map(
["fetch", "window", "navigator"].map((key) => [
key,
Object.getOwnPropertyDescriptor(globalThis, key),
]),
);
const calls = { create: 0, get: 0 };
Object.defineProperties(globalThis, {
fetch: { configurable: true, writable: true, value: fetcher },
window: {
configurable: true,
value: { location: { origin: "https://yoi.example" } },
},
navigator: {
configurable: true,
value: {
credentials: {
create: (options: CredentialCreationOptions) => {
calls.create++;
assertEquals([
...new Uint8Array(options.publicKey!.challenge as ArrayBuffer),
], [1, 2, 3]);
assertEquals([
...new Uint8Array(options.publicKey!.user.id as ArrayBuffer),
], [4, 5, 6]);
return Promise.resolve(credentialFixture());
},
get: (options: CredentialRequestOptions) => {
calls.get++;
assertEquals([
...new Uint8Array(
options.publicKey!.allowCredentials![0].id as ArrayBuffer,
),
], [7, 8, 9]);
return Promise.resolve(credentialFixture(true));
},
},
},
},
});
try {
await run(calls);
} finally {
for (const [key, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
}
for (const purpose of ["registration", "addition"] as const) {
Deno.test(`${purpose} adapter uses only its own options and complete routes with session credentials`, async () => {
const requests: { path: string; init: RequestInit }[] = [];
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? registrationOptions : { user: authUser },
);
}, async (calls) => {
const user = purpose === "registration"
? await registerPasskey("alice", "Alice")
: await addPasskey();
assertEquals(user, authUser);
assertEquals(calls, { create: 1, get: 0 });
});
assertEquals(requests.map(({ path }) => path), [
`/api/auth/passkeys/${purpose}/options`,
`/api/auth/passkeys/${purpose}/complete`,
]);
for (const { init } of requests) {
assertEquals(init.method, "POST");
assertEquals(init.credentials, "same-origin");
assertEquals(init.headers, { "content-type": "application/json" });
}
assertEquals(
JSON.parse(String(requests[0].init.body)),
purpose === "registration"
? {
handle: "alice",
display_name: "Alice",
browser_origin: "https://yoi.example",
}
: { browser_origin: "https://yoi.example" },
);
const complete = JSON.parse(String(requests[1].init.body));
assertEquals(Object.keys(complete).sort(), ["challenge_id", "credential"]);
assertEquals(complete.challenge_id, registrationOptions.challenge_id);
assertEquals(complete.credential.response.attestationObject, "AQID");
assertEquals(complete.credential.response.transports, ["internal"]);
});
}
Deno.test("addition rejects expired-session completion without falling back to registration", async () => {
const paths: string[] = [];
await withBrowserBoundary(async (input) => {
paths.push(String(input));
return paths.length === 1
? Response.json(registrationOptions)
: new Response(null, { status: 401 });
}, async () => {
await assertRejects(addPasskey(), "Auth request failed (401).");
});
assertEquals(paths, [
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
});
Deno.test("existing-handle registration rejection never starts WebAuthn or retries addition", async () => {
const paths: string[] = [];
await withBrowserBoundary(async (input) => {
paths.push(String(input));
return new Response(null, { status: 409 });
}, async (calls) => {
await assertRejects(registerPasskey("alice"), "Auth request failed (409).");
assertEquals(calls, { create: 0, get: 0 });
});
assertEquals(paths, ["/api/auth/passkeys/registration/options"]);
});
Deno.test("login requires a nonempty explicit handle before HTTP or WebAuthn", async () => {
await withBrowserBoundary(() => {
throw new Error("Unexpected HTTP request");
}, async (calls) => {
for (const handle of ["", " ", "\n\t"]) {
await assertRejects(
loginWithPasskey(handle),
"User handle is required for passkey login.",
);
}
assertEquals(calls, { create: 0, get: 0 });
});
});
Deno.test("login sends the explicit trimmed handle and serializes the assertion", async () => {
const requests: { path: string; init: RequestInit }[] = [];
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? loginOptions : { user: authUser },
);
}, async (calls) => {
assertEquals(await loginWithPasskey(" alice "), authUser);
assertEquals(calls, { create: 0, get: 1 });
});
assertEquals(requests.map(({ path }) => path), [
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
]);
assertEquals(JSON.parse(String(requests[0].init.body)), {
handle: "alice",
browser_origin: "https://yoi.example",
});
const complete = JSON.parse(String(requests[1].init.body));
assertEquals(complete.challenge_id, loginOptions.challenge_id);
assertEquals(complete.credential.response.signature, "AQID");
assertEquals(complete.credential.response.userHandle, null);
assertEquals(requests.map(({ init }) => init.credentials), [
"same-origin",
"same-origin",
]);
});