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:
@@ -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,
|
ActivationOptionsRequest,
|
||||||
PasskeyRegistrationCompleteRequest,
|
PasskeyRegistrationCompleteRequest,
|
||||||
} from "#lib/generated/auth-api.ts";
|
} 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 { browserSession } from "./session-state.ts";
|
||||||
import { normalizeActivationHandle } from "./hosting-accounts.ts";
|
import { normalizeActivationHandle } from "./hosting-accounts.ts";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,15 @@ export async function activateWithPasskey(
|
|||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<AuthUser> {
|
): Promise<AuthUser> {
|
||||||
const generation = browserSession.authenticationGeneration;
|
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 {
|
try {
|
||||||
handle = normalizeActivationHandle(handle);
|
handle = normalizeActivationHandle(handle);
|
||||||
if (window.isSecureContext !== true || !code || code.length > 4096) {
|
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",
|
path: "/api/auth/activation/options" | "/api/auth/activation/complete",
|
||||||
body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest,
|
body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||||
): Promise<unknown> => {
|
): Promise<unknown> => {
|
||||||
signal.throwIfAborted();
|
assertCurrent();
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@@ -46,7 +55,9 @@ export async function activateWithPasskey(
|
|||||||
await response.body?.cancel();
|
await response.body?.cancel();
|
||||||
throw new Error("Activation rejected");
|
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", {
|
const options = await request("/api/auth/activation/options", {
|
||||||
handle,
|
handle,
|
||||||
@@ -56,25 +67,32 @@ export async function activateWithPasskey(
|
|||||||
code = "";
|
code = "";
|
||||||
const publicKey = prepareRegistrationOptions(options);
|
const publicKey = prepareRegistrationOptions(options);
|
||||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||||
signal.throwIfAborted();
|
assertCurrent();
|
||||||
const credential = await navigator.credentials.create({
|
const credential = await navigator.credentials.create({
|
||||||
publicKey,
|
publicKey,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
if (!credential) throw new Error("Registration cancelled");
|
if (!credential) throw new Error("Registration cancelled");
|
||||||
signal.throwIfAborted();
|
assertCurrent();
|
||||||
if (generation !== browserSession.authenticationGeneration) throw new Error("Activation superseded");
|
|
||||||
const result = parseAuthUserResponse(
|
const result = parseAuthUserResponse(
|
||||||
await request("/api/auth/activation/complete", {
|
await request("/api/auth/activation/complete", {
|
||||||
challenge_id: challengeId,
|
challenge_id: challengeId,
|
||||||
credential: registrationCredentialToJson(credential),
|
credential: registrationCredentialToJson(credential),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
signal.throwIfAborted();
|
assertCurrent();
|
||||||
if (result.user.handle !== handle || generation !== browserSession.authenticationGeneration) throw new Error("Activation target mismatch");
|
if (result.user.handle !== handle) {
|
||||||
|
throw new Error("Activation target mismatch");
|
||||||
|
}
|
||||||
const current = await loadWhoami(signal);
|
const current = await loadWhoami(signal);
|
||||||
signal.throwIfAborted();
|
assertCurrent();
|
||||||
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");
|
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;
|
return result.user;
|
||||||
} catch {
|
} catch {
|
||||||
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
|
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { activateWithPasskey } from '#lib/workspace/auth/activation.ts';
|
import { activateWithPasskey } from '#lib/workspace/auth/activation.ts';
|
||||||
import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
|
import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
|
||||||
import { notifySessionChanged } from '#lib/workspace/auth/hosting.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';
|
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||||
|
|
||||||
let handle = $state('');
|
let handle = $state('');
|
||||||
@@ -19,12 +20,15 @@
|
|||||||
try { handle = normalizeActivationHandle(handle); }
|
try { handle = normalizeActivationHandle(handle); }
|
||||||
catch { error = 'Check the account ID supplied by your administrator.'; return; }
|
catch { error = 'Check the account ID supplied by your administrator.'; return; }
|
||||||
const current = ++generation;
|
const current = ++generation;
|
||||||
|
const authGeneration = browserSession.authenticationGeneration;
|
||||||
|
const authStamp = browserSession.authenticationStamp;
|
||||||
controller = new AbortController(); busy = true; error = '';
|
controller = new AbortController(); busy = true; error = '';
|
||||||
const pending = activateWithPasskey(handle, code, controller.signal);
|
const pending = activateWithPasskey(handle, code, controller.signal);
|
||||||
code = '';
|
code = '';
|
||||||
try {
|
try {
|
||||||
const confirmed = await pending;
|
const confirmed = await pending;
|
||||||
if (current !== generation) return;
|
if (current !== generation) return;
|
||||||
|
if (authGeneration !== browserSession.authenticationGeneration || authStamp !== browserSession.authenticationStamp) throw new Error('Activation superseded');
|
||||||
user = confirmed;
|
user = confirmed;
|
||||||
notifySessionChanged();
|
notifySessionChanged();
|
||||||
} catch {
|
} 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.queryByText(/You are signed in/)).toBeNull();
|
||||||
expect(screen.queryByRole("link", { name: "Open Account" })).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 active = true;
|
||||||
|
|
||||||
let verifiedGeneration: number | null = null;
|
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を検証する。
|
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
||||||
function matchesSession(expected: AuthUser): boolean {
|
function matchesSession(expected: AuthUser): boolean {
|
||||||
const snapshot = browserSession.snapshot;
|
const snapshot = browserSession.snapshot;
|
||||||
@@ -30,9 +31,10 @@
|
|||||||
return browserSession.subscribe(() => {
|
return browserSession.subscribe(() => {
|
||||||
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
|
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
|
||||||
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
|
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
|
||||||
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) {
|
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || verifiedStamp !== browserSession.authenticationStamp || !matchesSession(user)) {
|
||||||
verifiedLogin = false;
|
verifiedLogin = false;
|
||||||
verifiedGeneration = null;
|
verifiedGeneration = null;
|
||||||
|
verifiedStamp = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -63,26 +65,27 @@
|
|||||||
error = '';
|
error = '';
|
||||||
sessionMutation();
|
sessionMutation();
|
||||||
let generation = browserSession.authenticationGeneration;
|
let generation = browserSession.authenticationGeneration;
|
||||||
|
let stamp = browserSession.authenticationStamp;
|
||||||
const operation = new AbortController();
|
const operation = new AbortController();
|
||||||
loginController = operation;
|
loginController = operation;
|
||||||
let promptGeneration: number | null = generation;
|
let promptGeneration: number | null = generation;
|
||||||
const unsubscribe = browserSession.subscribe(() => {
|
const unsubscribe = browserSession.subscribe(() => {
|
||||||
if (promptGeneration !== null && promptGeneration !== browserSession.authenticationGeneration) operation.abort();
|
if (promptGeneration !== null && !isCurrent(promptGeneration, stamp)) operation.abort();
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const initial = await loadWhoami(operation.signal);
|
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, {
|
observeAuthOperation(operation.signal, {
|
||||||
expected: initial, phase: () => {}, additionAttempt: () => {},
|
expected: initial, phase: () => {}, additionAttempt: () => {},
|
||||||
verifySession: async () => {
|
verifySession: async () => {
|
||||||
if (!isCurrent(generation)) operation.abort();
|
if (!isCurrent(generation, stamp)) operation.abort();
|
||||||
operation.signal.throwIfAborted();
|
operation.signal.throwIfAborted();
|
||||||
const current = await loadWhoami(operation.signal);
|
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);
|
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) {
|
if (signedIn.user.user_id !== expected.user_id || signedIn.user.account_id !== expected.account_id) {
|
||||||
throw new Error('Unexpected login actor');
|
throw new Error('Unexpected login actor');
|
||||||
}
|
}
|
||||||
@@ -90,21 +93,23 @@
|
|||||||
promptGeneration = null;
|
promptGeneration = null;
|
||||||
sessionMutation();
|
sessionMutation();
|
||||||
generation = browserSession.authenticationGeneration;
|
generation = browserSession.authenticationGeneration;
|
||||||
|
stamp = browserSession.authenticationStamp;
|
||||||
await browserSession.refresh();
|
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();
|
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公開前の最終確認を行う。
|
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
|
||||||
await browserSession.refresh();
|
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;
|
verifiedGeneration = generation;
|
||||||
|
verifiedStamp = stamp;
|
||||||
verifiedLogin = true;
|
verifiedLogin = true;
|
||||||
notifySessionChanged();
|
notifySessionChanged();
|
||||||
} catch {
|
} 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.';
|
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を
|
// 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを
|
||||||
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
|
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
|
||||||
if (isCurrent(generation)) {
|
if (isCurrent(generation, stamp)) {
|
||||||
sessionMutation();
|
sessionMutation();
|
||||||
await browserSession.refresh();
|
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 () => {
|
test("setup logout during native login prompt aborts and never submits the late credential", async () => {
|
||||||
const { get, fetcher } = boundary();
|
const { get, fetcher } = boundary();
|
||||||
const prompted = gate<void>(); const credential = gate<Credential>();
|
const prompted = gate<void>();
|
||||||
get.mockImplementation(() => { prompted.resolve(); return credential.promise; });
|
const credential = gate<Credential>();
|
||||||
render(Setup); await enroll();
|
get.mockImplementation(() => {
|
||||||
await fireEvent.click(await screen.findByRole("button", { name: "Sign in with new passkey" }));
|
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 prompted.promise;
|
||||||
await changedInAnotherTab(null);
|
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);
|
expect(signal.aborted).toBe(true);
|
||||||
credential.resolve(credentialFixture(true));
|
credential.resolve(credentialFixture(true));
|
||||||
await screen.findByRole("alert");
|
await screen.findByRole("alert");
|
||||||
expect(fetcher.mock.calls.some(([path]) => path === "/api/auth/passkeys/login/complete")).toBe(false);
|
expect(
|
||||||
expect(screen.queryByRole("link", { name: "Open hosting settings" })).toBeNull();
|
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 () => {
|
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;
|
let accessRead = false;
|
||||||
fetcher.mockImplementation(async (input, init) => {
|
fetcher.mockImplementation(async (input, init) => {
|
||||||
if (String(input) === "/api/hosting/access") accessRead = true;
|
if (String(input) === "/api/hosting/access") accessRead = true;
|
||||||
if (String(input) === "/api/auth/whoami" && accessRead) return Response.json({
|
if (String(input) === "/api/auth/whoami" && accessRead) {
|
||||||
actor: setupActor, authentication_reference: "same-owner-different-session",
|
return Response.json({
|
||||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
actor: setupActor,
|
||||||
});
|
authentication_reference: "same-owner-different-session",
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
});
|
||||||
|
}
|
||||||
return await request(input, init);
|
return await request(input, init);
|
||||||
});
|
});
|
||||||
render(Setup); await enroll();
|
render(Setup);
|
||||||
await fireEvent.click(await screen.findByRole("button", { name: "Sign in with new passkey" }));
|
await enroll();
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||||
|
);
|
||||||
await screen.findByRole("alert");
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user