fix: fence hosting setup verification against superseded sessions

This commit is contained in:
2026-10-20 04:53:43 +09:00
parent bfeee487fd
commit 3e8164e757
2 changed files with 195 additions and 14 deletions
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { browserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
import { loadWhoami, loginWithPasskey } from '#lib/workspace/auth/api.ts';
import { onDestroy, onMount } from 'svelte';
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
import { loginWithPasskey } from '#lib/workspace/auth/api.ts';
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
import type { AuthUser } from '#lib/workspace/auth/model.ts';
@@ -14,13 +14,26 @@
const controller = new AbortController();
let active = true;
let verifiedGeneration: number | null = null;
const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration;
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
async function verifySession(expected: AuthUser): Promise<boolean> {
const { actor } = await loadWhoami();
if (!active || actor?.user_id !== expected.user_id || actor.account_id !== expected.account_id || actor.auth_method !== 'browser_session') return false;
const access = await loadHostingAccess();
return active && access.is_admin;
function matchesSession(expected: AuthUser): boolean {
const snapshot = browserSession.snapshot;
if (snapshot.kind !== 'ready' || snapshot.value.session.kind !== 'authenticated') return false;
const actor = snapshot.value.actor;
return actor?.user_id === expected.user_id && actor.account_id === expected.account_id && actor.auth_method === 'browser_session';
}
onMount(() => {
observeBrowserSession();
return browserSession.subscribe(() => {
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) {
verifiedLogin = false;
verifiedGeneration = null;
}
});
});
async function enroll() {
if (busy || user || !token.trim()) return;
@@ -47,20 +60,35 @@
busy = true;
error = '';
sessionMutation();
let generation = browserSession.authenticationGeneration;
try {
const signedIn = await loginWithPasskey(controller.signal);
if (!active) return;
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id || !await verifySession(expected)) {
throw new Error('Login or hosting access not verified');
if (!isCurrent(generation)) throw new Error('Login superseded');
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) {
throw new Error('Unexpected login actor');
}
if (!active) return;
// Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。
sessionMutation();
generation = browserSession.authenticationGeneration;
await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified');
const access = await loadHostingAccess();
if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified');
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded');
verifiedGeneration = generation;
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)) {
sessionMutation();
await browserSession.refresh();
}
} finally {
sessionMutation();
await browserSession.refresh();
if (active) busy = false;
}
}
@@ -8,6 +8,11 @@ import {
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import Setup from "./+page.svelte";
import {
browserSession,
observeBrowserSession,
} from "#lib/workspace/auth/session-state.ts";
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
import {
authUser,
credentialFixture,
@@ -17,6 +22,7 @@ import {
afterEach(() => {
cleanup();
browserSession.invalidate();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
@@ -268,3 +274,150 @@ test("API token identity is not mistaken for a verified browser session", async
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
});
function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
const setupActor = { ...authUser, auth_method: "browser_session" };
const replacedActor = {
...setupActor,
user_id: "other-user",
account_id: "other-account",
handle: "bob",
};
function identityResponse(actor: typeof setupActor | null) {
return Response.json({
actor,
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
});
}
async function changedInAnotherTab(actor: typeof setupActor | null) {
window.dispatchEvent(
new StorageEvent("storage", {
key: "yoi.browser-auth-generation",
newValue: JSON.stringify({
id: crypto.randomUUID(),
reason: actor ? "authentication" : "logout",
}),
}),
);
await browserSession.refresh();
}
for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
for (const replacement of [null, replacedActor]) {
test(`newer ${replacement ? "actor" : "logout"} while setup ${phase} is pending cannot publish old verified success`, async () => {
const { fetcher } = boundary();
const original = fetcher.getMockImplementation()!;
const started = gate<void>();
const pending = gate<Response>();
let current: typeof setupActor | null = setupActor;
let reads = 0;
fetcher.mockImplementation((input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") {
++reads;
if (
(phase === "whoami" && reads === 1) ||
(phase === "confirmation" && reads === 2)
) {
started.resolve();
return pending.promise;
}
return Promise.resolve(identityResponse(current));
}
if (
(phase === "access" && path === "/api/hosting/access") ||
(phase === "login" && path === "/api/auth/passkeys/login/complete")
) {
started.resolve();
return pending.promise;
}
return original(input, init);
});
observeBrowserSession();
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await started.promise;
const notify = vi.fn();
window.addEventListener(SESSION_CHANGED_EVENT, notify);
try {
current = replacement;
await changedInAnotherTab(current);
const acceptedGeneration = browserSession.authenticationGeneration;
pending.resolve(
phase === "access"
? Response.json({ is_admin: true })
: phase === "login"
? Response.json({ user: authUser })
: identityResponse(setupActor),
);
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Sign in with new passkey",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
expect(
screen.queryByText(
"Passkey login and hosting administrator access verified.",
),
).toBeNull();
expect(notify).not.toHaveBeenCalled();
expect(browserSession.authenticationGeneration).toBe(
acceptedGeneration,
);
expect(browserSession.snapshot).toMatchObject({
kind: "ready",
value: { actor: current },
});
} finally {
window.removeEventListener(SESSION_CHANGED_EVENT, notify);
}
});
}
}
for (const replacement of [null, replacedActor]) {
test(`setup verified success is removed on subsequent ${replacement ? "actor replacement" : "logout"}`, async () => {
const { fetcher } = boundary();
observeBrowserSession();
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await screen.findByRole("link", { name: "Open hosting settings" });
const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input) === "/api/auth/whoami"
? Promise.resolve(identityResponse(replacement))
: original(input, init)
);
await changedInAnotherTab(replacement);
await waitFor(() =>
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull()
);
expect(
screen.queryByText(
"Passkey login and hosting administrator access verified.",
),
).toBeNull();
expect(
(screen.getByRole("button", {
name: "Sign in with new passkey",
}) as HTMLButtonElement).disabled,
).toBe(false);
});
}