feat: offer ID-free passkey login and explicit legacy migration
This commit is contained in:
@@ -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" }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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), [
|
||||
|
||||
Reference in New Issue
Block a user