feat: offer ID-free passkey login and explicit legacy migration

This commit is contained in:
2026-10-16 19:01:43 +09:00
parent 9c12fb4372
commit 52e914eebc
17 changed files with 539 additions and 77 deletions
+1 -1
View File
@@ -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",
@@ -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<boolean | null>(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 @@
<p>New account registration is disabled. Existing users can still log in.</p>
{/if}
{/if}
{#if mode === 'login' || policy === true}
{#if mode === 'login'}
<form onsubmit={(event) => { event.preventDefault(); void submit(); }}>
<button class="primary" type="submit" disabled={auth.busy}>Log in with passkey</button>
<p class="muted">Choose a passkey and verify with your device. No account ID is needed.</p>
</form>
<details class="legacy-login" bind:open={legacyOpen}>
<summary>Use an older key with account ID</summary>
<form onsubmit={(event) => { event.preventDefault(); void submit(true); }}>
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> and add a discoverable passkey for ID-free login. Your existing keys are preserved.</p>
<label>Account ID (handle)
<input bind:value={handle} required maxlength="128" autocomplete="username" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="legacy-id-help" />
</label>
<p id="legacy-id-help" class="muted">Use your account ID, not your display name. This only signs in with an existing key; it does not register one.</p>
<button type="submit" disabled={auth.busy || !handle.trim()}>Log in with older key</button>
</form>
</details>
{:else if policy === true}
<form onsubmit={(event) => { event.preventDefault(); void submit(); }}>
<label>Account ID (handle)
<input bind:value={handle} required maxlength="128" autocomplete="username webauthn" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="id-help" />
<input bind:value={handle} required maxlength="128" autocomplete="username" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="id-help" />
</label>
<p id="id-help" class="muted">{mode === 'login' ? 'Use your account ID, not your display name.' : 'Your unique sign-in ID. This is separate from your display name.'}</p>
{#if mode === 'register'}
<p id="id-help" class="muted">Your unique account ID, separate from your display name. It is not needed for normal passkey login.</p>
<label>Display name
<input bind:value={displayName} required maxlength="128" autocomplete="name" disabled={auth.busy} />
</label>
{/if}
<button class="primary" type="submit" disabled={auth.busy || !handle.trim() || (mode === 'register' && !displayName.trim())}>{mode === 'login' ? 'Log in with passkey' : 'Register new account'}</button>
<p class="muted">Requires a device or security key that can save a discoverable passkey and verify you with a PIN or biometrics.</p>
<button class="primary" type="submit" disabled={auth.busy || !handle.trim() || !displayName.trim()}>Register new account</button>
</form>
{/if}
{/if}
@@ -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" }],
},
},
+23 -3
View File
@@ -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<AuthUser> {
return await authenticateWithPasskey({
browser_origin: window.location.origin,
}, signal);
}
export async function loginWithLegacyPasskey(
handle: string,
signal?: AbortSignal,
): Promise<AuthUser> {
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<AuthUser> {
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),
@@ -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); }
@@ -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 {
@@ -0,0 +1,158 @@
// 本番login adapterとWebAuthn変換を接続し、HTTP/credential境界だけを代替する。
// 署名検証・credentialのdiscoverability・session発行はBackend試験の責任。
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): 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<Credential | null>;
}) => Promise<void>,
) {
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<Credential | null> => {
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, []);
});
});
@@ -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,
@@ -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/);
@@ -40,6 +40,7 @@
</dl>
<section aria-label="Your Passkeys">
<h2>Passkeys</h2>
<p class="muted">Add a discoverable passkey for login without an account ID. Use a device or key that can save passkeys and verify you with a PIN or biometrics. Existing keys are preserved; do not remove them before confirming the new key works.</p>
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button>
</section>
<div class="actions">
@@ -126,6 +126,10 @@ function apiPaths(fetcher: ReturnType<typeof boundary>["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",
]);
});
@@ -49,10 +49,10 @@
<h1>Activate account</h1>
{#if user}
<p role="status">Passkey registration and activation confirmed for {user.handle}. You are signed in.</p>
<p>Use your Passkey for future logins. The activation code can no longer be used.</p>
<p>Choose your saved Passkey for future logins; no account ID is needed. The activation code can no longer be used.</p>
<a href="/account">Open Account</a>
{:else}
<p>Enter the ID and one-time code provided by your hosting administrator. You must register a Passkey before this account can be used.</p>
<p>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.</p>
<form onsubmit={(event) => { event.preventDefault(); void activate(); }} autocomplete="off">
<label for="activation-handle">Account ID (handle)</label>
<input id="activation-handle" bind:value={handle} required maxlength="128" autocomplete="off" autocapitalize="off" spellcheck="false" disabled={busy} />
@@ -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 @@
<a href="/account">Already have a passkey? Open Account</a>
{:else if !verifiedLogin}
<p role="status">Passkey added for {user.handle}. Enrollment is not a passkey login.</p>
<p>Sign in with the new passkey to verify it before opening hosting settings.</p>
<p>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.</p>
<button class="primary" type="button" disabled={busy} onclick={login}>{busy ? 'Signing in…' : 'Sign in with new passkey'}</button>
{:else}
<p role="status">Passkey login and hosting administrator access verified.</p>
@@ -17,10 +17,16 @@ type Harness = {
go: (path: string, mode?: string) => Promise<void>;
};
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<void>((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(
+40 -9
View File
@@ -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",
@@ -34,13 +34,24 @@ const mime: Record<string, string> = {
".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" } : {
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",
},
},
});
@@ -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 });
}
+6 -6
View File
@@ -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), [