diff --git a/web/workspace/src/lib/workspace/auth/activation.test.ts b/web/workspace/src/lib/workspace/auth/activation.test.ts index 9148314e2..de5562a3b 100644 --- a/web/workspace/src/lib/workspace/auth/activation.test.ts +++ b/web/workspace/src/lib/workspace/auth/activation.test.ts @@ -319,3 +319,74 @@ for (const stage of ["prompt", "completion", "confirmation"] as const) { }, ); } + +for ( + const phase of ["options", "prompt", "complete", "confirmation"] as const +) { + for (const reason of ["logout", "authentication"] as const) { + Deno.test(`activation fences shared ${reason} stamp during ${phase} before event delivery`, async () => { + const previous = Object.getOwnPropertyDescriptor( + globalThis, + "localStorage", + ); + let stamp = "initial-operation-stamp"; + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { getItem: () => stamp }, + }); + const generation = browserSession.authenticationGeneration; + const paths: string[] = []; + try { + await boundary(async (input) => { + const path = String(input); + paths.push(path); + if ( + (phase === "options" && path.endsWith("/options")) || + (phase === "complete" && path.endsWith("/complete")) || + (phase === "confirmation" && path === "/api/auth/whoami") + ) { + stamp = JSON.stringify({ id: "other-tab", reason }); + } + return Response.json( + path.endsWith("/options") + ? registrationOptions + : path.endsWith("/complete") + ? { + user: authUser, + authentication_reference: "fixture-authentication", + } + : confirmedSession(), + ); + }, async () => { + if (phase === "prompt") { + stamp = JSON.stringify({ id: "other-tab", reason }); + } + return credentialFixture(); + }, async () => { + await rejects( + activateWithPasskey( + "alice", + "synthetic-input-only", + new AbortController().signal, + ), + ); + equal(browserSession.authenticationGeneration, generation); + equal(stamp === "initial-operation-stamp", false); + equal( + paths.filter((path) => path.endsWith("/complete")).length, + phase === "complete" || phase === "confirmation" ? 1 : 0, + ); + equal(paths.includes("/api/auth/whoami"), phase === "confirmation"); + }); + } finally { + if (previous) { + Object.defineProperty( + globalThis, + "localStorage", + previous, + ); + } else Reflect.deleteProperty(globalThis, "localStorage"); + } + }); + } +} diff --git a/web/workspace/src/lib/workspace/auth/activation.ts b/web/workspace/src/lib/workspace/auth/activation.ts index 6cd963fb5..a17550fd9 100644 --- a/web/workspace/src/lib/workspace/auth/activation.ts +++ b/web/workspace/src/lib/workspace/auth/activation.ts @@ -2,7 +2,7 @@ import type { ActivationOptionsRequest, PasskeyRegistrationCompleteRequest, } from "#lib/generated/auth-api.ts"; -import { readBoundedAuthResponseJson, loadWhoami } from "./api.ts"; +import { loadWhoami, readBoundedAuthResponseJson } from "./api.ts"; import { browserSession } from "./session-state.ts"; import { normalizeActivationHandle } from "./hosting-accounts.ts"; import { @@ -21,6 +21,15 @@ export async function activateWithPasskey( signal: AbortSignal, ): Promise { const generation = browserSession.authenticationGeneration; + const stamp = browserSession.authenticationStamp; + // 別tabのstorage event配送より先にcredential/応答が返っても送信・適用しない。 + const assertCurrent = () => { + signal.throwIfAborted(); + if ( + generation !== browserSession.authenticationGeneration || + stamp !== browserSession.authenticationStamp + ) throw new Error("Activation superseded"); + }; try { handle = normalizeActivationHandle(handle); if (window.isSecureContext !== true || !code || code.length > 4096) { @@ -30,7 +39,7 @@ export async function activateWithPasskey( path: "/api/auth/activation/options" | "/api/auth/activation/complete", body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest, ): Promise => { - signal.throwIfAborted(); + assertCurrent(); const response = await fetch(path, { method: "POST", credentials: "same-origin", @@ -46,7 +55,9 @@ export async function activateWithPasskey( await response.body?.cancel(); throw new Error("Activation rejected"); } - return await readBoundedAuthResponseJson(response); + const result = await readBoundedAuthResponseJson(response); + assertCurrent(); + return result; }; const options = await request("/api/auth/activation/options", { handle, @@ -56,25 +67,32 @@ export async function activateWithPasskey( code = ""; const publicKey = prepareRegistrationOptions(options); const challengeId = (options as { challenge_id: string }).challenge_id; - signal.throwIfAborted(); + assertCurrent(); const credential = await navigator.credentials.create({ publicKey, signal, }); if (!credential) throw new Error("Registration cancelled"); - signal.throwIfAborted(); - if (generation !== browserSession.authenticationGeneration) throw new Error("Activation superseded"); + assertCurrent(); const result = parseAuthUserResponse( await request("/api/auth/activation/complete", { challenge_id: challengeId, credential: registrationCredentialToJson(credential), }), ); - signal.throwIfAborted(); - if (result.user.handle !== handle || generation !== browserSession.authenticationGeneration) throw new Error("Activation target mismatch"); + assertCurrent(); + if (result.user.handle !== handle) { + throw new Error("Activation target mismatch"); + } const current = await loadWhoami(signal); - signal.throwIfAborted(); - if (generation !== browserSession.authenticationGeneration || current.session.kind !== "authenticated" || current.actor?.auth_method !== "browser_session" || current.actor.user_id !== result.user.user_id || current.actor.account_id !== result.user.account_id || current.authentication_reference !== result.authentication_reference) throw new Error("Activation session unconfirmed"); + assertCurrent(); + if ( + current.session.kind !== "authenticated" || + current.actor?.auth_method !== "browser_session" || + current.actor.user_id !== result.user.user_id || + current.actor.account_id !== result.user.account_id || + current.authentication_reference !== result.authentication_reference + ) throw new Error("Activation session unconfirmed"); return result.user; } catch { // HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。 diff --git a/web/workspace/src/routes/activate/+page.svelte b/web/workspace/src/routes/activate/+page.svelte index 84eaaaefb..9c864169d 100644 --- a/web/workspace/src/routes/activate/+page.svelte +++ b/web/workspace/src/routes/activate/+page.svelte @@ -3,6 +3,7 @@ import { activateWithPasskey } from '#lib/workspace/auth/activation.ts'; import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts'; import { notifySessionChanged } from '#lib/workspace/auth/hosting.ts'; + import { browserSession } from '#lib/workspace/auth/session-state.ts'; import type { AuthUser } from '#lib/workspace/auth/model.ts'; let handle = $state(''); @@ -19,12 +20,15 @@ try { handle = normalizeActivationHandle(handle); } catch { error = 'Check the account ID supplied by your administrator.'; return; } const current = ++generation; + const authGeneration = browserSession.authenticationGeneration; + const authStamp = browserSession.authenticationStamp; controller = new AbortController(); busy = true; error = ''; const pending = activateWithPasskey(handle, code, controller.signal); code = ''; try { const confirmed = await pending; if (current !== generation) return; + if (authGeneration !== browserSession.authenticationGeneration || authStamp !== browserSession.authenticationStamp) throw new Error('Activation superseded'); user = confirmed; notifySessionChanged(); } catch { diff --git a/web/workspace/src/routes/activate/activation.browser.spec.ts b/web/workspace/src/routes/activate/activation.browser.spec.ts index 7073c54e4..6df8f1490 100644 --- a/web/workspace/src/routes/activate/activation.browser.spec.ts +++ b/web/workspace/src/routes/activate/activation.browser.spec.ts @@ -236,3 +236,45 @@ test("logout during the activation prompt fences a late credential before comple expect(screen.queryByText(/You are signed in/)).toBeNull(); expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull(); }); + +for (const phase of ["prompt", "confirmation"] as const) { + for (const reason of ["logout", "authentication"] as const) { + test(`activation UI rejects shared ${reason} stamp during ${phase} before event delivery`, async () => { + const { create, fetcher } = boundary("success"); + const started = gate(); + const pending = gate(); + const request = fetcher.getMockImplementation()!; + if (phase === "prompt") { + create.mockImplementation(async () => { + started.resolve(); + await pending.promise; + return credentialFixture(); + }); + } else {fetcher.mockImplementation(async (input, init) => { + if (String(input) === "/api/auth/whoami") { + started.resolve(); + await pending.promise; + } + return await request(input, init); + });} + render(Activate); + await submit(); + await started.promise; + const generation = browserSession.authenticationGeneration; + localStorage.setItem( + "yoi.browser-auth-generation", + JSON.stringify({ id: crypto.randomUUID(), reason }), + ); + expect(browserSession.authenticationGeneration).toBe(generation); + pending.resolve(); + await screen.findByRole("alert"); + expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull(); + expect(screen.queryByText(/You are signed in/)).toBeNull(); + expect( + fetcher.mock.calls.filter(([path]) => + path === "/api/auth/activation/complete" + ), + ).toHaveLength(phase === "prompt" ? 0 : 1); + }); + } +} diff --git a/web/workspace/src/routes/hosting/setup/+page.svelte b/web/workspace/src/routes/hosting/setup/+page.svelte index db24aa040..03c621a53 100644 --- a/web/workspace/src/routes/hosting/setup/+page.svelte +++ b/web/workspace/src/routes/hosting/setup/+page.svelte @@ -17,7 +17,8 @@ let active = true; let verifiedGeneration: number | null = null; - const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration; + let verifiedStamp: string | null = null; + const isCurrent = (generation: number, stamp: string | null) => active && generation === browserSession.authenticationGeneration && stamp === browserSession.authenticationStamp; // 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。 function matchesSession(expected: AuthUser): boolean { const snapshot = browserSession.snapshot; @@ -30,9 +31,10 @@ return browserSession.subscribe(() => { // 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。 // 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。 - if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) { + if (!user || verifiedGeneration !== browserSession.authenticationGeneration || verifiedStamp !== browserSession.authenticationStamp || !matchesSession(user)) { verifiedLogin = false; verifiedGeneration = null; + verifiedStamp = null; } }); }); @@ -63,26 +65,27 @@ error = ''; sessionMutation(); let generation = browserSession.authenticationGeneration; + let stamp = browserSession.authenticationStamp; const operation = new AbortController(); loginController = operation; let promptGeneration: number | null = generation; const unsubscribe = browserSession.subscribe(() => { - if (promptGeneration !== null && promptGeneration !== browserSession.authenticationGeneration) operation.abort(); + if (promptGeneration !== null && !isCurrent(promptGeneration, stamp)) operation.abort(); }); try { const initial = await loadWhoami(operation.signal); - if (!isCurrent(generation)) throw new Error('Login superseded'); + if (!isCurrent(generation, stamp)) throw new Error('Login superseded'); observeAuthOperation(operation.signal, { expected: initial, phase: () => {}, additionAttempt: () => {}, verifySession: async () => { - if (!isCurrent(generation)) operation.abort(); + if (!isCurrent(generation, stamp)) operation.abort(); operation.signal.throwIfAborted(); const current = await loadWhoami(operation.signal); - if (!isCurrent(generation) || initial.actor?.user_id !== current.actor?.user_id || initial.actor?.account_id !== current.actor?.account_id || initial.authentication_reference !== current.authentication_reference) throw new Error('Session changed'); + if (!isCurrent(generation, stamp) || initial.actor?.user_id !== current.actor?.user_id || initial.actor?.account_id !== current.actor?.account_id || initial.authentication_reference !== current.authentication_reference) throw new Error('Session changed'); }, }); const signedIn = await loginWithPasskey(operation.signal); - if (!isCurrent(generation)) throw new Error('Login superseded'); + if (!isCurrent(generation, stamp)) throw new Error('Login superseded'); if (signedIn.user.user_id !== expected.user_id || signedIn.user.account_id !== expected.account_id) { throw new Error('Unexpected login actor'); } @@ -90,21 +93,23 @@ promptGeneration = null; sessionMutation(); generation = browserSession.authenticationGeneration; + stamp = browserSession.authenticationStamp; await browserSession.refresh(); - if (!isCurrent(generation) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session not verified'); + if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session not verified'); const access = await loadHostingAccess(); - if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified'); + if (!isCurrent(generation, stamp) || !access.is_admin) throw new Error('Access not verified'); // accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。 await browserSession.refresh(); - if (!isCurrent(generation) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session superseded'); + if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session superseded'); verifiedGeneration = generation; + verifiedStamp = stamp; verifiedLogin = true; notifySessionChanged(); } catch { if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.'; // 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを // invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。 - if (isCurrent(generation)) { + if (isCurrent(generation, stamp)) { sessionMutation(); await browserSession.refresh(); } diff --git a/web/workspace/src/routes/hosting/setup/hosting-setup.browser.spec.ts b/web/workspace/src/routes/hosting/setup/hosting-setup.browser.spec.ts index d83532a16..41d5f6459 100644 --- a/web/workspace/src/routes/hosting/setup/hosting-setup.browser.spec.ts +++ b/web/workspace/src/routes/hosting/setup/hosting-setup.browser.spec.ts @@ -467,33 +467,151 @@ for (const replacement of [null, replacedActor]) { test("setup logout during native login prompt aborts and never submits the late credential", async () => { const { get, fetcher } = boundary(); - const prompted = gate(); const credential = gate(); - get.mockImplementation(() => { prompted.resolve(); return credential.promise; }); - render(Setup); await enroll(); - await fireEvent.click(await screen.findByRole("button", { name: "Sign in with new passkey" })); + const prompted = gate(); + const credential = gate(); + get.mockImplementation(() => { + prompted.resolve(); + return credential.promise; + }); + render(Setup); + await enroll(); + await fireEvent.click( + await screen.findByRole("button", { name: "Sign in with new passkey" }), + ); await prompted.promise; await changedInAnotherTab(null); - const signal = (get.mock.calls[0] as unknown as [CredentialRequestOptions])[0].signal!; + const signal = (get.mock.calls[0] as unknown as [CredentialRequestOptions])[0] + .signal!; expect(signal.aborted).toBe(true); credential.resolve(credentialFixture(true)); await screen.findByRole("alert"); - expect(fetcher.mock.calls.some(([path]) => path === "/api/auth/passkeys/login/complete")).toBe(false); - expect(screen.queryByRole("link", { name: "Open hosting settings" })).toBeNull(); + expect( + fetcher.mock.calls.some(([path]) => + path === "/api/auth/passkeys/login/complete" + ), + ).toBe(false); + expect(screen.queryByRole("link", { name: "Open hosting settings" })) + .toBeNull(); }); test("setup final confirmation rejects replacement session for the same owner", async () => { - const { fetcher } = boundary(); const request = fetcher.getMockImplementation()!; + const { fetcher } = boundary(); + const request = fetcher.getMockImplementation()!; let accessRead = false; fetcher.mockImplementation(async (input, init) => { if (String(input) === "/api/hosting/access") accessRead = true; - if (String(input) === "/api/auth/whoami" && accessRead) return Response.json({ - actor: setupActor, authentication_reference: "same-owner-different-session", - session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, - }); + if (String(input) === "/api/auth/whoami" && accessRead) { + return Response.json({ + actor: setupActor, + authentication_reference: "same-owner-different-session", + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }); + } return await request(input, init); }); - render(Setup); await enroll(); - await fireEvent.click(await screen.findByRole("button", { name: "Sign in with new passkey" })); + render(Setup); + await enroll(); + await fireEvent.click( + await screen.findByRole("button", { name: "Sign in with new passkey" }), + ); await screen.findByRole("alert"); - expect(screen.queryByRole("link", { name: "Open hosting settings" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Open hosting settings" })) + .toBeNull(); }); + +for ( + const phase of [ + "initial", + "options", + "prompt", + "pre-complete", + "complete", + "whoami", + "access", + "confirmation", + ] as const +) { + for (const reason of ["logout", "authentication"] as const) { + test(`setup fences shared ${reason} stamp during ${phase} before event delivery`, async () => { + const { fetcher, get } = boundary(); + const request = fetcher.getMockImplementation()!; + const started = gate(); + const pending = gate(); + let optionsRead = false, + promptReturned = false, + completionSent = false, + accessRead = false, + held = false; + const hold = async () => { + held = true; + started.resolve(); + await pending.promise; + }; + get.mockImplementation(async () => { + if (phase === "prompt") await hold(); + promptReturned = true; + return credentialFixture(true); + }); + fetcher.mockImplementation(async (input, init) => { + if (new Headers(init?.headers).has("authorization")) { + return await request(input, init); + } + const path = String(input); + const point = path === "/api/auth/whoami" + ? !optionsRead + ? "initial" + : !completionSent && promptReturned + ? "pre-complete" + : accessRead + ? "confirmation" + : "whoami" + : path.endsWith("/login/options") + ? "options" + : path.endsWith("/login/complete") + ? "complete" + : path === "/api/hosting/access" + ? "access" + : null; + if (point === "options") optionsRead = true; + if (point === "complete") completionSent = true; + if (point === "access") accessRead = true; + const response = await request(input, init); + if (!held && point === phase) await hold(); + return response; + }); + render(Setup); + await enroll(); + await fireEvent.click( + await screen.findByRole("button", { name: "Sign in with new passkey" }), + ); + await started.promise; + const generation = browserSession.authenticationGeneration; + const stamp = JSON.stringify({ id: crypto.randomUUID(), reason }); + // 同一documentのsetItemはstorage eventを配送しない。generationを直接変えず順序を固定する。 + localStorage.setItem("yoi.browser-auth-generation", stamp); + expect(browserSession.authenticationGeneration).toBe(generation); + pending.resolve(); + await screen.findByRole("alert"); + await waitFor(() => + expect( + (screen.getByRole("button", { + name: "Sign in with new passkey", + }) as HTMLButtonElement).disabled, + ).toBe(false) + ); + expect( + fetcher.mock.calls.filter(([path]) => + path === "/api/auth/passkeys/login/complete" + ), + ).toHaveLength( + ["complete", "whoami", "access", "confirmation"].includes(phase) + ? 1 + : 0, + ); + expect(screen.queryByRole("link", { name: "Open hosting settings" })) + .toBeNull(); + // 古いcatchが新tabのstampを自身のsessionMutationで上書きしない。 + expect(browserSession.authenticationStamp).toBe(stamp); + }); + } +}