diff --git a/web/workspace/deno.json b/web/workspace/deno.json index b98886d97..7924a15b2 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "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/activation-browser/activation-shell.browser.test.ts", "test:browser:hosting": "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/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts", diff --git a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte index 8d4946211..2dafcddd9 100644 --- a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte +++ b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import { loginWithPasskey, registerPasskey } from './api.ts'; + import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey } from './api.ts'; import { BrowserAuth } from './browser-session.svelte.ts'; import { loadRegistrationPolicy } from './hosting.ts'; import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts'; @@ -13,6 +13,7 @@ const auth = new BrowserAuth(); let handle = $state(''); let displayName = $state(''); + let legacyOpen = $state(false); let policy = $state(null); let policyLoading = $state(true); let policyError = $state(''); @@ -33,16 +34,18 @@ } } - async function submit() { - if (auth.busy || auth.session.kind !== 'ready' || auth.actor || !handle.trim() || - (mode === 'register' && (policy !== true || !displayName.trim()))) return; + async function submit(legacy = false) { const registering = mode === 'register'; + if (auth.busy || auth.session.kind !== 'ready' || auth.actor || + ((registering || legacy) && !handle.trim()) || + (registering && (policy !== true || !displayName.trim()))) return; const id = handle.trim(); const name = displayName.trim(); const confirmed = await auth.run( registering ? 'Waiting for new account registration…' : 'Waiting for passkey login…', registering ? 'New account registered.' : 'Logged in.', - (signal) => registering ? registerPasskey(id, name, signal) : loginWithPasskey(id, signal), + (signal) => registering ? registerPasskey(id, name, signal) + : legacy ? loginWithLegacyPasskey(id, signal) : loginWithPasskey(signal), ); if (confirmed && auth.actor) { await goto(destination, { replaceState: true }); @@ -86,18 +89,33 @@

New account registration is disabled. Existing users can still log in.

{/if} {/if} - {#if mode === 'login' || policy === true} + {#if mode === 'login'} +
{ event.preventDefault(); void submit(); }}> + +

Choose a passkey and verify with your device. No account ID is needed.

+
+
+ Use an older key with account ID +
{ event.preventDefault(); void submit(true); }}> +

If an older key is not listed or cannot sign in above, use compatibility login. After login, open Account and add a discoverable passkey for ID-free login. Your existing keys are preserved.

+ +

Use your account ID, not your display name. This only signs in with an existing key; it does not register one.

+ +
+
+ {:else if policy === true}
{ event.preventDefault(); void submit(); }}> -

{mode === 'login' ? 'Use your account ID, not your display name.' : 'Your unique sign-in ID. This is separate from your display name.'}

