fix: fence hosting setup verification against superseded sessions
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user