fix: fence activation and setup against delayed cross-tab events

Capture the shared authentication stamp as well as generation before dispatch and across response/confirmation/application awaits. Do not let stale setup failure reconciliation overwrite a newer tab's stamp. Add no-event-yet logout and account-change regressions for activation and setup, including prompt, pre-complete and post-dispatch confirmation boundaries.

Addresses MR AC4_CROSS_TAB_ACTIVATION. Validation: activation21/setup40 narrow tests; Web720 unit+625 component, check0/0/build, activation and hosting browser suites; root check/fmt/diff; both explicit real Backend browser suites including native CTAP2. Existing Server exclusions and 15 identical baseline naming findings remain unchanged. No verifier, binding, migration or rendering contract weakened.
This commit is contained in:
2026-10-26 07:34:04 +09:00
parent f17b5212c7
commit c6a8759e7b
6 changed files with 294 additions and 36 deletions
@@ -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");
}
});
}
}
@@ -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<AuthUser> {
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<unknown> => {
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を含む可能性があるため、本文も例外も転載しない。
@@ -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 {
@@ -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<void>();
const pending = gate<void>();
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);
});
}
}
@@ -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();
}
@@ -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<void>(); const credential = gate<Credential>();
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<void>();
const credential = gate<Credential>();
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<void>();
const pending = gate<void>();
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);
});
}
}