feat(auth): separate anonymous entry from expired session recovery
This commit is contained in:
@@ -19,6 +19,8 @@ sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
yoi-workspace-server.workspace = true
|
||||
server-api.workspace = true
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/// <reference lib="dom" />
|
||||
// WorkerE2eEnvironment所有の実Backendへ全APIをproxyし、本番static shellを配信する。
|
||||
// fixture-serverと同じSPA配信方式。製品APIのwhoami、認証、認可、Set-Cookieは置換しない。
|
||||
// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。
|
||||
// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { extname, join, normalize } from "@std/path";
|
||||
import { type Browser, type BrowserContext, chromium } from "playwright";
|
||||
|
||||
const [backend, origin, build, handle] = Deno.args;
|
||||
if (!backend || !origin || !build || !handle) {
|
||||
throw new Error(
|
||||
"Expected isolated Backend URL, browser origin, production build, handle",
|
||||
);
|
||||
}
|
||||
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".woff2": "font/woff2",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
const server = Deno.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(new URL(origin).port),
|
||||
onListen() {},
|
||||
}, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/__e2e/expired-session" && request.method === "POST") {
|
||||
if (request.headers.get("origin") !== origin) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
// Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。
|
||||
const fixture = await bridge("expired-session", {}) as {
|
||||
set_cookies: string[];
|
||||
};
|
||||
const headers = new Headers({ "cache-control": "no-store" });
|
||||
for (const cookie of fixture.set_cookies) {
|
||||
headers.append("set-cookie", cookie);
|
||||
}
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
if (
|
||||
url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET"
|
||||
) {
|
||||
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
|
||||
const present = request.headers.get("cookie")?.split(";").some((pair) =>
|
||||
pair.trim().split("=", 1)[0] === "yoi_workspace_session"
|
||||
) ?? false;
|
||||
return Response.json(present, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
// OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。
|
||||
return await fetch(new URL(url.pathname + url.search, backend), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: ["GET", "HEAD"].includes(request.method)
|
||||
? undefined
|
||||
: await request.arrayBuffer(),
|
||||
redirect: "manual",
|
||||
});
|
||||
}
|
||||
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
|
||||
if (relative.startsWith("..")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
let file = join(build, relative);
|
||||
try {
|
||||
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||
return new Response(await Deno.readFile(file), {
|
||||
headers: {
|
||||
"content-type": mime[extname(file)] ?? "application/octet-stream",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
if (url.pathname.startsWith("/_app/")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return new Response(await Deno.readFile(join(build, "index.html")), {
|
||||
headers: { "content-type": mime[".html"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。
|
||||
const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream())
|
||||
.getReader();
|
||||
let pending = "";
|
||||
async function bridge(operation: string, options: unknown): Promise<unknown> {
|
||||
await Deno.stdout.write(
|
||||
new TextEncoder().encode(JSON.stringify({ operation, options }) + "\n"),
|
||||
);
|
||||
while (!pending.includes("\n")) {
|
||||
const { done, value } = await input.read();
|
||||
if (done) throw new Error("ResidentAuthenticator disconnected");
|
||||
pending += value;
|
||||
if (pending.length > 1024 * 1024) throw new Error("Oversized credential");
|
||||
}
|
||||
const newline = pending.indexOf("\n");
|
||||
const credential = JSON.parse(pending.slice(0, newline));
|
||||
pending = pending.slice(newline + 1);
|
||||
return credential;
|
||||
}
|
||||
let browser: Browser | undefined;
|
||||
let context: BrowserContext | undefined;
|
||||
const terminate = () => {
|
||||
void (async () => {
|
||||
await browser?.close();
|
||||
await server.shutdown();
|
||||
Deno.exit(143);
|
||||
})();
|
||||
};
|
||||
Deno.addSignalListener("SIGTERM", terminate);
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, timeout: 15_000 });
|
||||
context = await browser.newContext();
|
||||
context.setDefaultTimeout(10_000);
|
||||
await context.route(
|
||||
"**/*",
|
||||
(route) =>
|
||||
new URL(route.request().url()).origin === origin
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
await context.exposeBinding(
|
||||
"residentCredential",
|
||||
(_source, operation: string, options: unknown) =>
|
||||
bridge(operation, options),
|
||||
);
|
||||
await context.addInitScript(() => {
|
||||
const encode = (value: unknown): unknown => {
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(value)))
|
||||
.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(encode);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, encode(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const bytes = (value: string) => {
|
||||
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
||||
return Uint8Array.from(
|
||||
atob(base64),
|
||||
(character) => character.charCodeAt(0),
|
||||
).buffer;
|
||||
};
|
||||
for (const operation of ["create", "get"] as const) {
|
||||
Object.defineProperty(navigator.credentials, operation, {
|
||||
configurable: true,
|
||||
value: async (
|
||||
options: CredentialCreationOptions & CredentialRequestOptions,
|
||||
) => {
|
||||
const bridge = (window as unknown as {
|
||||
residentCredential: (
|
||||
operation: string,
|
||||
options: unknown,
|
||||
) => Promise<{
|
||||
id: string;
|
||||
rawId: string;
|
||||
response: Record<string, string | string[] | null>;
|
||||
extensions?: unknown;
|
||||
}>;
|
||||
}).residentCredential;
|
||||
const wire = await bridge(operation, {
|
||||
publicKey: encode(options.publicKey),
|
||||
});
|
||||
const response = Object.fromEntries(
|
||||
Object.entries(wire.response).map((
|
||||
[key, value],
|
||||
) => [key, typeof value === "string" ? bytes(value) : value]),
|
||||
);
|
||||
return {
|
||||
id: wire.id,
|
||||
rawId: bytes(wire.rawId),
|
||||
type: "public-key",
|
||||
getClientExtensionResults: () => wire.extensions ?? {},
|
||||
response: {
|
||||
...response,
|
||||
getTransports: () => wire.response.transports ?? [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const at = async (path: string) => {
|
||||
await page.waitForURL((url) =>
|
||||
url.origin === origin && url.pathname + url.search + url.hash === path
|
||||
);
|
||||
console.error(`navigation-ready: ${new URL(path, origin).pathname}`);
|
||||
};
|
||||
const whoami = async () => {
|
||||
const response = await page.request.get(`${origin}/api/auth/whoami`);
|
||||
assertEquals(response.status(), 200);
|
||||
return await response.json();
|
||||
};
|
||||
const account = "/account?view=passkeys&filter=a%2Fb#selected";
|
||||
const privateRequests: string[] = [];
|
||||
const observePrivate = (request: { url(): string }) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (/^\/api\/(workspaces|w\/|hosting\/)/.test(path)) {
|
||||
privateRequests.push(path);
|
||||
}
|
||||
};
|
||||
page.on("request", observePrivate);
|
||||
await page.goto(origin + account);
|
||||
await at("/");
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true }).waitFor();
|
||||
assertEquals((await whoami()).session, { kind: "anonymous" });
|
||||
assertEquals(
|
||||
privateRequests,
|
||||
[],
|
||||
"anonymous gate must run before private loaders",
|
||||
);
|
||||
page.off("request", observePrivate);
|
||||
|
||||
await page.goto(
|
||||
`${origin}/register?${new URLSearchParams({ returnTo: account })}`,
|
||||
);
|
||||
await page.getByLabel("Account ID (handle)", { exact: true }).fill(handle);
|
||||
await page.getByLabel("Display name", { exact: true }).fill(
|
||||
"Real browser E2E",
|
||||
);
|
||||
const registration = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname ===
|
||||
"/api/auth/passkeys/registration/complete"
|
||||
);
|
||||
await page.getByRole("button", { name: "Register new account", exact: true })
|
||||
.click();
|
||||
assertEquals((await registration).status(), 200);
|
||||
await at(account);
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||
const identity = await whoami();
|
||||
assertEquals(identity.actor?.handle, handle);
|
||||
assertEquals(identity.session.kind, "authenticated");
|
||||
assert(typeof identity.session.expires_at === "string");
|
||||
const cookies = await context.cookies(origin);
|
||||
const session = cookies.find((cookie) =>
|
||||
cookie.name === "yoi_workspace_session"
|
||||
);
|
||||
const marker = cookies.find((cookie) =>
|
||||
cookie.name === "yoi_workspace_session_expiry"
|
||||
);
|
||||
assert(session && marker, "Backend must issue both cookies");
|
||||
for (const cookie of [session, marker]) {
|
||||
assert(cookie.httpOnly);
|
||||
assertEquals(cookie.sameSite, "Lax");
|
||||
}
|
||||
const seconds = (Date.parse(identity.session.expires_at) / 1000) -
|
||||
Date.now() / 1000;
|
||||
assert(seconds > 14 * 86400 - 60 && seconds <= 14 * 86400);
|
||||
assert(
|
||||
Math.abs(session.expires - Date.parse(identity.session.expires_at) / 1000) <
|
||||
2,
|
||||
);
|
||||
assert(Math.abs(marker.expires - session.expires - 7 * 86400) < 2);
|
||||
await page.goto(origin + account);
|
||||
await at(account);
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||
await page.reload();
|
||||
await at(account);
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||
|
||||
// 明示logoutは実Serverのrevoke + 両cookie削除を通る。
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||
await at("/");
|
||||
assertEquals((await whoami()).session, { kind: "anonymous" });
|
||||
const afterLogout = await context.cookies(origin);
|
||||
assert(
|
||||
!afterLogout.some((cookie) =>
|
||||
[session.name, marker.name].includes(cookie.name)
|
||||
),
|
||||
);
|
||||
const revoked = await page.request.get(`${origin}/api/auth/whoami`, {
|
||||
headers: {
|
||||
cookie:
|
||||
`${session.name}=${session.value}; ${marker.name}=${marker.value}`,
|
||||
},
|
||||
});
|
||||
assertEquals((await revoked.json()).session, { kind: "anonymous" });
|
||||
await page.goto(origin + account);
|
||||
await at("/");
|
||||
|
||||
// 実discoverable loginで、確認済みの認証成功後のみpathname/query/fragmentへ復帰する。
|
||||
await page.goto(
|
||||
`${origin}/login?${new URLSearchParams({ returnTo: account })}`,
|
||||
);
|
||||
await page.reload();
|
||||
const login = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/auth/passkeys/login/complete"
|
||||
);
|
||||
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
.click();
|
||||
assertEquals((await login).status(), 200);
|
||||
await at(account);
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||
assertEquals((await whoami()).actor?.handle, handle);
|
||||
|
||||
// 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、
|
||||
// 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。
|
||||
const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
|
||||
const catalogResponse = () =>
|
||||
page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/workspaces" &&
|
||||
response.request().method() === "GET"
|
||||
);
|
||||
const initialCatalog = catalogResponse();
|
||||
await page.goto(origin + expiredTarget);
|
||||
assertEquals((await initialCatalog).status(), 200);
|
||||
await at(expiredTarget);
|
||||
await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor();
|
||||
const issuedStatus = await page.evaluate(async () => {
|
||||
const response = await fetch("/__e2e/expired-session", {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
});
|
||||
return response.status;
|
||||
});
|
||||
assertEquals(issuedStatus, 204);
|
||||
const initiallyPresent = await page.evaluate(async () =>
|
||||
await (await fetch("/__e2e/primary-cookie-present", { cache: "no-store" }))
|
||||
.json()
|
||||
);
|
||||
assertEquals(
|
||||
initiallyPresent,
|
||||
true,
|
||||
"short-lived primary must first reach Chromium",
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const deadline = performance.now() + 10_000;
|
||||
do {
|
||||
const response = await fetch("/__e2e/primary-cookie-present", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.ok && await response.json() === false) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} while (performance.now() < deadline);
|
||||
throw new Error("Primary session cookie did not naturally expire");
|
||||
});
|
||||
// 値をassertionへ渡さず、cookie jarにもprimaryが消えmarkerが残ることを確認する。
|
||||
const expiredCookieNames = (await context.cookies(origin)).map((cookie) =>
|
||||
cookie.name
|
||||
);
|
||||
assert(!expiredCookieNames.includes(session.name));
|
||||
assert(expiredCookieNames.includes(marker.name));
|
||||
assertEquals(await whoami(), {
|
||||
actor: null,
|
||||
session: { kind: "expired" },
|
||||
});
|
||||
privateRequests.length = 0;
|
||||
page.on("request", observePrivate);
|
||||
const expiredWhoami = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await page.reload();
|
||||
const expiredResponse = await expiredWhoami;
|
||||
assertEquals(expiredResponse.status(), 200);
|
||||
// SPAがloginへ移った後の旧document bodyをCDPから読み直さない。
|
||||
// 分類本文は直前/復帰待ち後にも同じ実Backendから取得し、browser要求はstatusを観測する。
|
||||
const expiredLogin = `/login?${new URLSearchParams({
|
||||
returnTo: expiredTarget,
|
||||
})}`;
|
||||
await at(expiredLogin);
|
||||
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
privateRequests,
|
||||
[],
|
||||
"expired reload must gate the real private catalog loader",
|
||||
);
|
||||
assertEquals(await whoami(), { actor: null, session: { kind: "expired" } });
|
||||
await page.reload();
|
||||
await at(expiredLogin);
|
||||
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
privateRequests,
|
||||
[],
|
||||
"login reload must not lose or privately load returnTo",
|
||||
);
|
||||
page.off("request", observePrivate);
|
||||
const recoveredCatalog = catalogResponse();
|
||||
const relogin = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/auth/passkeys/login/complete"
|
||||
);
|
||||
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
.click();
|
||||
assertEquals((await relogin).status(), 200);
|
||||
await at(expiredTarget);
|
||||
assertEquals((await recoveredCatalog).status(), 200);
|
||||
await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor();
|
||||
const recovered = await whoami();
|
||||
assertEquals(recovered.session.kind, "authenticated");
|
||||
assertEquals(recovered.actor?.user_id, identity.actor.user_id);
|
||||
assertEquals(recovered.actor?.handle, handle);
|
||||
await page.goto(origin + account);
|
||||
await at(account);
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||
|
||||
// fixtureでprimaryだけを除去。Server時計は進めていないため、markerのみでは
|
||||
// authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。
|
||||
await context.clearCookies({ name: session.name });
|
||||
const partial = await whoami();
|
||||
assertEquals(partial, { actor: null, session: { kind: "anonymous" } });
|
||||
await page.reload();
|
||||
await at("/");
|
||||
await context.clearCookies();
|
||||
await page.goto(origin + account);
|
||||
await at("/");
|
||||
assertEquals((await whoami()).session, { kind: "anonymous" });
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context?.close();
|
||||
await browser?.close();
|
||||
await server.shutdown();
|
||||
Deno.removeSignalListener("SIGTERM", terminate);
|
||||
}
|
||||
await Deno.stdout.write(new TextEncoder().encode('{"operation":"done"}\n'));
|
||||
// 親はstdout EOFで完了を確定する。stdin streamのprefetch取消を待つと相互待ちになる。
|
||||
Deno.exit(0);
|
||||
@@ -1,11 +1,12 @@
|
||||
//! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、
|
||||
//! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、
|
||||
//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(browser UI/OS認証器は対象外)。
|
||||
//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(OS/物理認証器は対象外)。
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
|
||||
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
|
||||
//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
||||
//! `/`・`/login`・`/dashboard`のstatic shellは対象外。実Server CLIにはstatic assets設定経路がなく、
|
||||
//! shell表示/browser遷移は本番routerのin-process検証やbrowser側の合成HTTP検証と区別する。
|
||||
//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
||||
//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが
|
||||
//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
|
||||
//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。
|
||||
|
||||
use std::error::Error;
|
||||
use std::fs::OpenOptions;
|
||||
@@ -29,6 +30,9 @@ mod resident_model;
|
||||
use resident_model::ResidentAuthenticator;
|
||||
use yoi_e2e::WorkerE2eEnvironment;
|
||||
|
||||
#[path = "auth_passkeys/browser_sessions.rs"]
|
||||
mod browser_sessions;
|
||||
|
||||
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
|
||||
const BROWSER_ORIGIN: &str = "http://localhost:5173";
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! 実Backend・本番static shell・Chromium cookie jarの接続を検証する。
|
||||
//! navigator.credentialsだけを共有ResidentAuthenticatorの署名へ接続する。
|
||||
//! 期限切れケースは実登録userへ公開store APIで固定expiry sessionを作り、
|
||||
//! 本番cookie生成関数の短命primary/猶予markerをfixture HTTPで配信する。
|
||||
//! SQL直書き・resolver/APIの置換・製品clockの追加はしない。
|
||||
//! 実発行14日cookieの属性/DTOと短命fixtureの自然消失/復帰を分けて検証する。
|
||||
//! OS/物理認証器と14日経過したServer時計は検証しない。
|
||||
//! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite:
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored`
|
||||
//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
use yoi_workspace_server::auth::{
|
||||
SESSION_EXPIRY_GRACE_SECONDS, SessionCookiePolicy, mint_secret, new_id,
|
||||
session_marker_cookie_name, session_set_cookie, token_hash,
|
||||
};
|
||||
use yoi_workspace_server::store::{BrowserSessionRecord, ControlPlaneStore, SqliteWorkspaceStore};
|
||||
|
||||
// 戻り値はprivate IPC→fixture HTTPのSet-Cookieだけに使い、assert/debug/artifactへ出さない。
|
||||
fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result<Value> {
|
||||
// default環境や任意pathへ接続せず、このenvironmentが起動した既存DBだけを開く。
|
||||
let database = environment
|
||||
.server_data_dir
|
||||
.join("server.db")
|
||||
.canonicalize()?;
|
||||
if !database.starts_with(environment.root.canonicalize()?) {
|
||||
return Err("expired-session fixture DB is outside its environment".into());
|
||||
}
|
||||
let store = SqliteWorkspaceStore::open(database)?;
|
||||
let user = store
|
||||
.get_user_by_handle(&environment.account_handle)?
|
||||
.ok_or("browser registration must create the fixture user first")?;
|
||||
let token = mint_secret("yoi_sess");
|
||||
let hash = token_hash(&token);
|
||||
let now = chrono::Utc::now();
|
||||
// Server時計を動かさず、既に期限切れだが実猶予内のrecordを公開store APIで保存する。
|
||||
store.create_browser_session(&BrowserSessionRecord {
|
||||
session_id: new_id("session"),
|
||||
token_hash: hash.clone(),
|
||||
user_id: user.user_id,
|
||||
created_at: (now - chrono::Duration::days(14) - chrono::Duration::seconds(60)).to_rfc3339(),
|
||||
expires_at: (now - chrono::Duration::seconds(60)).to_rfc3339(),
|
||||
revoked_at: None,
|
||||
})?;
|
||||
let policy = SessionCookiePolicy {
|
||||
cookie_name: "yoi_workspace_session",
|
||||
path: "/",
|
||||
domain: None,
|
||||
secure: false,
|
||||
};
|
||||
let marker_name = session_marker_cookie_name(policy.cookie_name);
|
||||
Ok(json!({"set_cookies": [
|
||||
session_set_cookie(policy, &token, 2),
|
||||
session_set_cookie(SessionCookiePolicy {
|
||||
cookie_name: &marker_name,
|
||||
..policy
|
||||
}, &hash, SESSION_EXPIRY_GRACE_SECONDS - 60),
|
||||
]}))
|
||||
}
|
||||
|
||||
struct BrowserProcess(Option<Child>);
|
||||
impl BrowserProcess {
|
||||
fn child(&mut self) -> &mut Child {
|
||||
self.0.as_mut().expect("browser process retained")
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserProcess {
|
||||
fn drop(&mut self) {
|
||||
let Some(mut child) = self.0.take() else {
|
||||
return;
|
||||
};
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
let (send, receive) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _ = send.send(child.wait());
|
||||
});
|
||||
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = waiter.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires production Web assets, Deno and Playwright Chromium"]
|
||||
fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result
|
||||
{
|
||||
// Backend/process/DBの所有は既存harnessに限定する。Web buildは呼出元が用意する。
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let build = root.join("web/workspace/build");
|
||||
if !build.join("index.html").is_file() {
|
||||
return Err("build web/workspace production assets before selecting browser E2E".into());
|
||||
}
|
||||
let mut environment = WorkerE2eEnvironment::new()?;
|
||||
let reservation = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = reservation.local_addr()?.port();
|
||||
let origin = format!("http://localhost:{port}");
|
||||
std::fs::write(
|
||||
&environment.server_config,
|
||||
format!("[browser]\npublic_url = \"{origin}\"\n"),
|
||||
)?;
|
||||
environment.start_empty_backend()?;
|
||||
let admin =
|
||||
environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?;
|
||||
let bearer = admin["access_token"]
|
||||
.as_str()
|
||||
.ok_or("missing setup token")?;
|
||||
let base = environment
|
||||
.server_url
|
||||
.as_deref()
|
||||
.ok_or("missing Backend URL")?;
|
||||
success(
|
||||
http_client()?
|
||||
.put(format!("{base}/api/hosting/settings"))
|
||||
.bearer_auth(bearer)
|
||||
.json(&json!({"self_registration_enabled": true}))
|
||||
.send()?,
|
||||
)?;
|
||||
drop(reservation);
|
||||
let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?;
|
||||
let mut process = BrowserProcess(Some(
|
||||
Command::new("deno")
|
||||
.current_dir(&root)
|
||||
.args([
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--no-lock",
|
||||
"--allow-env",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"--allow-write",
|
||||
"--allow-run",
|
||||
"--allow-sys",
|
||||
"tests/e2e/browser/auth_sessions.ts",
|
||||
base,
|
||||
&origin,
|
||||
build.to_str().ok_or("non-UTF8 build path")?,
|
||||
&environment.account_handle,
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(stderr)
|
||||
.spawn()?,
|
||||
));
|
||||
let stdout = process
|
||||
.child()
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or("missing browser stdout")?;
|
||||
let mut stdin = process
|
||||
.child()
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or("missing browser stdin")?;
|
||||
let (send, receive) = mpsc::sync_channel(4);
|
||||
// bounded NDJSONは実WebAuthn options/署名とfixture cookieを運び、artifactには記録しない。
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.by_ref().take(1024 * 1024).read_line(&mut line) {
|
||||
Ok(0) => break,
|
||||
Ok(_) if line.ends_with('\n') => {
|
||||
if send.send(Ok(line)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = send.send(Err("invalid or oversized browser IPC"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
let mut authenticator = ResidentAuthenticator::new(true);
|
||||
let mut completed = false;
|
||||
loop {
|
||||
let message = match receive.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
||||
{
|
||||
Ok(message) => message.map_err(std::io::Error::other)?,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
Err(_) => return Err("browser E2E timed out; see browser.stderr.log".into()),
|
||||
};
|
||||
let request: Value = serde_json::from_str(&message)?;
|
||||
let credential = match request["operation"].as_str() {
|
||||
Some("create") => serde_json::to_value(authenticator.do_registration(
|
||||
origin.parse()?,
|
||||
serde_json::from_value(request["options"].clone())?,
|
||||
)?)?,
|
||||
Some("get") => serde_json::to_value(authenticator.do_authentication(
|
||||
origin.parse()?,
|
||||
serde_json::from_value(request["options"].clone())?,
|
||||
)?)?,
|
||||
Some("expired-session") => expired_session_fixture(&environment)?,
|
||||
Some("done") => {
|
||||
completed = true;
|
||||
continue;
|
||||
}
|
||||
_ => return Err("unexpected browser IPC operation".into()),
|
||||
};
|
||||
serde_json::to_writer(&mut stdin, &credential)?;
|
||||
writeln!(stdin)?;
|
||||
stdin.flush()?;
|
||||
}
|
||||
reader.join().map_err(|_| "browser reader panicked")?;
|
||||
assert!(
|
||||
completed,
|
||||
"browser did not complete; see browser.stderr.log"
|
||||
);
|
||||
assert!(
|
||||
process.child().wait()?.success(),
|
||||
"browser E2E failed; see browser.stderr.log"
|
||||
);
|
||||
drop(process);
|
||||
let report = environment.cleanup()?;
|
||||
assert!(report.cleanup_success);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user