- {#if mode === 'register'} - - {/if} - +

Your unique account ID, separate from your display name. It is not needed for normal passkey login.

+ +

Requires a device or security key that can save a discoverable passkey and verify you with a PIN or biometrics.

+
{/if} {/if} diff --git a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts index 383139d50..ad24e6937 100644 --- a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts @@ -14,6 +14,11 @@ export const registrationOptions = { rp: { id: "localhost", name: "Yoi" }, user: { id: "BAUG", name: "alice", displayName: "Alice" }, pubKeyCredParams: [{ type: "public-key", alg: -7 }], + authenticatorSelection: { + residentKey: "required", + requireResidentKey: true, + userVerification: "required", + }, }, }, }; @@ -22,6 +27,16 @@ export const loginOptions = { public_key: { publicKey: { challenge: "AQID", + allowCredentials: [], + userVerification: "required", + }, + }, +}; +export const legacyLoginOptions = { + ...loginOptions, + public_key: { + publicKey: { + ...loginOptions.public_key.publicKey, allowCredentials: [{ type: "public-key", id: "BwgJ" }], }, }, diff --git a/web/workspace/src/lib/workspace/auth/api.ts b/web/workspace/src/lib/workspace/auth/api.ts index f796031c4..73678fed5 100644 --- a/web/workspace/src/lib/workspace/auth/api.ts +++ b/web/workspace/src/lib/workspace/auth/api.ts @@ -160,6 +160,9 @@ async function createPasskey( ); } signal?.throwIfAborted(); + if (typeof navigator.credentials?.create !== "function") { + throw new DOMException("Passkeys unavailable", "NotSupportedError"); + } const credential = await navigator.credentials.create({ signal, publicKey: prepareRegistrationOptions(options), @@ -184,17 +187,31 @@ async function createPasskey( } export async function loginWithPasskey( + signal?: AbortSignal, +): Promise { + return await authenticateWithPasskey({ + browser_origin: window.location.origin, + }, signal); +} + +export async function loginWithLegacyPasskey( handle: string, signal?: AbortSignal, ): Promise { const loginHandle = handle.trim(); if (!loginHandle) { - throw new Error("User handle is required for passkey login."); + throw new Error("User handle is required for legacy passkey login."); } - const optionsRequest: PasskeyLoginOptionsRequest = { + return await authenticateWithPasskey({ handle: loginHandle, browser_origin: window.location.origin, - }; + }, signal); +} + +async function authenticateWithPasskey( + optionsRequest: PasskeyLoginOptionsRequest, + signal?: AbortSignal, +): Promise { const options = await requestJson("/api/auth/passkeys/login/options", { method: "POST", body: JSON.stringify(optionsRequest), @@ -210,6 +227,9 @@ export async function loginWithPasskey( ); } signal?.throwIfAborted(); + if (typeof navigator.credentials?.get !== "function") { + throw new DOMException("Passkeys unavailable", "NotSupportedError"); + } const credential = await navigator.credentials.get({ signal, publicKey: prepareLoginOptions(options), diff --git a/web/workspace/src/lib/workspace/auth/auth-page.css b/web/workspace/src/lib/workspace/auth/auth-page.css index 5540693c0..ecd3b143a 100644 --- a/web/workspace/src/lib/workspace/auth/auth-page.css +++ b/web/workspace/src/lib/workspace/auth/auth-page.css @@ -22,3 +22,8 @@ .auth-page a.entry-primary:hover { background: var(--interactive-hover); color: var(--text-strong); } .auth-page a.entry-primary:active { background: var(--interactive-selected); color: var(--text-strong); } .auth-page dd { margin: 0; overflow-wrap: anywhere; } +.auth-page summary { cursor: pointer; } +.auth-page summary:hover { color: var(--text-strong); } +.auth-page summary:active { color: var(--text-muted); } +.auth-page summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.auth-page .legacy-login[open] { display: grid; gap: var(--space-4); } diff --git a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts index 50add1a12..f956e48fa 100644 --- a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts +++ b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts @@ -17,14 +17,17 @@ export function authErrorMessage(error: unknown): string { } return error.message; } + 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. You can try again."; + 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 or try Passkey login before retrying registration."; + return "Authentication could not be confirmed. Check your session before retrying the operation."; } export class BrowserAuth { diff --git a/web/workspace/src/lib/workspace/auth/login.test.ts b/web/workspace/src/lib/workspace/auth/login.test.ts new file mode 100644 index 000000000..7b6356d2f --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/login.test.ts @@ -0,0 +1,158 @@ +// 本番login adapterとWebAuthn変換を接続し、HTTP/credential境界だけを代替する。 +// 署名検証・credentialのdiscoverability・session発行はBackend試験の責任。 +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +import { loginWithLegacyPasskey, loginWithPasskey } from "./api.ts"; +import { + authUser, + credentialFixture, + legacyLoginOptions, + loginOptions, +} from "./api.test-fixtures.ts"; + +function equal(actual: unknown, expected: unknown) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +async function boundary( + run: (state: { + requests: { path: string; init: RequestInit }[]; + credential: ( + options: CredentialRequestOptions, + ) => Promise; + }) => Promise, +) { + const keys = ["fetch", "window", "navigator"]; + const descriptors = keys.map((key) => + Object.getOwnPropertyDescriptor(globalThis, key) + ); + const state = { + requests: [] as { path: string; init: RequestInit }[], + credential: async ( + options: CredentialRequestOptions, + ): Promise => { + equal(options.publicKey?.userVerification, "required"); + return credentialFixture(true); + }, + }; + Object.defineProperties(globalThis, { + window: { + configurable: true, + value: { location: { origin: "https://yoi.example" } }, + }, + navigator: { + configurable: true, + value: { + credentials: { + get: (options: CredentialRequestOptions) => state.credential(options), + }, + }, + }, + fetch: { + configurable: true, + value: (input: string, init: RequestInit) => { + state.requests.push({ path: String(input), init }); + if (String(input).endsWith("/options")) { + const body = JSON.parse(String(init.body)); + return Promise.resolve( + Response.json(body.handle ? legacyLoginOptions : loginOptions), + ); + } + return Promise.resolve(Response.json({ user: authUser })); + }, + }, + }); + try { + await run(state); + } finally { + keys.forEach((key, i) => { + const descriptor = descriptors[i]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + }); + } +} + +for (const legacy of [false, true]) { + Deno.test(`${legacy ? "explicit compatibility" : "discoverable"} login selects only its intended options and serializes assertion`, async () => { + await boundary(async (state) => { + const signal = new AbortController().signal; + state.credential = async (options) => { + equal(options.signal, signal); + equal(options.publicKey?.userVerification, "required"); + const allowed = options.publicKey?.allowCredentials; + if (legacy) { + equal(allowed?.length, 1); + equal([...new Uint8Array(allowed![0].id as ArrayBuffer)], [7, 8, 9]); + } else equal(allowed, []); + return credentialFixture(true); + }; + equal( + await (legacy + ? loginWithLegacyPasskey(" alice ", signal) + : loginWithPasskey(signal)), + authUser, + ); + equal(state.requests.map(({ path }) => path), [ + "/api/auth/passkeys/login/options", + "/api/auth/passkeys/login/complete", + ]); + equal(JSON.parse(String(state.requests[0].init.body)), { + ...(legacy ? { handle: "alice" } : {}), + browser_origin: "https://yoi.example", + }); + equal(state.requests.map(({ init }) => init.credentials), [ + "same-origin", + "same-origin", + ]); + const complete = JSON.parse(String(state.requests[1].init.body)); + equal(complete.challenge_id, loginOptions.challenge_id); + equal(complete.credential.response.signature, "AQID"); + equal(complete.credential.response.userHandle, null); + }); + }); + Deno.test(`${legacy ? "compatibility" : "discoverable"} login cancellation and late aborted credential never send completion`, async () => { + for (const late of [false, true]) { + await boundary(async (state) => { + const controller = new AbortController(); + state.credential = async () => { + if (late) controller.abort(); + return late ? credentialFixture(true) : null; + }; + try { + await (legacy + ? loginWithLegacyPasskey("alice", controller.signal) + : loginWithPasskey(controller.signal)); + throw new Error("Unexpected login success"); + } catch (error) { + if (!(error instanceof DOMException)) throw error; + equal(error.name, late ? "AbortError" : "NotAllowedError"); + } + equal(state.requests.length, 1); + }); + } + }); +} + +Deno.test("explicit compatibility rejects empty handles before HTTP or WebAuthn", async () => { + await boundary(async (state) => { + for (const handle of ["", " ", "\n\t"]) { + try { + await loginWithLegacyPasskey(handle); + throw new Error("Unexpected login success"); + } catch (error) { + equal( + (error as Error).message, + "User handle is required for legacy passkey login.", + ); + } + } + equal(state.requests, []); + }); +}); diff --git a/web/workspace/src/lib/workspace/auth/model.test.ts b/web/workspace/src/lib/workspace/auth/model.test.ts index b4972a315..177db4b8f 100644 --- a/web/workspace/src/lib/workspace/auth/model.test.ts +++ b/web/workspace/src/lib/workspace/auth/model.test.ts @@ -68,6 +68,11 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => { displayName: "Local User", }, pubKeyCredParams: [{ type: "public-key", alg: -7 }], + authenticatorSelection: { + residentKey: "required", + requireResidentKey: true, + userVerification: "required", + }, excludeCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource, @@ -77,6 +82,11 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => { }); assertEquals(bytes(options.challenge), [1, 2, 3]); + assertEquals(options.authenticatorSelection, { + residentKey: "required", + requireResidentKey: true, + userVerification: "required", + }); assertEquals(bytes(options.user.id), [4, 5, 6]); assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [ 7, diff --git a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts index a66581887..b892e0086 100644 --- a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts +++ b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts @@ -175,7 +175,7 @@ test("busy login rejects duplicate operations and refresh until its post-complet const run = auth.run( "Waiting", "Logged in", - (signal) => loginWithPasskey("alice", signal), + (signal) => loginWithPasskey(signal), ); expect(auth.busy).toBe(true); expect(auth.status).toBe("Waiting"); @@ -234,7 +234,14 @@ for (const status of [400, 401, 410]) { }); } -for (const name of ["NotAllowedError", "AbortError", "Error"] as const) { +for ( + const name of [ + "NotAllowedError", + "NotSupportedError", + "AbortError", + "Error", + ] as const +) { test(`credential ${name} is sanitized, never completed, and leaves explicit retry possible`, async () => { const { fetcher, get } = boundary(); const auth = session(); @@ -248,12 +255,14 @@ for (const name of ["NotAllowedError", "AbortError", "Error"] as const) { await auth.run( "Waiting", "Logged in", - (signal) => loginWithPasskey("alice", signal), + (signal) => loginWithPasskey(signal), ), ).toBe(false); expect(auth.error).toMatch( name === "NotAllowedError" ? /cancelled or timed out/ + : name === "NotSupportedError" + ? /Completion is not confirmed/ : name === "AbortError" ? /Completion is not confirmed/ : /Authentication could not be confirmed/, @@ -271,7 +280,7 @@ for (const name of ["NotAllowedError", "AbortError", "Error"] as const) { await auth.run( "Waiting", "Logged in", - (signal) => loginWithPasskey("alice", signal), + (signal) => loginWithPasskey(signal), ), ).toBe(true); expect(auth.error).toBe(""); @@ -298,7 +307,7 @@ test("completion does not confirm authentication when the subsequent whoami fail await auth.run( "Waiting", "Logged in", - (signal) => loginWithPasskey("alice", signal), + (signal) => loginWithPasskey(signal), ), ).toBe(false); expect(auth.session).toEqual({ kind: "error" }); @@ -343,7 +352,7 @@ test("dispose aborts WebAuthn and ignores a late credential without completion, const run = auth.run( "Waiting", "Logged in", - (signal) => loginWithPasskey("alice", signal), + (signal) => loginWithPasskey(signal), ); const signal = await started.promise; expect(signal.aborted).toBe(false); @@ -386,7 +395,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not const run = auth.run( "Waiting", "Logged in", - (signal) => loginWithPasskey("alice", signal), + (signal) => loginWithPasskey(signal), ); const signal = await started.promise; auth.dispose(); @@ -431,7 +440,7 @@ test("dispose during the post-operation whoami prevents its late actor from beco const run = auth.run( "Waiting", "Logged in", - (signal) => loginWithPasskey("alice", signal), + (signal) => loginWithPasskey(signal), ); await started.promise; expect(auth.session.kind).toBe("loading"); @@ -454,9 +463,7 @@ for (const purpose of ["login", "addition"] as const) { "Waiting", "Confirmed", (signal) => - purpose === "login" - ? loginWithPasskey("alice", signal) - : addPasskey(signal), + purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal), ), ).toBe(false); expect(auth.error).toMatch(/cancelled or timed out/); diff --git a/web/workspace/src/routes/account/+page.svelte b/web/workspace/src/routes/account/+page.svelte index c44ed8ad9..a1501e5ec 100644 --- a/web/workspace/src/routes/account/+page.svelte +++ b/web/workspace/src/routes/account/+page.svelte @@ -40,6 +40,7 @@

Passkeys

+

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.

diff --git a/web/workspace/src/routes/account/account.browser.spec.ts b/web/workspace/src/routes/account/account.browser.spec.ts index d0a524886..cd51302e6 100644 --- a/web/workspace/src/routes/account/account.browser.spec.ts +++ b/web/workspace/src/routes/account/account.browser.spec.ts @@ -126,6 +126,10 @@ function apiPaths(fetcher: ReturnType["fetcher"]) { } async function enterIdentity(registering = false) { + if (!registering) { + await screen.findByRole("button", { name: "Log in with passkey" }); + return; + } await fireEvent.input(await screen.findByLabelText("Account ID (handle)"), { target: { value: " alice " }, }); @@ -140,11 +144,16 @@ async function submit(name: string) { await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!); } -test("login asks only for an account ID and links separate registration and code activation", async () => { +test("normal login needs no account ID and links separate registration and code activation", async () => { const { fetcher } = boundary({ policy: true }); page.url = new URL("https://example.test/login?returnTo=%2Faccount"); render(Login); - const handle = await screen.findByLabelText("Account ID (handle)"); + await screen.findByRole("button", { name: "Log in with passkey" }); + expect( + (screen.getByText("Use an older key with account ID").closest( + "details", + ) as HTMLDetailsElement).open, + ).toBe(false); expect(screen.queryByLabelText("Display name")).toBeNull(); expect(screen.queryByRole("button", { name: "Register new account" })) .toBeNull(); @@ -160,15 +169,17 @@ test("login asks only for an account ID and links separate registration and code .getAttribute("href"), ) .toBe("/activate"); - await fireEvent.input(handle, { target: { value: " " } }); expect( (screen.getByRole("button", { name: "Log in with passkey", }) as HTMLButtonElement).disabled, ) - .toBe(true); + .toBe(false); await submit("Log in with passkey"); - expect(posted(fetcher)).toEqual([]); + await waitFor(() => expect(posted(fetcher)).toHaveLength(2)); + expect(posted(fetcher)[0].body).toEqual({ + browser_origin: window.location.origin, + }); expect(apiPaths(fetcher)).toContain("/api/auth/registration-policy"); }); @@ -206,7 +217,6 @@ for ( "/api/auth/passkeys/login/complete", ]); expect(requests[0].body).toEqual({ - handle: "alice", browser_origin: window.location.origin, }); expect(requests[1].body.challenge_id).toBe(loginOptions.challenge_id); @@ -530,7 +540,7 @@ 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. You can try again.", + "Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.", ); await waitFor(() => expect( @@ -676,3 +686,47 @@ for (const status of [401, 410]) { expect(goto).not.toHaveBeenCalled(); }); } + +test("explicit older-key login alone sends the trimmed account ID", async () => { + const { fetcher } = boundary(); + render(Login); + await screen.findByRole("button", { name: "Log in with passkey" }); + await fireEvent.click(screen.getByText("Use an older key with account ID")); + await fireEvent.input(screen.getByLabelText("Account ID (handle)"), { + target: { value: " alice " }, + }); + await submit("Log in with older key"); + await waitFor(() => expect(goto).toHaveBeenCalled()); + expect(posted(fetcher)[0].body).toEqual({ + handle: "alice", + browser_origin: window.location.origin, + }); +}); + +test("compatibility login keeps its selected form and ID after cancellation and session refresh", async () => { + const { fetcher, get } = boundary(); + get.mockRejectedValueOnce(new DOMException("cancelled", "NotAllowedError")); + render(Login); + await screen.findByRole("button", { name: "Log in with passkey" }); + const disclosure = screen.getByText("Use an older key with account ID") + .closest("details") as HTMLDetailsElement; + disclosure.open = true; + await fireEvent(disclosure, new Event("toggle")); + await fireEvent.input(screen.getByLabelText("Account ID (handle)"), { + target: { value: "alice" }, + }); + await submit("Log in with older key"); + await screen.findByText(/cancelled or timed out/); + await screen.findByText("Use an older key with account ID"); + expect( + (screen.getByText("Use an older key with account ID").closest( + "details", + ) as HTMLDetailsElement).open, + ).toBe(true); + expect( + (screen.getByLabelText("Account ID (handle)") as HTMLInputElement).value, + ).toBe("alice"); + expect(posted(fetcher).map(({ path }) => path)).toEqual([ + "/api/auth/passkeys/login/options", + ]); +}); diff --git a/web/workspace/src/routes/activate/+page.svelte b/web/workspace/src/routes/activate/+page.svelte index 1537c2aac..84eaaaefb 100644 --- a/web/workspace/src/routes/activate/+page.svelte +++ b/web/workspace/src/routes/activate/+page.svelte @@ -49,10 +49,10 @@

Activate account

{#if user}

Passkey registration and activation confirmed for {user.handle}. You are signed in.

-

Use your Passkey for future logins. The activation code can no longer be used.

+

Choose your saved Passkey for future logins; no account ID is needed. The activation code can no longer be used.

Open Account {:else} -

Enter the ID and one-time code provided by your hosting administrator. You must register a Passkey before this account can be used.

+

Enter the ID and one-time code provided by your hosting administrator. You must register a discoverable Passkey before this account can be used. Use a device or key that can save passkeys and verify you with a PIN or biometrics. If unsupported or cancelled, the account stays pending; retry with a supported device.

{ event.preventDefault(); void activate(); }} autocomplete="off"> diff --git a/web/workspace/src/routes/hosting/setup/+page.svelte b/web/workspace/src/routes/hosting/setup/+page.svelte index 41b4dfbef..4a218f40c 100644 --- a/web/workspace/src/routes/hosting/setup/+page.svelte +++ b/web/workspace/src/routes/hosting/setup/+page.svelte @@ -46,7 +46,7 @@ busy = true; error = ''; try { - const signedIn = await loginWithPasskey(expected.handle); + const signedIn = await loginWithPasskey(controller.signal); if (!active) return; if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id || !await verifySession(expected)) { throw new Error('Login or hosting access not verified'); @@ -89,7 +89,7 @@ Already have a passkey? Open Account {:else if !verifiedLogin}

Passkey added for {user.handle}. Enrollment is not a passkey login.

-

Sign in with the new passkey to verify it before opening hosting settings.

+

Choose the new passkey for {user.handle} and verify with your device. No account ID is needed; only this administrator account will pass the access check.

{:else}

Passkey login and hosting administrator access verified.

diff --git a/web/workspace/test/auth-browser/auth-shell.browser.test.ts b/web/workspace/test/auth-browser/auth-shell.browser.test.ts index ed18b2828..d02ab77be 100644 --- a/web/workspace/test/auth-browser/auth-shell.browser.test.ts +++ b/web/workspace/test/auth-browser/auth-shell.browser.test.ts @@ -17,10 +17,16 @@ type Harness = { go: (path: string, mode?: string) => Promise; }; const login = async (page: Page) => { - await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice"); await page.getByRole("button", { name: "Log in with passkey", exact: true }) .click(); }; +const legacyLogin = async (page: Page) => { + await page.getByText("Use an older key with account ID", { exact: true }) + .click(); + await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice"); + await page.getByRole("button", { name: "Log in with older key", exact: true }) + .click(); +}; const register = async (page: Page) => { await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice"); await page.getByLabel("Display name", { exact: true }).fill("Alice"); @@ -134,13 +140,17 @@ Deno.test("standalone auth separates login, registration, own account and explic await page.evaluate(() => history.length), historyBefore + 1, ); - const id = page.getByLabel("Account ID (handle)", { exact: true }); - await id.waitFor(); + const button = page.getByRole("button", { + name: "Log in with passkey", + exact: true, + }); + await button.waitFor(); + assertEquals(await page.locator("input:visible").count(), 0); assertEquals( await page.getByLabel("Display name", { exact: true }).count(), 0, ); - assertEquals(await page.locator("input").count(), 1); + assertEquals(await page.locator("details[open]").count(), 0); assertEquals( await page.getByRole("link", { name: "Create a new account", @@ -152,12 +162,9 @@ Deno.test("standalone auth separates login, registration, own account and explic name: "Activate an account with a code", exact: true, }).waitFor(); - await id.fill("alice"); - await id.press("Tab"); - const button = page.getByRole("button", { - name: "Log in with passkey", - exact: true, - }); + await button.focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Shift+Tab"); assert( await button.evaluate((element) => element === document.activeElement && @@ -166,6 +173,12 @@ Deno.test("standalone auth separates login, registration, own account and explic ); await page.keyboard.press("Enter"); await at(page, "/account"); + assertEquals( + mutations.find((entry) => + entry.path === "/api/auth/passkeys/login/options" + )?.keys, + ["browser_origin"], + ); await page.getByRole("button", { name: "Add passkey to alice", exact: true, @@ -448,6 +461,105 @@ Deno.test("standalone auth separates login, registration, own account and explic assertEquals(mutations, []); }), ); + await t.step( + "explicit older-key login sends a handle, returns to device approval without approving", + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go("/login?returnTo=%2Flogin%2Fdevice%3Fuser_code%3DABCD-EFGH"); + await legacyLogin(page); + await at(page, "/login/device?user_code=ABCD-EFGH"); + await page.getByRole("button", { + name: "Approve device login", + exact: true, + }).waitFor(); + assertEquals( + mutations.find((entry) => + entry.path === "/api/auth/passkeys/login/options" + )?.keys, + ["browser_origin", "handle"], + ); + assertEquals(count(mutations, "/api/auth/device-login/approve"), 0); + await page.getByRole("button", { + name: "Approve device login", + exact: true, + }).click(); + await page.getByText( + "Device login approved. You can return to the CLI.", + { exact: true }, + ).waitFor(); + }), + ); + await t.step( + "explicit compatibility does not require input for a later normal login", + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go("/login", "login-cancel"); + await legacyLogin(page); + await page.getByRole("alert").waitFor(); + assertEquals( + count(mutations, "/api/auth/passkeys/login/complete"), + 0, + ); + await login(page); + await page.getByRole("alert").waitFor(); + assertEquals( + mutations.filter((entry) => + entry.path === "/api/auth/passkeys/login/options" + ).map((entry) => entry.keys), + [ + ["browser_origin", "handle"], + ["browser_origin"], + ], + ); + }), + ); + await t.step( + "pending older-key login blocks both forms and ignores late credentials after unmount", + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go("/login", "login-pending"); + await legacyLogin(page); + await page.waitForFunction(() => + typeof (window as unknown as { __authFixtureRelease?: unknown }) + .__authFixtureRelease === "function" + ); + for (const name of ["Log in with passkey", "Log in with older key"]) { + assertEquals( + await page.getByRole("button", { name, exact: true }).isEnabled(), + false, + ); + } + await page.locator("form").evaluateAll((forms) => { + for (const form of forms) { + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + } + }); + assertEquals(count(mutations, "/api/auth/passkeys/login/options"), 1); + await page.getByRole("link", { + name: "Create a new account", + exact: true, + }).click(); + await page.getByLabel("Display name", { exact: true }).waitFor(); + await page.evaluate(() => + (window as unknown as { __authFixtureRelease: () => void }) + .__authFixtureRelease() + ); + await page.evaluate(() => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(() => resolve()) + ) + ) + ); + assertEquals(new URL(page.url()).pathname, "/register"); + assertEquals( + count(mutations, "/api/auth/passkeys/login/complete"), + 0, + ); + }), + ); await t.step( "leaving a pending login never completes or redirects after navigation", () => @@ -534,7 +646,15 @@ Deno.test("standalone auth separates login, registration, own account and explic : purpose === "addition" ? "/account" : "/login"; - for (const failure of ["cancel", "error", "http-error", "expired"]) { + for ( + const failure of [ + "cancel", + "unsupported", + "error", + "http-error", + "expired", + ] + ) { await t.step( `${purpose} ${failure} is visible and does not claim successful authentication`, () => @@ -552,7 +672,8 @@ Deno.test("standalone auth separates login, registration, own account and explic path === route || (purpose === "addition" && path === "/login"), ); if ( - failure === "cancel" || failure === "error" || + failure === "cancel" || failure === "unsupported" || + failure === "error" || failure === "http-error" ) { assertEquals( diff --git a/web/workspace/test/auth-browser/capture.ts b/web/workspace/test/auth-browser/capture.ts index 3e979fa6e..de03bbf5c 100644 --- a/web/workspace/test/auth-browser/capture.ts +++ b/web/workspace/test/auth-browser/capture.ts @@ -24,9 +24,12 @@ const fill = (selector: string, value: string): Interaction => ({ value, }); const id = 'label:has-text("ID (handle)") input'; -const login = [ +const loginButton = 'button:text-is("Log in with passkey")'; +const login = [click(loginButton)]; +const legacyLogin = [ + click('summary:text-is("Use an older key with account ID")'), fill(id, "alice"), - click('button:text-is("Log in with passkey")'), + click('button:text-is("Log in with older key")'), ]; const register = [ fill(id, "alice"), @@ -53,23 +56,32 @@ const route = ( capturePoints, }); const routes: RouteScenario[] = [ - route("login", "/login?fixture=anonymous", `${id}:enabled`, [ + route("login", "/login?fixture=anonymous", `${loginButton}:enabled`, [ { id: "initial", - label: "ID-only login; registration and activation entries", + label: "ID-free primary login; explicit older-key disclosure", fullPage: true, }, { id: "keyboard-focus", label: "Login keyboard focus", fullPage: true, - interaction: [fill(id, "alice"), { + interaction: [{ action: "press", selector: loginButton, key: "Tab" }, { action: "press", - selector: id, - key: "Tab", + selector: 'summary:text-is("Use an older key with account ID")', + key: "Shift+Tab", }], ready: ready('button:text-is("Log in with passkey"):focus-visible'), }, + { + id: "legacy-expanded", + label: "Explicit old-key compatibility and safe migration guidance", + fullPage: true, + interaction: [ + click('summary:text-is("Use an older key with account ID")'), + ], + ready: ready(`${id}:visible`), + }, ]), route( "account-redirect", @@ -139,7 +151,7 @@ const routes: RouteScenario[] = [ label: "Dedicated login with device returnTo", fullPage: true, interaction: [click('a:text-is("Log in to continue")')], - ready: ready(`${id}:enabled`), + ready: ready(`${loginButton}:enabled`), }, { id: "authenticated-unapproved", @@ -203,7 +215,7 @@ const routes: RouteScenario[] = [ }, ]), ...["login", "registration", "addition"].flatMap((purpose) => - ["pending", "cancel", "error", "expired"].map((state) => { + ["pending", "cancel", "unsupported", "error", "expired"].map((state) => { const path = purpose === "registration" ? "/register" : purpose === "addition" @@ -219,6 +231,8 @@ const routes: RouteScenario[] = [ `${path}?fixture=${purpose}-${state}`, purpose === "addition" ? 'button:text-is("Add passkey to alice")' + : purpose === "login" + ? `${loginButton}:enabled` : `${id}:enabled`, [{ id: "result", @@ -232,6 +246,23 @@ const routes: RouteScenario[] = [ ); }) ), + ...["pending", "cancel", "unsupported", "error", "expired"].map((state) => + route( + `legacy-login-${state}`, + `/login?fixture=login-${state}`, + `${loginButton}:enabled`, + [{ + id: "result", + label: + `Explicit compatibility ${state}; not confirmed login or enrollment`, + fullPage: true, + interaction: legacyLogin, + ready: ready( + state === "pending" ? '[role="status"]' : '[role="alert"]', + ), + }], + ) + ), route( "activation-entry", "/login?fixture=activation-pending", diff --git a/web/workspace/test/auth-browser/fixture-server.ts b/web/workspace/test/auth-browser/fixture-server.ts index abbeaed7d..5b7a8c1e8 100644 --- a/web/workspace/test/auth-browser/fixture-server.ts +++ b/web/workspace/test/auth-browser/fixture-server.ts @@ -34,14 +34,25 @@ const mime: Record = { ".png": "image/png", ".ico": "image/x-icon", }; -const options = (login: boolean) => ({ +const options = (login: boolean, legacy = false) => ({ challenge_id: "synthetic-challenge-only", - public_key: login ? { challenge: "dWk" } : { - challenge: "dWk", - rp: { name: "UI projection" }, - user: { id: "dWk", name: "alice", displayName: "Alice" }, - pubKeyCredParams: [{ type: "public-key", alg: -7 }], - }, + public_key: login + ? { + challenge: "dWk", + allowCredentials: legacy ? [{ type: "public-key", id: "dWk" }] : [], + userVerification: "required", + } + : { + challenge: "dWk", + rp: { name: "UI projection" }, + user: { id: "dWk", name: "alice", displayName: "Alice" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + authenticatorSelection: { + residentKey: "required", + requireResidentKey: true, + userVerification: "required", + }, + }, }); Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => { @@ -70,6 +81,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => { if (mode.endsWith("-cancel")) { extra = `for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`; + } else if (mode.endsWith("-unsupported")) { + extra = + `for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Synthetic unsupported key','NotSupportedError')}});`; } else if (mode.endsWith("-error")) { extra = `for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Synthetic authenticator failure','UnknownError')}});`; @@ -176,7 +190,12 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => { if (purpose === "addition") state.signedIn = false; return new Response(null, { status: 401 }); } - if (stage === "options") return Response.json(options(purpose === "login")); + if (stage === "options") { + const body = await request.json(); + return Response.json( + options(purpose === "login", typeof body.handle === "string"), + ); + } state.signedIn = true; return Response.json({ user }); } diff --git a/web/workspace/tests/auth-api.test.ts b/web/workspace/tests/auth-api.test.ts index 3fa3eaa50..c2f9d5630 100644 --- a/web/workspace/tests/auth-api.test.ts +++ b/web/workspace/tests/auth-api.test.ts @@ -5,7 +5,7 @@ declare const Deno: { import { addPasskey, loadWhoami, - loginWithPasskey, + loginWithLegacyPasskey, readBoundedAuthResponseJson, registerPasskey, } from "../src/lib/workspace/auth/api.ts"; @@ -13,7 +13,7 @@ import { import { authUser, credentialFixture, - loginOptions, + legacyLoginOptions as loginOptions, registrationOptions, } from "../src/lib/workspace/auth/api.test-fixtures.ts"; @@ -232,14 +232,14 @@ Deno.test("existing-handle registration rejection never starts WebAuthn or retri assertEquals(paths, ["/api/auth/passkeys/registration/options"]); }); -Deno.test("login requires a nonempty explicit handle before HTTP or WebAuthn", async () => { +Deno.test("compatibility 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.", + loginWithLegacyPasskey(handle), + "User handle is required for legacy passkey login.", ); } assertEquals(calls, { create: 0, get: 0 }); @@ -254,7 +254,7 @@ Deno.test("login sends the explicit trimmed handle and serializes the assertion" requests.length === 1 ? loginOptions : { user: authUser }, ); }, async (calls) => { - assertEquals(await loginWithPasskey(" alice "), authUser); + assertEquals(await loginWithLegacyPasskey(" alice "), authUser); assertEquals(calls, { create: 0, get: 1 }); }); assertEquals(requests.map(({ path }) => path), [