chore: merge develop into hare/develop
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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") &&
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user