Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions. Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
407 lines
15 KiB
TypeScript
407 lines
15 KiB
TypeScript
/// <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 { serveProductionShell } from "./static_shell.ts";
|
|
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",
|
|
);
|
|
}
|
|
const server = await serveProductionShell(
|
|
backend,
|
|
origin,
|
|
build,
|
|
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" },
|
|
});
|
|
}
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
// 逐次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,
|
|
authentication_reference: 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,
|
|
authentication_reference: 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,
|
|
authentication_reference: 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);
|