fix: fence hosting setup verification against superseded sessions
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { browserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
|
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
|
||||||
import { loadWhoami, loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
import { loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
||||||
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
|
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
|
||||||
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||||
@@ -14,13 +14,26 @@
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
|
let verifiedGeneration: number | null = null;
|
||||||
|
const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration;
|
||||||
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
||||||
async function verifySession(expected: AuthUser): Promise<boolean> {
|
function matchesSession(expected: AuthUser): boolean {
|
||||||
const { actor } = await loadWhoami();
|
const snapshot = browserSession.snapshot;
|
||||||
if (!active || actor?.user_id !== expected.user_id || actor.account_id !== expected.account_id || actor.auth_method !== 'browser_session') return false;
|
if (snapshot.kind !== 'ready' || snapshot.value.session.kind !== 'authenticated') return false;
|
||||||
const access = await loadHostingAccess();
|
const actor = snapshot.value.actor;
|
||||||
return active && access.is_admin;
|
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() {
|
async function enroll() {
|
||||||
if (busy || user || !token.trim()) return;
|
if (busy || user || !token.trim()) return;
|
||||||
@@ -47,20 +60,35 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
sessionMutation();
|
sessionMutation();
|
||||||
|
let generation = browserSession.authenticationGeneration;
|
||||||
try {
|
try {
|
||||||
const signedIn = await loginWithPasskey(controller.signal);
|
const signedIn = await loginWithPasskey(controller.signal);
|
||||||
if (!active) return;
|
if (!isCurrent(generation)) throw new Error('Login superseded');
|
||||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id || !await verifySession(expected)) {
|
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) {
|
||||||
throw new Error('Login or hosting access not verified');
|
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;
|
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 {
|
// 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを
|
||||||
|
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
|
||||||
|
if (isCurrent(generation)) {
|
||||||
sessionMutation();
|
sessionMutation();
|
||||||
await browserSession.refresh();
|
await browserSession.refresh();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
if (active) busy = false;
|
if (active) busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
} from "@testing-library/svelte";
|
} from "@testing-library/svelte";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import Setup from "./+page.svelte";
|
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 {
|
import {
|
||||||
authUser,
|
authUser,
|
||||||
credentialFixture,
|
credentialFixture,
|
||||||
@@ -17,6 +22,7 @@ import {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
browserSession.invalidate();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
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" }))
|
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||||
.toBeNull();
|
.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