feat(auth): separate anonymous entry from expired session recovery
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { type Browser, chromium, type Page } from "playwright";
|
||||
import { sessionNavigation } from "./session-navigation.ts";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
@@ -14,7 +15,7 @@ type Mutation = { path: string; keys: string[] };
|
||||
type Harness = {
|
||||
page: Page;
|
||||
mutations: Mutation[];
|
||||
go: (path: string, mode?: string) => Promise<void>;
|
||||
go: (path: string, mode?: string, unavailableRoot?: boolean) => Promise<void>;
|
||||
};
|
||||
const login = async (page: Page) => {
|
||||
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
@@ -67,6 +68,19 @@ async function isolated(
|
||||
const page = await context.newPage();
|
||||
const mutations: Mutation[] = [];
|
||||
const errors: string[] = [];
|
||||
const rejectedLoads: unknown[] = [];
|
||||
let expectsUnavailableRoot = false;
|
||||
await page.exposeFunction(
|
||||
"__authFixtureRejectedLoad",
|
||||
(reason: unknown) => rejectedLoads.push(reason),
|
||||
);
|
||||
await page.addInitScript(() => {
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
void (window as unknown as {
|
||||
__authFixtureRejectedLoad: (reason: unknown) => Promise<void>;
|
||||
}).__authFixtureRejectedLoad(event.reason);
|
||||
});
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
@@ -81,14 +95,38 @@ async function isolated(
|
||||
await body({
|
||||
page,
|
||||
mutations,
|
||||
go: async (path, mode = "anonymous") => {
|
||||
const url = new URL(path, origin);
|
||||
url.searchParams.set("fixture", mode);
|
||||
await page.goto(url.href);
|
||||
go: async (path, mode = "anonymous", unavailableRoot = false) => {
|
||||
// fixture選択を実際の戻り先queryへ混入させない。同じcontextのcookieをHTTPで設定する。
|
||||
expectsUnavailableRoot ||= unavailableRoot ||
|
||||
(path.startsWith("/account") &&
|
||||
[
|
||||
"session-error",
|
||||
"session-malformed",
|
||||
"session-retry",
|
||||
"network-error",
|
||||
].includes(mode));
|
||||
const setup = new URL("/", origin);
|
||||
setup.searchParams.set("fixture", mode);
|
||||
await page.request.get(setup.href);
|
||||
await page.goto(new URL(path, origin).href);
|
||||
},
|
||||
});
|
||||
await noOverflow(page);
|
||||
assertEquals(errors, []);
|
||||
// root load自身の503ではKitのstatic fallback再実行も同じHttpErrorをrejectする。
|
||||
// minifyされたclass名で除外せず、意図した障害fixtureのstatus/bodyと件数だけを許す。
|
||||
if (expectsUnavailableRoot && rejectedLoads.length > 0) {
|
||||
for (const reason of rejectedLoads) {
|
||||
assertEquals(reason, {
|
||||
status: 503,
|
||||
body: {
|
||||
status: 503,
|
||||
message:
|
||||
"Your session could not be loaded. Login status is unknown. Retry this page.",
|
||||
},
|
||||
});
|
||||
}
|
||||
assertEquals(errors.length, rejectedLoads.length);
|
||||
} else assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -113,12 +151,13 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
readyUrl: `${origin}/health`,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
||||
`${root}/target/web-ux/auth-interactions/processes`,
|
||||
`${root}/target/web-ux/t767-validation/auth-interactions/processes`,
|
||||
[],
|
||||
);
|
||||
let browser: Browser | undefined;
|
||||
try {
|
||||
browser = await chromium.launch();
|
||||
await sessionNavigation(t, browser, isolated, origin);
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const step = (
|
||||
@@ -130,11 +169,11 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
() => isolated(browser!, body, theme, width),
|
||||
);
|
||||
await step(
|
||||
"anonymous account replaces history with login then returns to own account",
|
||||
"expired account replaces history with login then returns to own account",
|
||||
async ({ page, go, mutations }) => {
|
||||
await page.goto(`${origin}/health`);
|
||||
const historyBefore = await page.evaluate(() => history.length);
|
||||
await go("/account");
|
||||
await go("/account", "session-expired");
|
||||
await at(page, "/login?returnTo=%2Faccount");
|
||||
assertEquals(
|
||||
await page.evaluate(() => history.length),
|
||||
@@ -208,16 +247,12 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
);
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/login");
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/");
|
||||
await page.reload();
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(count(mutations, "/api/auth/logout"), 1);
|
||||
},
|
||||
);
|
||||
@@ -410,10 +445,15 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go(path, "session-error");
|
||||
await page.getByRole("button", {
|
||||
name: "Retry session",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
if (path === "/account") {
|
||||
// 初回protected loadの確認不能はroot loaderの503。画面内再確認とは分ける。
|
||||
await page.getByText(/Login status is unknown/i).waitFor();
|
||||
} else {
|
||||
await page.getByRole("button", {
|
||||
name: "Retry session",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
}
|
||||
assertEquals(
|
||||
await page.getByRole("link", {
|
||||
name: "Log in to continue",
|
||||
@@ -627,6 +667,9 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
"/register?returnTo=%2Flogin",
|
||||
"/activate",
|
||||
"/login/../login",
|
||||
"/%2f%2fexample.invalid",
|
||||
"/account?filter=%252F%252Fexample.invalid",
|
||||
"/account#%5Cexample.invalid",
|
||||
]
|
||||
) {
|
||||
await t.step(
|
||||
@@ -713,7 +756,11 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("button", { name: label, exact: true })
|
||||
await page.getByRole("button", {
|
||||
name: label,
|
||||
exact: true,
|
||||
includeHidden: true,
|
||||
})
|
||||
.evaluate((button) => {
|
||||
button.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
|
||||
@@ -10,6 +10,10 @@ import type {
|
||||
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const phase = Deno.args[0] ?? "after";
|
||||
const coverage = Deno.args[1] ?? "all";
|
||||
if (coverage !== "all" && coverage !== "session") {
|
||||
throw new Error("Expected capture coverage: all or session");
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(phase)) {
|
||||
throw new Error("Expected safe capture label");
|
||||
}
|
||||
@@ -86,7 +90,39 @@ const routes: RouteScenario[] = [
|
||||
route(
|
||||
"account-redirect",
|
||||
"/account?fixture=anonymous",
|
||||
'button:text-is("Log in with passkey")',
|
||||
'h1:text-is("Yoi")',
|
||||
),
|
||||
route(
|
||||
"expired-account",
|
||||
"/account?fixture=session-expired&tab=keys#passkeys",
|
||||
'[role="status"]:has-text("Your browser session expired")',
|
||||
),
|
||||
route(
|
||||
"expired-login",
|
||||
"/login?fixture=session-expired-cancel-once&returnTo=%2Faccount%3Ftab%3Dkeys%23passkeys",
|
||||
'[role="status"]:has-text("Your browser session expired")',
|
||||
[
|
||||
{
|
||||
id: "initial",
|
||||
label: "Explicit expiry and login continuation, not anonymous entry",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "cancelled",
|
||||
label: "Cancelled passkey keeps expiry context and retry destination",
|
||||
fullPage: true,
|
||||
interaction: login,
|
||||
ready: ready('[role="alert"]:has-text("cancelled")'),
|
||||
},
|
||||
{
|
||||
id: "returned",
|
||||
label:
|
||||
"Retry confirms authentication before returning to the requested account",
|
||||
fullPage: true,
|
||||
interaction: login,
|
||||
ready: ready('button:text-is("Add passkey to alice")'),
|
||||
},
|
||||
],
|
||||
),
|
||||
route("register", "/register?fixture=anonymous", `${id}:enabled`, [
|
||||
{
|
||||
@@ -129,10 +165,10 @@ const routes: RouteScenario[] = [
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
label: "Log out returns to login",
|
||||
label: "Log out forgets expiry and returns to public home",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Log out")')],
|
||||
ready: ready('button:text-is("Log in with passkey")'),
|
||||
ready: ready('h1:text-is("Yoi")'),
|
||||
},
|
||||
],
|
||||
),
|
||||
@@ -184,7 +220,7 @@ const routes: RouteScenario[] = [
|
||||
route(
|
||||
"account-session-error",
|
||||
"/account?fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
"text=/Login status is unknown/",
|
||||
),
|
||||
route(
|
||||
"register-session-error",
|
||||
@@ -297,6 +333,14 @@ const routes: RouteScenario[] = [
|
||||
],
|
||||
),
|
||||
];
|
||||
const sessionCaptureIds = new Set([
|
||||
"login",
|
||||
"account-redirect",
|
||||
"expired-account",
|
||||
"expired-login",
|
||||
"session-error",
|
||||
"account-session-error",
|
||||
]);
|
||||
await Deno.mkdir(`${root}/target/web-ux`, { recursive: true });
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
@@ -314,7 +358,9 @@ for (const colorScheme of ["light", "dark"] as const) {
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ width, height: 900, label: `${width}` }],
|
||||
routes,
|
||||
routes: coverage === "session"
|
||||
? routes.filter((route) => sessionCaptureIds.has(route.id))
|
||||
: routes,
|
||||
redact: {
|
||||
// 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。
|
||||
// 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。
|
||||
|
||||
@@ -48,7 +48,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
},
|
||||
],
|
||||
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
||||
`${root}/target/web-ux/entry-browser/processes`,
|
||||
`${root}/target/web-ux/t767-validation/entry-browser/processes`,
|
||||
[],
|
||||
);
|
||||
try {
|
||||
@@ -230,16 +230,8 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
];
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Log in with passkey" })
|
||||
.waitFor();
|
||||
retainedCounts.push(
|
||||
await page.locator(".workspace-alert").count(),
|
||||
);
|
||||
await page.getByRole("link", {
|
||||
name: "Dashboard / Workspaces",
|
||||
exact: true,
|
||||
}).click();
|
||||
await ready(page, "Log in");
|
||||
assertEquals(new URL(page.url()).pathname, "/");
|
||||
retainedCounts.push(
|
||||
await page.locator(".workspace-alert").count(),
|
||||
);
|
||||
@@ -253,8 +245,11 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
await page.getByRole("link", { name: "Yoi home", exact: true })
|
||||
.click();
|
||||
await ready(page, "Log in");
|
||||
retainedCounts.push(
|
||||
await page.locator(".workspace-alert").count(),
|
||||
);
|
||||
const folder =
|
||||
`${root}/target/web-ux/entry-browser/private-alerts-${colorScheme}-${width}`;
|
||||
`${root}/target/web-ux/t767-validation/entry-browser/private-alerts-${colorScheme}-${width}`;
|
||||
await Deno.mkdir(folder, { recursive: true });
|
||||
const bytes = await page.screenshot({
|
||||
path: folder + "/viewport.png",
|
||||
@@ -281,7 +276,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
JSON.stringify(
|
||||
{
|
||||
scenario:
|
||||
"Workspace Stop notification → Account logout → dashboard/login → public home (SPA)",
|
||||
"Workspace Stop notification → Account logout → public home → login → public home (SPA)",
|
||||
boundary:
|
||||
"synthetic HTTP and Worker subscription; production UI/router/store",
|
||||
path: new URL(page.url()).pathname,
|
||||
@@ -590,13 +585,19 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
session: {
|
||||
kind: "authenticated",
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("focus"))
|
||||
);
|
||||
await page.getByText("Loading session…", { exact: true })
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Loading session…",
|
||||
})
|
||||
.waitFor();
|
||||
const completed = page.waitForResponse((response) =>
|
||||
response.request().method() === "POST" &&
|
||||
@@ -611,9 +612,10 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
)
|
||||
);
|
||||
assertEquals(new URL(page.url()).pathname, "/dashboard");
|
||||
// 確認中のsubtreeは副作用の結果を保持したままhidden/inertになる。
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace display name").count(),
|
||||
0,
|
||||
await page.getByLabel("Workspace display name").isVisible(),
|
||||
false,
|
||||
);
|
||||
releaseSession();
|
||||
const link = page.getByRole("link", {
|
||||
@@ -630,7 +632,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
0,
|
||||
);
|
||||
const folder =
|
||||
`${root}/target/web-ux/entry-browser/confirmed-${colorScheme}-${width}`;
|
||||
`${root}/target/web-ux/t767-validation/entry-browser/confirmed-${colorScheme}-${width}`;
|
||||
await Deno.mkdir(folder, { recursive: true });
|
||||
const bytes = await page.screenshot({
|
||||
path: folder + "/viewport.png",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 本番build/client/shellだけを検証する合成HTTP/WebAuthn境界。
|
||||
// 実認証・署名・cookie安全性・policyの永続化は検証しない。本文/credentialは記録しない。
|
||||
// 実認証・署名・製品cookie設計・policyの永続化は検証しない。本文/credentialは記録しない。
|
||||
// cookie-sessionは実HTTPのMax-AgeをChromiumが失効させる境界だけを検証する。Backendの判定は合成。
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
|
||||
|
||||
@@ -22,6 +23,9 @@ type State = {
|
||||
signedIn: boolean;
|
||||
whoamiCalls: number;
|
||||
selfRegistrationEnabled: boolean;
|
||||
expired: boolean;
|
||||
expiresAt: string | null;
|
||||
cookieSession: boolean;
|
||||
};
|
||||
const sessions = new Map<string, State>();
|
||||
const mime: Record<string, string> = {
|
||||
@@ -62,7 +66,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const selected = url.searchParams.get("fixture");
|
||||
let id = request.headers.get("cookie")?.match(/auth-fixture=([\w-]+)/)?.[1];
|
||||
// routeごとに独立。クライアントgoto後も同じ合成sessionをwhoamiへ投影する。
|
||||
if (selected || !id || !sessions.has(id)) {
|
||||
if (
|
||||
(selected && sessions.get(id ?? "")?.mode !== selected) || !id ||
|
||||
!sessions.has(id)
|
||||
) {
|
||||
id = crypto.randomUUID();
|
||||
sessions.set(id, {
|
||||
mode: selected ?? "anonymous",
|
||||
@@ -72,13 +79,54 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
selected?.startsWith("addition-") === true,
|
||||
whoamiCalls: 0,
|
||||
selfRegistrationEnabled: selected !== "policy-disabled",
|
||||
expired: selected?.startsWith("session-expired") === true,
|
||||
expiresAt: null,
|
||||
cookieSession: false,
|
||||
});
|
||||
}
|
||||
const state = sessions.get(id)!;
|
||||
const { mode } = state;
|
||||
const sessionCookiePresent = request.headers.get("cookie")?.split(/;\s*/)
|
||||
.includes("auth-fixture-session=active") === true;
|
||||
if (state.cookieSession && !sessionCookiePresent) {
|
||||
state.signedIn = false;
|
||||
state.expired = true;
|
||||
}
|
||||
// 制御は同一browser contextの合成fixtureに限定。認証・cookieをJSで偽造しない。
|
||||
if (path === "/__ui_fixture/session") {
|
||||
if (request.method === "POST") {
|
||||
const { operation } = await request.json();
|
||||
if (operation === "arm-cookie") {
|
||||
state.signedIn = true;
|
||||
state.expired = false;
|
||||
state.cookieSession = true;
|
||||
state.expiresAt = new Date(Date.now() + 3000).toISOString();
|
||||
return Response.json({ expires_at: state.expiresAt }, {
|
||||
headers: {
|
||||
"set-cookie":
|
||||
"auth-fixture-session=active; Max-Age=3; Path=/; HttpOnly; SameSite=Strict",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (operation === "expire" || operation === "anonymous") {
|
||||
state.signedIn = false;
|
||||
state.expired = operation === "expire";
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
} else return new Response(null, { status: 400 });
|
||||
}
|
||||
return Response.json({
|
||||
session_cookie_present: sessionCookiePresent,
|
||||
whoami_calls: state.whoamiCalls,
|
||||
expired: state.expired,
|
||||
});
|
||||
}
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
let extra = "";
|
||||
if (mode.endsWith("-cancel")) {
|
||||
if (mode.endsWith("-cancel-once")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) { const original=navigator.credentials[method].bind(navigator.credentials); let first=true; Object.defineProperty(navigator.credentials,method,{configurable:true,value:async(...args)=>{if(first){first=false;throw new DOMException('Passkey cancelled','NotAllowedError')}return original(...args)}}); }`;
|
||||
} else if (mode.endsWith("-cancel")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`;
|
||||
} else if (mode.endsWith("-unsupported")) {
|
||||
@@ -116,6 +164,13 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
actor: state.signedIn
|
||||
? { ...user, auth_method: "browser_session" }
|
||||
: null,
|
||||
session: state.signedIn
|
||||
? {
|
||||
kind: "authenticated",
|
||||
expires_at: state.expiresAt ??
|
||||
new Date(Date.now() + 86_400_000).toISOString(),
|
||||
}
|
||||
: { kind: state.expired ? "expired" : "anonymous" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
@@ -160,7 +215,15 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
}
|
||||
if (path === "/api/auth/logout") {
|
||||
state.signedIn = false;
|
||||
return Response.json({ status: "logged_out" });
|
||||
state.expired = false;
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
return Response.json({ status: "logged_out" }, {
|
||||
headers: {
|
||||
"set-cookie":
|
||||
"auth-fixture-session=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/device-login/approve") {
|
||||
if (!state.signedIn) return new Response(null, { status: 401 });
|
||||
@@ -187,7 +250,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
if (mode === `${purpose}-expired` && stage === "complete") {
|
||||
if (purpose === "addition") state.signedIn = false;
|
||||
// challengeの401だけではbrowser sessionの失効を主張しない。
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
if (stage === "options") {
|
||||
@@ -197,6 +260,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
);
|
||||
}
|
||||
state.signedIn = true;
|
||||
state.expired = false;
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path === "/api/auth/activation/options") {
|
||||
@@ -204,6 +270,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
state.signedIn = true;
|
||||
state.expired = false;
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
return Response.json({ user });
|
||||
}
|
||||
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
/// <reference lib="dom" />
|
||||
// auth-shellの本番build/HTTP/WebAuthn fixtureと隔離contextを再利用する。
|
||||
// Max-Ageは本物のHTTP cookieをbrowserが消すことを観測するが、whoami分類は合成。
|
||||
// 実Serverのsession/marker/revoke判定の証明ではない。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import type { Browser, Page } from "playwright";
|
||||
|
||||
type Harness = {
|
||||
page: Page;
|
||||
mutations: { path: string; keys: string[] }[];
|
||||
go: (path: string, mode?: string, unavailableRoot?: boolean) => Promise<void>;
|
||||
};
|
||||
type Isolated = (
|
||||
browser: Browser,
|
||||
body: (harness: Harness) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
|
||||
export async function sessionNavigation(
|
||||
t: Deno.TestContext,
|
||||
browser: Browser,
|
||||
isolated: Isolated,
|
||||
origin: string,
|
||||
) {
|
||||
const step = (name: string, body: (harness: Harness) => Promise<void>) =>
|
||||
t.step(name, () => isolated(browser, body));
|
||||
const login = (page: Page) =>
|
||||
page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
.click();
|
||||
const at = (page: Page, target: string) =>
|
||||
page.waitForURL((url) =>
|
||||
url.origin === origin && url.pathname + url.search + url.hash === target
|
||||
);
|
||||
const loginTarget = (target: string) =>
|
||||
`/login?${new URLSearchParams({ returnTo: target })}`;
|
||||
const control = async (page: Page, operation: string) => {
|
||||
const response = await page.request.post(`${origin}/__ui_fixture/session`, {
|
||||
data: { operation },
|
||||
});
|
||||
assertEquals(response.status(), 200);
|
||||
return response;
|
||||
};
|
||||
const focus = (page: Page) =>
|
||||
page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
const noPrivateRequests = (page: Page) => {
|
||||
const paths: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (/^\/api\/(workspaces|w\/|hosting\/)/.test(path)) paths.push(path);
|
||||
});
|
||||
return paths;
|
||||
};
|
||||
for (
|
||||
const target of [
|
||||
"/w/team/tickets?filter=100%25#entry",
|
||||
"/w/team/tickets/%e6%9c%aa?filter=100%25#part%25",
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`expiry retains encoded URL data ${target}`,
|
||||
async ({ page, go }) => {
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.reload();
|
||||
await at(page, loginTarget(target));
|
||||
assertEquals(privateRequests, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"successful login retains percent query and fragment data",
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?filter=100%25#part%25";
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.waitFor();
|
||||
},
|
||||
);
|
||||
const protectedRoutes = [
|
||||
"/dashboard",
|
||||
"/account",
|
||||
"/hosting/settings",
|
||||
"/w/private/tickets",
|
||||
];
|
||||
for (const path of protectedRoutes) {
|
||||
await step(
|
||||
`fresh anonymous ${path} goes to public root before private loaders`,
|
||||
async ({ page, go, mutations }) => {
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
await go(`${path}?view=private#selected`);
|
||||
await at(page, "/");
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true }).waitFor();
|
||||
assertEquals(await page.locator(".app-shell").count(), 0);
|
||||
assertEquals(privateRequests, []);
|
||||
assertEquals(mutations, []);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
`expired ${path} preserves pathname query and fragment before private loaders`,
|
||||
async ({ page, go }) => {
|
||||
const target = `${path}?filter=a%2Fb&view=thread#selected`;
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(privateRequests, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
for (
|
||||
const [path, selector] of [
|
||||
["/", "h1"],
|
||||
["/login", "button[type=submit]"],
|
||||
["/register", "#registration-handle"],
|
||||
["/activate", "#activation-handle"],
|
||||
["/login/device?user_code=ABCD-EFGH", 'a[href^="/login?"]'],
|
||||
["/hosting/setup", "#host-setup-token"],
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`anonymous public entry ${path} does not redirect or load private data`,
|
||||
async ({ page, go }) => {
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
await go(path);
|
||||
// 固有controlのrenderまで待ち、単なるdocument load成功で済ませない。
|
||||
if (path === "/register") {
|
||||
await page.getByLabel("Display name", { exact: true }).waitFor();
|
||||
} else await page.locator(selector).first().waitFor();
|
||||
await at(page, path);
|
||||
assertEquals(privateRequests, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"expired login reload cancellation and retry keep the complete original URL",
|
||||
async ({ page, go, mutations }) => {
|
||||
const target = "/account?tab=keys&filter=a%2Fb#passkeys";
|
||||
await go(target, "session-expired-cancel-once");
|
||||
await at(page, loginTarget(target));
|
||||
await page.reload();
|
||||
await login(page);
|
||||
await page.getByRole("alert").filter({ hasText: /cancelled|timed out/i })
|
||||
.waitFor();
|
||||
await at(page, loginTarget(target));
|
||||
assertEquals(
|
||||
mutations.filter((m) => m.path.endsWith("/login/complete")).length,
|
||||
0,
|
||||
);
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.reload();
|
||||
await at(page, target);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
},
|
||||
);
|
||||
for (
|
||||
const mode of [
|
||||
"session-error",
|
||||
"session-malformed",
|
||||
"session-loading",
|
||||
"network-error",
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`protected ${mode} stays unknown without redirect or private loaders`,
|
||||
async ({ page, go }) => {
|
||||
if (mode === "network-error") {
|
||||
await page.route(
|
||||
"**/api/auth/whoami",
|
||||
(route) => route.abort("failed"),
|
||||
);
|
||||
}
|
||||
const privateRequests = noPrivateRequests(page);
|
||||
const target = "/account?tab=keys#passkeys";
|
||||
await go(target, mode);
|
||||
if (mode === "session-loading") {
|
||||
// 実要求がpendingであることを観測する。loadingの文言はloaderと画面で異なる。
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
} else await page.getByText(/Login status is unknown/i).waitFor();
|
||||
await at(page, target);
|
||||
assertEquals(privateRequests, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"protected unknown can reload and retry without losing query or fragment",
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#passkeys";
|
||||
let failed = true;
|
||||
await page.route(
|
||||
"**/api/auth/whoami",
|
||||
(route) => failed ? route.fulfill({ status: 503 }) : route.continue(),
|
||||
);
|
||||
await go(target, "account", true);
|
||||
await page.getByText(/Login status is unknown/i).waitFor();
|
||||
await at(page, target);
|
||||
failed = false;
|
||||
await page.reload();
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await at(page, target);
|
||||
},
|
||||
);
|
||||
for (const active of [false, true]) {
|
||||
await step(
|
||||
`real HTTP Max-Age cookie disappears and ${
|
||||
active ? "active expiry" : "reload"
|
||||
} returns through login`,
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#after-cookie-expiry";
|
||||
await go(target, "account");
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
const armed = await control(page, "arm-cookie");
|
||||
assert(armed.headers()["set-cookie"].includes("Max-Age=3"));
|
||||
const cookie = (await page.context().cookies()).find((c) =>
|
||||
c.name === "auth-fixture-session"
|
||||
);
|
||||
assert(cookie?.httpOnly);
|
||||
assert(cookie.expires > Date.now() / 1000);
|
||||
if (active) {
|
||||
// whoamiのexpires_atを製品timerへ渡す。テスト側clockの前進やcookie削除はしない。
|
||||
const refreshed = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await focus(page);
|
||||
const wire = await (await refreshed).json();
|
||||
assertEquals(wire.session.kind, "authenticated");
|
||||
assertEquals(
|
||||
wire.session.expires_at,
|
||||
(await armed.json()).expires_at,
|
||||
);
|
||||
}
|
||||
// 固定sleepではなくHTTP要求にcookieが付かなくなる事実で同期する。
|
||||
await page.evaluate(async () => {
|
||||
const deadline = performance.now() + 10_000;
|
||||
do {
|
||||
const observation = await (await fetch("/__ui_fixture/session", {
|
||||
cache: "no-store",
|
||||
})).json();
|
||||
if (observation.session_cookie_present === false) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} while (performance.now() < deadline);
|
||||
throw new Error("Primary session cookie did not naturally expire");
|
||||
});
|
||||
assertEquals(
|
||||
(await page.context().cookies()).some((c) =>
|
||||
c.name === "auth-fixture-session"
|
||||
),
|
||||
false,
|
||||
);
|
||||
// 長寿命の識別補助は残る。全消去とは異なる条件を作ったことも確認する。
|
||||
assert(
|
||||
(await page.context().cookies()).some((c) =>
|
||||
c.name === "auth-fixture"
|
||||
),
|
||||
);
|
||||
if (!active) await page.reload();
|
||||
await at(page, loginTarget(target));
|
||||
await page.reload();
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"concurrent API 401 and repeated session checks cannot overwrite the original expired return URL",
|
||||
async ({ page, go }) => {
|
||||
const target = "/w/private/tickets?view=thread#concurrent-expiry";
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => release = resolve);
|
||||
const statuses: number[] = [];
|
||||
// 本番Workspace loaderの並列requestを止める。window.fetchの呼出しだけで
|
||||
// 製品API clientの401再照会を通したことにはしない。
|
||||
await page.route("**/api/w/private/**", async (route) => {
|
||||
await held;
|
||||
statuses.push(401);
|
||||
await route.fulfill({ status: 401 });
|
||||
});
|
||||
const requests = Promise.all([
|
||||
page.waitForRequest((r) =>
|
||||
new URL(r.url()).pathname === "/api/w/private/workspace"
|
||||
),
|
||||
page.waitForRequest((r) =>
|
||||
new URL(r.url()).pathname === "/api/w/private/repositories"
|
||||
),
|
||||
]);
|
||||
try {
|
||||
await go(target, "account");
|
||||
await requests;
|
||||
await control(page, "expire");
|
||||
release();
|
||||
// focus等の外部契機なしに、同時401がwhoami再照会へ接続すること。
|
||||
await at(page, loginTarget(target));
|
||||
assert(statuses.length >= 2);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await focus(page);
|
||||
await (await response).finished();
|
||||
}
|
||||
await at(page, loginTarget(target));
|
||||
await page.unroute("**/api/w/private/**");
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"delayed pre-logout authenticated response cannot restore private UI or an old return URL",
|
||||
async ({ page, go }) => {
|
||||
await go("/account?tab=keys#stale", "account");
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.waitFor();
|
||||
let started!: () => void;
|
||||
let failed!: (error: unknown) => void;
|
||||
let release!: () => void;
|
||||
const requested = new Promise<void>((resolve, reject) => {
|
||||
started = resolve;
|
||||
failed = reject;
|
||||
});
|
||||
const held = new Promise<void>((resolve) => release = resolve);
|
||||
let first = true;
|
||||
await page.route("**/api/auth/whoami", async (route) => {
|
||||
if (!first) return route.continue();
|
||||
first = false;
|
||||
try {
|
||||
const old = await route.fetch({ timeout: 5000 });
|
||||
started();
|
||||
await held;
|
||||
await route.fulfill({
|
||||
response: old,
|
||||
headers: { ...old.headers(), "x-auth-fixture-held": "true" },
|
||||
});
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const observed = page.waitForRequest((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await focus(page);
|
||||
await observed;
|
||||
await requested;
|
||||
// 他tabの本物の製品logoutでgeneration/storage eventも通す。
|
||||
const other = await page.context().newPage();
|
||||
try {
|
||||
await other.goto(`${origin}/account`);
|
||||
await other.getByRole("button", { name: "Log out", exact: true })
|
||||
.click();
|
||||
await at(other, "/");
|
||||
const delivered = page.waitForResponse((r) =>
|
||||
r.headers()["x-auth-fixture-held"] === "true"
|
||||
);
|
||||
release();
|
||||
await (await delivered).finished();
|
||||
// 古い応答を実際に製品へ届け、readSession/renderのtask境界まで進める。
|
||||
await page.evaluate(() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => resolve())
|
||||
)
|
||||
)
|
||||
);
|
||||
await at(page, "/");
|
||||
assertEquals(new URL(page.url()).searchParams.has("returnTo"), false);
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
await page.goto(`${origin}/account`);
|
||||
await at(page, "/");
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"challenge 401 leaves an authenticated account in place and permits retry",
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#challenge";
|
||||
await go(target, "addition-expired");
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
await at(page, target);
|
||||
assertEquals(
|
||||
(await (await page.request.get(`${origin}/api/auth/whoami`)).json())
|
||||
.session.kind,
|
||||
"authenticated",
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).isEnabled(),
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"explicit logout and complete browser storage removal classify as anonymous, not expired",
|
||||
async ({ page, go }) => {
|
||||
await go("/account", "account");
|
||||
await control(page, "arm-cookie");
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||
await at(page, "/");
|
||||
const wire = await (await page.request.get(`${origin}/api/auth/whoami`))
|
||||
.json();
|
||||
assertEquals(wire, { actor: null, session: { kind: "anonymous" } });
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await page.context().clearCookies();
|
||||
await page.goto(`${origin}/account?tab=keys#cleared`);
|
||||
await at(page, "/");
|
||||
assertEquals(new URL(page.url()).searchParams.has("returnTo"), false);
|
||||
},
|
||||
);
|
||||
for (const event of ["pageshow", "visibilitychange"] as const) {
|
||||
await step(
|
||||
`${event} rechecks active session and preserves expired return URL`,
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#lifecycle";
|
||||
await go(target, "account");
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await control(page, "expire");
|
||||
await page.evaluate((event) => {
|
||||
if (event === "visibilitychange") {
|
||||
if (document.hidden) {
|
||||
throw new Error("Expected a visible test page");
|
||||
}
|
||||
document.dispatchEvent(new Event(event));
|
||||
} else window.dispatchEvent(new Event(event));
|
||||
}, event);
|
||||
await at(page, loginTarget(target));
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"forget expired session clears returnTo and fragment before a later login",
|
||||
async ({ page, go, mutations }) => {
|
||||
await go("/account?tab=keys#forgotten", "session-expired");
|
||||
await page.getByRole("button", {
|
||||
name: "Forget this session",
|
||||
exact: true,
|
||||
}).click();
|
||||
await at(page, "/login");
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
(await (await page.request.get(`${origin}/api/auth/whoami`)).json())
|
||||
.session.kind,
|
||||
"anonymous",
|
||||
);
|
||||
assertEquals(
|
||||
mutations.filter((m) => m.path === "/api/auth/logout").length,
|
||||
1,
|
||||
);
|
||||
await login(page);
|
||||
await at(page, "/dashboard");
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"forget session is allowed on unknown login and never claims authenticated success",
|
||||
async ({ page, go, mutations }) => {
|
||||
await go(
|
||||
"/login?returnTo=%2Faccount%3Ftab%3Dkeys%23forgotten#entry",
|
||||
"session-error",
|
||||
);
|
||||
await page.getByText(/Login status is unknown/i).waitFor();
|
||||
await page.getByRole("button", {
|
||||
name: "Forget this session",
|
||||
exact: true,
|
||||
}).click();
|
||||
await at(page, "/login");
|
||||
await page.getByText(/Login status is unknown/i).waitFor();
|
||||
assertEquals(
|
||||
mutations.filter((m) => m.path === "/api/auth/logout").length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: /^Continue to/ }).count(),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"other-tab logout clears a pending expired login return URL",
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#other-tab-logout";
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
const other = await page.context().newPage();
|
||||
try {
|
||||
await other.goto(`${origin}${loginTarget(target)}`);
|
||||
await other.getByRole("button", {
|
||||
name: "Forget this session",
|
||||
exact: true,
|
||||
}).click();
|
||||
await at(other, "/login");
|
||||
await at(page, "/login");
|
||||
await login(page);
|
||||
await at(page, "/dashboard");
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
for (const status of [403, 404]) {
|
||||
await step(
|
||||
`post-login resource ${status} remains a resource failure, never a relogin loop`,
|
||||
async ({ page, go }) => {
|
||||
const target = "/dashboard?selected=missing#resource";
|
||||
let whoamiCalls = 0;
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/auth/whoami") {
|
||||
whoamiCalls++;
|
||||
}
|
||||
});
|
||||
await page.route(
|
||||
"**/api/workspaces?*",
|
||||
(route) => route.fulfill({ status }),
|
||||
);
|
||||
await go(target, "session-expired");
|
||||
await at(page, loginTarget(target));
|
||||
await login(page);
|
||||
await at(page, target);
|
||||
await page.getByText(/Workspaces could not be loaded/).waitFor();
|
||||
const before = whoamiCalls;
|
||||
await page.reload();
|
||||
await page.getByText(/Workspaces could not be loaded/).waitFor();
|
||||
await at(page, target);
|
||||
// reloadごとの有限なreadで、403/404をexpiredへ変換してloopしない。
|
||||
assert(whoamiCalls - before <= 4);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user