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": "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",
|
"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",
|
"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: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: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",
|
"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 { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
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 { BrowserAuth } from './browser-session.svelte.ts';
|
||||||
import { loadRegistrationPolicy } from './hosting.ts';
|
import { loadRegistrationPolicy } from './hosting.ts';
|
||||||
import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts';
|
import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts';
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
const auth = new BrowserAuth();
|
const auth = new BrowserAuth();
|
||||||
let handle = $state('');
|
let handle = $state('');
|
||||||
let displayName = $state('');
|
let displayName = $state('');
|
||||||
|
let legacyOpen = $state(false);
|
||||||
let policy = $state<boolean | null>(null);
|
let policy = $state<boolean | null>(null);
|
||||||
let policyLoading = $state(true);
|
let policyLoading = $state(true);
|
||||||
let policyError = $state('');
|
let policyError = $state('');
|
||||||
@@ -33,16 +34,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit(legacy = false) {
|
||||||
if (auth.busy || auth.session.kind !== 'ready' || auth.actor || !handle.trim() ||
|
|
||||||
(mode === 'register' && (policy !== true || !displayName.trim()))) return;
|
|
||||||
const registering = mode === 'register';
|
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 id = handle.trim();
|
||||||
const name = displayName.trim();
|
const name = displayName.trim();
|
||||||
const confirmed = await auth.run(
|
const confirmed = await auth.run(
|
||||||
registering ? 'Waiting for new account registration…' : 'Waiting for passkey login…',
|
registering ? 'Waiting for new account registration…' : 'Waiting for passkey login…',
|
||||||
registering ? 'New account registered.' : 'Logged in.',
|
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) {
|
if (confirmed && auth.actor) {
|
||||||
await goto(destination, { replaceState: true });
|
await goto(destination, { replaceState: true });
|
||||||
@@ -86,18 +89,33 @@
|
|||||||
<p>New account registration is disabled. Existing users can still log in.</p>
|
<p>New account registration is disabled. Existing users can still log in.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/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(); }}>
|
<form onsubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||||
<label>Account ID (handle)
|
<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>
|
</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>
|
<p id="id-help" class="muted">Your unique account ID, separate from your display name. It is not needed for normal passkey login.</p>
|
||||||
{#if mode === 'register'}
|
<label>Display name
|
||||||
<label>Display name
|
<input bind:value={displayName} required maxlength="128" autocomplete="name" disabled={auth.busy} />
|
||||||
<input bind:value={displayName} required maxlength="128" autocomplete="name" disabled={auth.busy} />
|
</label>
|
||||||
</label>
|
<p class="muted">Requires a device or security key that can save a discoverable passkey and verify you with a PIN or biometrics.</p>
|
||||||
{/if}
|
<button class="primary" type="submit" disabled={auth.busy || !handle.trim() || !displayName.trim()}>Register new account</button>
|
||||||
<button class="primary" type="submit" disabled={auth.busy || !handle.trim() || (mode === 'register' && !displayName.trim())}>{mode === 'login' ? 'Log in with passkey' : 'Register new account'}</button>
|
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export const registrationOptions = {
|
|||||||
rp: { id: "localhost", name: "Yoi" },
|
rp: { id: "localhost", name: "Yoi" },
|
||||||
user: { id: "BAUG", name: "alice", displayName: "Alice" },
|
user: { id: "BAUG", name: "alice", displayName: "Alice" },
|
||||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "required",
|
||||||
|
requireResidentKey: true,
|
||||||
|
userVerification: "required",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -22,6 +27,16 @@ export const loginOptions = {
|
|||||||
public_key: {
|
public_key: {
|
||||||
publicKey: {
|
publicKey: {
|
||||||
challenge: "AQID",
|
challenge: "AQID",
|
||||||
|
allowCredentials: [],
|
||||||
|
userVerification: "required",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const legacyLoginOptions = {
|
||||||
|
...loginOptions,
|
||||||
|
public_key: {
|
||||||
|
publicKey: {
|
||||||
|
...loginOptions.public_key.publicKey,
|
||||||
allowCredentials: [{ type: "public-key", id: "BwgJ" }],
|
allowCredentials: [{ type: "public-key", id: "BwgJ" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ async function createPasskey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
if (typeof navigator.credentials?.create !== "function") {
|
||||||
|
throw new DOMException("Passkeys unavailable", "NotSupportedError");
|
||||||
|
}
|
||||||
const credential = await navigator.credentials.create({
|
const credential = await navigator.credentials.create({
|
||||||
signal,
|
signal,
|
||||||
publicKey: prepareRegistrationOptions(options),
|
publicKey: prepareRegistrationOptions(options),
|
||||||
@@ -184,17 +187,31 @@ async function createPasskey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loginWithPasskey(
|
export async function loginWithPasskey(
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<AuthUser> {
|
||||||
|
return await authenticateWithPasskey({
|
||||||
|
browser_origin: window.location.origin,
|
||||||
|
}, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithLegacyPasskey(
|
||||||
handle: string,
|
handle: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<AuthUser> {
|
): Promise<AuthUser> {
|
||||||
const loginHandle = handle.trim();
|
const loginHandle = handle.trim();
|
||||||
if (!loginHandle) {
|
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,
|
handle: loginHandle,
|
||||||
browser_origin: window.location.origin,
|
browser_origin: window.location.origin,
|
||||||
};
|
}, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticateWithPasskey(
|
||||||
|
optionsRequest: PasskeyLoginOptionsRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<AuthUser> {
|
||||||
const options = await requestJson("/api/auth/passkeys/login/options", {
|
const options = await requestJson("/api/auth/passkeys/login/options", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(optionsRequest),
|
body: JSON.stringify(optionsRequest),
|
||||||
@@ -210,6 +227,9 @@ export async function loginWithPasskey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
if (typeof navigator.credentials?.get !== "function") {
|
||||||
|
throw new DOMException("Passkeys unavailable", "NotSupportedError");
|
||||||
|
}
|
||||||
const credential = await navigator.credentials.get({
|
const credential = await navigator.credentials.get({
|
||||||
signal,
|
signal,
|
||||||
publicKey: prepareLoginOptions(options),
|
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: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 a.entry-primary:active { background: var(--interactive-selected); color: var(--text-strong); }
|
||||||
.auth-page dd { margin: 0; overflow-wrap: anywhere; }
|
.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;
|
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") {
|
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") {
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
return "Operation stopped. Completion is not confirmed; check your session before retrying.";
|
return "Operation stopped. Completion is not confirmed; check your session before retrying.";
|
||||||
}
|
}
|
||||||
// Browser/transport例外の本文を表示せず、credentialや外部応答を診断へ複写しない。
|
// 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 {
|
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",
|
displayName: "Local User",
|
||||||
},
|
},
|
||||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "required",
|
||||||
|
requireResidentKey: true,
|
||||||
|
userVerification: "required",
|
||||||
|
},
|
||||||
excludeCredentials: [{
|
excludeCredentials: [{
|
||||||
type: "public-key",
|
type: "public-key",
|
||||||
id: "BwgJ" as unknown as BufferSource,
|
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(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.user.id), [4, 5, 6]);
|
||||||
assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [
|
assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [
|
||||||
7,
|
7,
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ test("busy login rejects duplicate operations and refresh until its post-complet
|
|||||||
const run = auth.run(
|
const run = auth.run(
|
||||||
"Waiting",
|
"Waiting",
|
||||||
"Logged in",
|
"Logged in",
|
||||||
(signal) => loginWithPasskey("alice", signal),
|
(signal) => loginWithPasskey(signal),
|
||||||
);
|
);
|
||||||
expect(auth.busy).toBe(true);
|
expect(auth.busy).toBe(true);
|
||||||
expect(auth.status).toBe("Waiting");
|
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 () => {
|
test(`credential ${name} is sanitized, never completed, and leaves explicit retry possible`, async () => {
|
||||||
const { fetcher, get } = boundary();
|
const { fetcher, get } = boundary();
|
||||||
const auth = session();
|
const auth = session();
|
||||||
@@ -248,12 +255,14 @@ for (const name of ["NotAllowedError", "AbortError", "Error"] as const) {
|
|||||||
await auth.run(
|
await auth.run(
|
||||||
"Waiting",
|
"Waiting",
|
||||||
"Logged in",
|
"Logged in",
|
||||||
(signal) => loginWithPasskey("alice", signal),
|
(signal) => loginWithPasskey(signal),
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(auth.error).toMatch(
|
expect(auth.error).toMatch(
|
||||||
name === "NotAllowedError"
|
name === "NotAllowedError"
|
||||||
? /cancelled or timed out/
|
? /cancelled or timed out/
|
||||||
|
: name === "NotSupportedError"
|
||||||
|
? /Completion is not confirmed/
|
||||||
: name === "AbortError"
|
: name === "AbortError"
|
||||||
? /Completion is not confirmed/
|
? /Completion is not confirmed/
|
||||||
: /Authentication could not be confirmed/,
|
: /Authentication could not be confirmed/,
|
||||||
@@ -271,7 +280,7 @@ for (const name of ["NotAllowedError", "AbortError", "Error"] as const) {
|
|||||||
await auth.run(
|
await auth.run(
|
||||||
"Waiting",
|
"Waiting",
|
||||||
"Logged in",
|
"Logged in",
|
||||||
(signal) => loginWithPasskey("alice", signal),
|
(signal) => loginWithPasskey(signal),
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(auth.error).toBe("");
|
expect(auth.error).toBe("");
|
||||||
@@ -298,7 +307,7 @@ test("completion does not confirm authentication when the subsequent whoami fail
|
|||||||
await auth.run(
|
await auth.run(
|
||||||
"Waiting",
|
"Waiting",
|
||||||
"Logged in",
|
"Logged in",
|
||||||
(signal) => loginWithPasskey("alice", signal),
|
(signal) => loginWithPasskey(signal),
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(auth.session).toEqual({ kind: "error" });
|
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(
|
const run = auth.run(
|
||||||
"Waiting",
|
"Waiting",
|
||||||
"Logged in",
|
"Logged in",
|
||||||
(signal) => loginWithPasskey("alice", signal),
|
(signal) => loginWithPasskey(signal),
|
||||||
);
|
);
|
||||||
const signal = await started.promise;
|
const signal = await started.promise;
|
||||||
expect(signal.aborted).toBe(false);
|
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(
|
const run = auth.run(
|
||||||
"Waiting",
|
"Waiting",
|
||||||
"Logged in",
|
"Logged in",
|
||||||
(signal) => loginWithPasskey("alice", signal),
|
(signal) => loginWithPasskey(signal),
|
||||||
);
|
);
|
||||||
const signal = await started.promise;
|
const signal = await started.promise;
|
||||||
auth.dispose();
|
auth.dispose();
|
||||||
@@ -431,7 +440,7 @@ test("dispose during the post-operation whoami prevents its late actor from beco
|
|||||||
const run = auth.run(
|
const run = auth.run(
|
||||||
"Waiting",
|
"Waiting",
|
||||||
"Logged in",
|
"Logged in",
|
||||||
(signal) => loginWithPasskey("alice", signal),
|
(signal) => loginWithPasskey(signal),
|
||||||
);
|
);
|
||||||
await started.promise;
|
await started.promise;
|
||||||
expect(auth.session.kind).toBe("loading");
|
expect(auth.session.kind).toBe("loading");
|
||||||
@@ -454,9 +463,7 @@ for (const purpose of ["login", "addition"] as const) {
|
|||||||
"Waiting",
|
"Waiting",
|
||||||
"Confirmed",
|
"Confirmed",
|
||||||
(signal) =>
|
(signal) =>
|
||||||
purpose === "login"
|
purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal),
|
||||||
? loginWithPasskey("alice", signal)
|
|
||||||
: addPasskey(signal),
|
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(auth.error).toMatch(/cancelled or timed out/);
|
expect(auth.error).toMatch(/cancelled or timed out/);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
</dl>
|
</dl>
|
||||||
<section aria-label="Your Passkeys">
|
<section aria-label="Your Passkeys">
|
||||||
<h2>Passkeys</h2>
|
<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>
|
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button>
|
||||||
</section>
|
</section>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ function apiPaths(fetcher: ReturnType<typeof boundary>["fetcher"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function enterIdentity(registering = false) {
|
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)"), {
|
await fireEvent.input(await screen.findByLabelText("Account ID (handle)"), {
|
||||||
target: { value: " alice " },
|
target: { value: " alice " },
|
||||||
});
|
});
|
||||||
@@ -140,11 +144,16 @@ async function submit(name: string) {
|
|||||||
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
|
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 });
|
const { fetcher } = boundary({ policy: true });
|
||||||
page.url = new URL("https://example.test/login?returnTo=%2Faccount");
|
page.url = new URL("https://example.test/login?returnTo=%2Faccount");
|
||||||
render(Login);
|
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.queryByLabelText("Display name")).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
@@ -160,15 +169,17 @@ test("login asks only for an account ID and links separate registration and code
|
|||||||
.getAttribute("href"),
|
.getAttribute("href"),
|
||||||
)
|
)
|
||||||
.toBe("/activate");
|
.toBe("/activate");
|
||||||
await fireEvent.input(handle, { target: { value: " " } });
|
|
||||||
expect(
|
expect(
|
||||||
(screen.getByRole("button", {
|
(screen.getByRole("button", {
|
||||||
name: "Log in with passkey",
|
name: "Log in with passkey",
|
||||||
}) as HTMLButtonElement).disabled,
|
}) as HTMLButtonElement).disabled,
|
||||||
)
|
)
|
||||||
.toBe(true);
|
.toBe(false);
|
||||||
await submit("Log in with passkey");
|
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");
|
expect(apiPaths(fetcher)).toContain("/api/auth/registration-policy");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,7 +217,6 @@ for (
|
|||||||
"/api/auth/passkeys/login/complete",
|
"/api/auth/passkeys/login/complete",
|
||||||
]);
|
]);
|
||||||
expect(requests[0].body).toEqual({
|
expect(requests[0].body).toEqual({
|
||||||
handle: "alice",
|
|
||||||
browser_origin: window.location.origin,
|
browser_origin: window.location.origin,
|
||||||
});
|
});
|
||||||
expect(requests[1].body.challenge_id).toBe(loginOptions.challenge_id);
|
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 enterIdentity();
|
||||||
await submit("Log in with passkey");
|
await submit("Log in with passkey");
|
||||||
await screen.findByText(
|
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(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
@@ -676,3 +686,47 @@ for (const status of [401, 410]) {
|
|||||||
expect(goto).not.toHaveBeenCalled();
|
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>
|
<h1>Activate account</h1>
|
||||||
{#if user}
|
{#if user}
|
||||||
<p role="status">Passkey registration and activation confirmed for {user.handle}. You are signed in.</p>
|
<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>
|
<a href="/account">Open Account</a>
|
||||||
{:else}
|
{: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">
|
<form onsubmit={(event) => { event.preventDefault(); void activate(); }} autocomplete="off">
|
||||||
<label for="activation-handle">Account ID (handle)</label>
|
<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} />
|
<input id="activation-handle" bind:value={handle} required maxlength="128" autocomplete="off" autocapitalize="off" spellcheck="false" disabled={busy} />
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const signedIn = await loginWithPasskey(expected.handle);
|
const signedIn = await loginWithPasskey(controller.signal);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id || !await verifySession(expected)) {
|
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');
|
throw new Error('Login or hosting access not verified');
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<a href="/account">Already have a passkey? Open Account</a>
|
<a href="/account">Already have a passkey? Open Account</a>
|
||||||
{:else if !verifiedLogin}
|
{:else if !verifiedLogin}
|
||||||
<p role="status">Passkey added for {user.handle}. Enrollment is not a passkey login.</p>
|
<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>
|
<button class="primary" type="button" disabled={busy} onclick={login}>{busy ? 'Signing in…' : 'Sign in with new passkey'}</button>
|
||||||
{:else}
|
{:else}
|
||||||
<p role="status">Passkey login and hosting administrator access verified.</p>
|
<p role="status">Passkey login and hosting administrator access verified.</p>
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ type Harness = {
|
|||||||
go: (path: string, mode?: string) => Promise<void>;
|
go: (path: string, mode?: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
const login = async (page: Page) => {
|
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 })
|
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||||
.click();
|
.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) => {
|
const register = async (page: Page) => {
|
||||||
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
|
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
|
||||||
await page.getByLabel("Display name", { 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),
|
await page.evaluate(() => history.length),
|
||||||
historyBefore + 1,
|
historyBefore + 1,
|
||||||
);
|
);
|
||||||
const id = page.getByLabel("Account ID (handle)", { exact: true });
|
const button = page.getByRole("button", {
|
||||||
await id.waitFor();
|
name: "Log in with passkey",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await button.waitFor();
|
||||||
|
assertEquals(await page.locator("input:visible").count(), 0);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await page.getByLabel("Display name", { exact: true }).count(),
|
await page.getByLabel("Display name", { exact: true }).count(),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
assertEquals(await page.locator("input").count(), 1);
|
assertEquals(await page.locator("details[open]").count(), 0);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await page.getByRole("link", {
|
await page.getByRole("link", {
|
||||||
name: "Create a new account",
|
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",
|
name: "Activate an account with a code",
|
||||||
exact: true,
|
exact: true,
|
||||||
}).waitFor();
|
}).waitFor();
|
||||||
await id.fill("alice");
|
await button.focus();
|
||||||
await id.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
const button = page.getByRole("button", {
|
await page.keyboard.press("Shift+Tab");
|
||||||
name: "Log in with passkey",
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
assert(
|
assert(
|
||||||
await button.evaluate((element) =>
|
await button.evaluate((element) =>
|
||||||
element === document.activeElement &&
|
element === document.activeElement &&
|
||||||
@@ -166,6 +173,12 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
|||||||
);
|
);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await at(page, "/account");
|
await at(page, "/account");
|
||||||
|
assertEquals(
|
||||||
|
mutations.find((entry) =>
|
||||||
|
entry.path === "/api/auth/passkeys/login/options"
|
||||||
|
)?.keys,
|
||||||
|
["browser_origin"],
|
||||||
|
);
|
||||||
await page.getByRole("button", {
|
await page.getByRole("button", {
|
||||||
name: "Add passkey to alice",
|
name: "Add passkey to alice",
|
||||||
exact: true,
|
exact: true,
|
||||||
@@ -448,6 +461,105 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
|||||||
assertEquals(mutations, []);
|
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(
|
await t.step(
|
||||||
"leaving a pending login never completes or redirects after navigation",
|
"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"
|
: purpose === "addition"
|
||||||
? "/account"
|
? "/account"
|
||||||
: "/login";
|
: "/login";
|
||||||
for (const failure of ["cancel", "error", "http-error", "expired"]) {
|
for (
|
||||||
|
const failure of [
|
||||||
|
"cancel",
|
||||||
|
"unsupported",
|
||||||
|
"error",
|
||||||
|
"http-error",
|
||||||
|
"expired",
|
||||||
|
]
|
||||||
|
) {
|
||||||
await t.step(
|
await t.step(
|
||||||
`${purpose} ${failure} is visible and does not claim successful authentication`,
|
`${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"),
|
path === route || (purpose === "addition" && path === "/login"),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
failure === "cancel" || failure === "error" ||
|
failure === "cancel" || failure === "unsupported" ||
|
||||||
|
failure === "error" ||
|
||||||
failure === "http-error"
|
failure === "http-error"
|
||||||
) {
|
) {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ const fill = (selector: string, value: string): Interaction => ({
|
|||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
const id = 'label:has-text("ID (handle)") input';
|
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"),
|
fill(id, "alice"),
|
||||||
click('button:text-is("Log in with passkey")'),
|
click('button:text-is("Log in with older key")'),
|
||||||
];
|
];
|
||||||
const register = [
|
const register = [
|
||||||
fill(id, "alice"),
|
fill(id, "alice"),
|
||||||
@@ -53,23 +56,32 @@ const route = (
|
|||||||
capturePoints,
|
capturePoints,
|
||||||
});
|
});
|
||||||
const routes: RouteScenario[] = [
|
const routes: RouteScenario[] = [
|
||||||
route("login", "/login?fixture=anonymous", `${id}:enabled`, [
|
route("login", "/login?fixture=anonymous", `${loginButton}:enabled`, [
|
||||||
{
|
{
|
||||||
id: "initial",
|
id: "initial",
|
||||||
label: "ID-only login; registration and activation entries",
|
label: "ID-free primary login; explicit older-key disclosure",
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "keyboard-focus",
|
id: "keyboard-focus",
|
||||||
label: "Login keyboard focus",
|
label: "Login keyboard focus",
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
interaction: [fill(id, "alice"), {
|
interaction: [{ action: "press", selector: loginButton, key: "Tab" }, {
|
||||||
action: "press",
|
action: "press",
|
||||||
selector: id,
|
selector: 'summary:text-is("Use an older key with account ID")',
|
||||||
key: "Tab",
|
key: "Shift+Tab",
|
||||||
}],
|
}],
|
||||||
ready: ready('button:text-is("Log in with passkey"):focus-visible'),
|
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(
|
route(
|
||||||
"account-redirect",
|
"account-redirect",
|
||||||
@@ -139,7 +151,7 @@ const routes: RouteScenario[] = [
|
|||||||
label: "Dedicated login with device returnTo",
|
label: "Dedicated login with device returnTo",
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
interaction: [click('a:text-is("Log in to continue")')],
|
interaction: [click('a:text-is("Log in to continue")')],
|
||||||
ready: ready(`${id}:enabled`),
|
ready: ready(`${loginButton}:enabled`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "authenticated-unapproved",
|
id: "authenticated-unapproved",
|
||||||
@@ -203,7 +215,7 @@ const routes: RouteScenario[] = [
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
...["login", "registration", "addition"].flatMap((purpose) =>
|
...["login", "registration", "addition"].flatMap((purpose) =>
|
||||||
["pending", "cancel", "error", "expired"].map((state) => {
|
["pending", "cancel", "unsupported", "error", "expired"].map((state) => {
|
||||||
const path = purpose === "registration"
|
const path = purpose === "registration"
|
||||||
? "/register"
|
? "/register"
|
||||||
: purpose === "addition"
|
: purpose === "addition"
|
||||||
@@ -219,6 +231,8 @@ const routes: RouteScenario[] = [
|
|||||||
`${path}?fixture=${purpose}-${state}`,
|
`${path}?fixture=${purpose}-${state}`,
|
||||||
purpose === "addition"
|
purpose === "addition"
|
||||||
? 'button:text-is("Add passkey to alice")'
|
? 'button:text-is("Add passkey to alice")'
|
||||||
|
: purpose === "login"
|
||||||
|
? `${loginButton}:enabled`
|
||||||
: `${id}:enabled`,
|
: `${id}:enabled`,
|
||||||
[{
|
[{
|
||||||
id: "result",
|
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(
|
route(
|
||||||
"activation-entry",
|
"activation-entry",
|
||||||
"/login?fixture=activation-pending",
|
"/login?fixture=activation-pending",
|
||||||
|
|||||||
@@ -34,14 +34,25 @@ const mime: Record<string, string> = {
|
|||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".ico": "image/x-icon",
|
".ico": "image/x-icon",
|
||||||
};
|
};
|
||||||
const options = (login: boolean) => ({
|
const options = (login: boolean, legacy = false) => ({
|
||||||
challenge_id: "synthetic-challenge-only",
|
challenge_id: "synthetic-challenge-only",
|
||||||
public_key: login ? { challenge: "dWk" } : {
|
public_key: login
|
||||||
challenge: "dWk",
|
? {
|
||||||
rp: { name: "UI projection" },
|
challenge: "dWk",
|
||||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
allowCredentials: legacy ? [{ type: "public-key", id: "dWk" }] : [],
|
||||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
userVerification: "required",
|
||||||
},
|
}
|
||||||
|
: {
|
||||||
|
challenge: "dWk",
|
||||||
|
rp: { name: "UI projection" },
|
||||||
|
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||||
|
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "required",
|
||||||
|
requireResidentKey: true,
|
||||||
|
userVerification: "required",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||||
@@ -70,6 +81,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
|||||||
if (mode.endsWith("-cancel")) {
|
if (mode.endsWith("-cancel")) {
|
||||||
extra =
|
extra =
|
||||||
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`;
|
`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")) {
|
} else if (mode.endsWith("-error")) {
|
||||||
extra =
|
extra =
|
||||||
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Synthetic authenticator failure','UnknownError')}});`;
|
`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;
|
if (purpose === "addition") state.signedIn = false;
|
||||||
return new Response(null, { status: 401 });
|
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;
|
state.signedIn = true;
|
||||||
return Response.json({ user });
|
return Response.json({ user });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare const Deno: {
|
|||||||
import {
|
import {
|
||||||
addPasskey,
|
addPasskey,
|
||||||
loadWhoami,
|
loadWhoami,
|
||||||
loginWithPasskey,
|
loginWithLegacyPasskey,
|
||||||
readBoundedAuthResponseJson,
|
readBoundedAuthResponseJson,
|
||||||
registerPasskey,
|
registerPasskey,
|
||||||
} from "../src/lib/workspace/auth/api.ts";
|
} from "../src/lib/workspace/auth/api.ts";
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
authUser,
|
authUser,
|
||||||
credentialFixture,
|
credentialFixture,
|
||||||
loginOptions,
|
legacyLoginOptions as loginOptions,
|
||||||
registrationOptions,
|
registrationOptions,
|
||||||
} from "../src/lib/workspace/auth/api.test-fixtures.ts";
|
} 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"]);
|
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(() => {
|
await withBrowserBoundary(() => {
|
||||||
throw new Error("Unexpected HTTP request");
|
throw new Error("Unexpected HTTP request");
|
||||||
}, async (calls) => {
|
}, async (calls) => {
|
||||||
for (const handle of ["", " ", "\n\t"]) {
|
for (const handle of ["", " ", "\n\t"]) {
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
loginWithPasskey(handle),
|
loginWithLegacyPasskey(handle),
|
||||||
"User handle is required for passkey login.",
|
"User handle is required for legacy passkey login.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assertEquals(calls, { create: 0, get: 0 });
|
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 },
|
requests.length === 1 ? loginOptions : { user: authUser },
|
||||||
);
|
);
|
||||||
}, async (calls) => {
|
}, async (calls) => {
|
||||||
assertEquals(await loginWithPasskey(" alice "), authUser);
|
assertEquals(await loginWithLegacyPasskey(" alice "), authUser);
|
||||||
assertEquals(calls, { create: 0, get: 1 });
|
assertEquals(calls, { create: 0, get: 1 });
|
||||||
});
|
});
|
||||||
assertEquals(requests.map(({ path }) => path), [
|
assertEquals(requests.map(({ path }) => path), [
|
||||||
|
|||||||
Reference in New Issue
Block a user