fix(auth): confirm passkey operations and preserve safe legacy migration
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.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
- [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。
|
||||
- [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。
|
||||
- [`WorkerSessionSubscription`]: 公開Worker WebSocketの初期full Snapshot照合と停止確認。モデル呼出しは行わない。
|
||||
- `tests/auth_passkeys/native_migration.rs`・`browser/native_passkey_migration.ts`: 実Backend/本番shell/Chromium native CTAP2の旧鍵移行。fixture境界・未確認範囲・明示選択コマンドはmoduleに置く。通常suiteやsoftware authenticatorの成功で代替しない。
|
||||
|
||||
## 固定Workerの段階起動
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。
|
||||
// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { extname, join, normalize } from "@std/path";
|
||||
import { serveProductionShell } from "./static_shell.ts";
|
||||
import { type Browser, type BrowserContext, chromium } from "playwright";
|
||||
|
||||
const [backend, origin, build, handle] = Deno.args;
|
||||
@@ -13,82 +13,43 @@ if (!backend || !origin || !build || !handle) {
|
||||
"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 });
|
||||
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 });
|
||||
}
|
||||
// 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);
|
||||
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 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"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。
|
||||
const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream())
|
||||
@@ -313,7 +274,8 @@ try {
|
||||
|
||||
// 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 expiredTarget =
|
||||
"/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
|
||||
const catalogResponse = () =>
|
||||
page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/workspaces" &&
|
||||
@@ -360,6 +322,7 @@ try {
|
||||
assert(expiredCookieNames.includes(marker.name));
|
||||
assertEquals(await whoami(), {
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
});
|
||||
privateRequests.length = 0;
|
||||
@@ -383,7 +346,11 @@ try {
|
||||
[],
|
||||
"expired reload must gate the real private catalog loader",
|
||||
);
|
||||
assertEquals(await whoami(), { actor: null, session: { kind: "expired" } });
|
||||
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 })
|
||||
@@ -416,7 +383,11 @@ try {
|
||||
// authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。
|
||||
await context.clearCookies({ name: session.name });
|
||||
const partial = await whoami();
|
||||
assertEquals(partial, { actor: null, session: { kind: "anonymous" } });
|
||||
assertEquals(partial, {
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
await page.reload();
|
||||
await at("/");
|
||||
await context.clearCookies();
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
/// <reference lib="dom" />
|
||||
// 実Backend + 本番static shell + Chromium native WebAuthn/CTAP2の旧鍵移行matrix。
|
||||
// navigator.credentials、HTTP応答、暗号検証を置換しない。CDPは合成認証器の
|
||||
// 能力/本人確認/タッチだけを制御し、credentialのexport/import/deleteはしない。
|
||||
// 旧鍵fixtureだけは実optionsのuser.idとresident指定を変更してnative createする。
|
||||
// 登録attestationはuser handleを署名しないため、本番verifier/storeで保存できる。
|
||||
// 現行製品の登録動作の再現ではなく、旧random handle保存状態の明示的な合成である。
|
||||
// 実スマホ/OSのprovider選択/物理鍵は対象外。mobileはChromiumのviewportのみ。
|
||||
import { join } from "@std/path";
|
||||
import { type Browser, type CDPSession, chromium, type Page } from "playwright";
|
||||
import { serveProductionShell } from "./static_shell.ts";
|
||||
|
||||
const [backend, origin, build, handlePrefix, artifacts] = Deno.args;
|
||||
if (!backend || !origin || !build || !handlePrefix || !artifacts) {
|
||||
throw new Error(
|
||||
"Expected owned Backend, origin, production build, synthetic handle prefix, artifacts",
|
||||
);
|
||||
}
|
||||
const started = performance.now();
|
||||
const server = await serveProductionShell(backend, origin, build);
|
||||
let browser: Browser | undefined;
|
||||
let phase = "launch";
|
||||
const rows: {
|
||||
resident: boolean;
|
||||
authenticator: string;
|
||||
outcome: string;
|
||||
elapsed_ms: number;
|
||||
}[] = [];
|
||||
class NativeCheckError extends Error {}
|
||||
function check(condition: unknown, diagnostic: string): asserts condition {
|
||||
// 値をdumpするassertEqualsは使わない。例外/HTTP本文/credential/cookieをログに残さない。
|
||||
if (!condition) throw new NativeCheckError(diagnostic);
|
||||
}
|
||||
type User = { user_id: string; account_id: string; handle: string };
|
||||
type Key = {
|
||||
key_reference: string;
|
||||
normal_login_verified_at: string | null;
|
||||
last_used_at: string | null;
|
||||
};
|
||||
async function whoami(page: Page) {
|
||||
const response = await page.request.get(`${origin}/api/auth/whoami`);
|
||||
check(response.status() === 200, "whoami status");
|
||||
return await response.json() as {
|
||||
actor: User | null;
|
||||
authentication_reference: string | null;
|
||||
session: { kind: string };
|
||||
};
|
||||
}
|
||||
async function owner(page: Page, user: User, reference?: string) {
|
||||
const current = await whoami(page);
|
||||
check(
|
||||
current.session.kind === "authenticated" &&
|
||||
current.actor?.user_id === user.user_id &&
|
||||
current.actor.account_id === user.account_id &&
|
||||
current.actor.handle === user.handle,
|
||||
"authenticated owner mismatch",
|
||||
);
|
||||
check(
|
||||
typeof current.authentication_reference === "string",
|
||||
"missing authentication reference",
|
||||
);
|
||||
if (reference) {
|
||||
check(
|
||||
current.authentication_reference === reference,
|
||||
"issued authentication mismatch",
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
async function keys(page: Page, user: User): Promise<Key[]> {
|
||||
const response = await page.request.get(`${origin}/api/auth/passkeys`);
|
||||
check(response.status() === 200, "key list status");
|
||||
const result = await response.json();
|
||||
check(
|
||||
result.user.user_id === user.user_id &&
|
||||
result.user.account_id === user.account_id,
|
||||
"key list owner",
|
||||
);
|
||||
return result.keys;
|
||||
}
|
||||
async function presence(
|
||||
cdp: CDPSession,
|
||||
authenticatorId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
await cdp.send("WebAuthn.setAutomaticPresenceSimulation", {
|
||||
authenticatorId,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
async function authenticator(
|
||||
cdp: CDPSession,
|
||||
resident: boolean,
|
||||
automaticPresenceSimulation = true,
|
||||
) {
|
||||
return (await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
||||
options: {
|
||||
protocol: "ctap2",
|
||||
transport: "usb",
|
||||
hasResidentKey: resident,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation,
|
||||
},
|
||||
})).authenticatorId;
|
||||
}
|
||||
async function account(page: Page) {
|
||||
await page.goto(origin + "/account");
|
||||
await page.getByRole("heading", { name: "Account", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||
}
|
||||
async function logout(page: Page) {
|
||||
await account(page);
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||
await page.waitForURL((url) => url.origin === origin && url.pathname === "/");
|
||||
check(
|
||||
(await whoami(page)).session.kind === "anonymous",
|
||||
"logout must clear authentication",
|
||||
);
|
||||
}
|
||||
async function login(page: Page, user: User, legacy: boolean) {
|
||||
await page.goto(origin + "/login?returnTo=%2Faccount");
|
||||
const button = page.getByRole("button", {
|
||||
name: legacy ? "Log in with older key" : "Log in with passkey",
|
||||
exact: true,
|
||||
});
|
||||
if (legacy) {
|
||||
await page.getByText("Use an older key with account ID", { exact: true })
|
||||
.click();
|
||||
await page.getByLabel("Account ID (handle)", { exact: true }).fill(
|
||||
user.handle,
|
||||
);
|
||||
} else {
|
||||
// 互換入口は閉じたまま。通常loginのrequestにhandle/user/accountが入らないことも検査する。
|
||||
check(
|
||||
!await page.getByLabel("Account ID (handle)", { exact: true })
|
||||
.isVisible(),
|
||||
"normal login asks for ID",
|
||||
);
|
||||
}
|
||||
const options = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/login/options"
|
||||
);
|
||||
const complete = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/login/complete"
|
||||
);
|
||||
await button.click();
|
||||
const optionResponse = await options;
|
||||
check(optionResponse.status() === 200, "login options status");
|
||||
const input = optionResponse.request().postDataJSON();
|
||||
if (!legacy) {
|
||||
check(
|
||||
Object.keys(input).every((key) => key === "browser_origin"),
|
||||
"normal login selected an account",
|
||||
);
|
||||
const wire = await optionResponse.json();
|
||||
check(
|
||||
!wire.public_key.publicKey.allowCredentials?.length,
|
||||
"normal login sends an allow-list",
|
||||
);
|
||||
}
|
||||
const response = await complete;
|
||||
check(response.status() === 200, "native login complete status");
|
||||
const issued = await response.json();
|
||||
check(
|
||||
issued.user.user_id === user.user_id &&
|
||||
issued.user.account_id === user.account_id,
|
||||
"login returned wrong owner",
|
||||
);
|
||||
await page.waitForURL((url) => url.pathname === "/account");
|
||||
await owner(page, user, issued.authentication_reference);
|
||||
}
|
||||
|
||||
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 });
|
||||
for (const resident of [false, true]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
context.setDefaultTimeout(12_000);
|
||||
// 製品もfixtureも外部通信しない。loopback proxy以外へのbrowser requestは拒否する。
|
||||
await context.route(
|
||||
"**/*",
|
||||
(route) =>
|
||||
new URL(route.request().url()).origin === origin
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
let page = await context.newPage();
|
||||
let pageErrors = 0;
|
||||
page.on("pageerror", () => ++pageErrors);
|
||||
let cdp = await context.newCDPSession(page);
|
||||
await cdp.send("WebAuthn.enable", { enableUI: false });
|
||||
// 両行とも認証器はresident key対応にする。residentは旧credentialの作成指定だけを変え、
|
||||
// resident必須の追加が能力不足で拒否されるケースとduplicate exclusionを混同しない。
|
||||
const old = await authenticator(cdp, true);
|
||||
let completionPosts = 0;
|
||||
const completions = () => completionPosts;
|
||||
page.on("request", (r) => {
|
||||
if (
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
||||
) ++completionPosts;
|
||||
});
|
||||
try {
|
||||
phase = `legacy-fixture-resident-${resident}`;
|
||||
await page.goto(origin + "/register");
|
||||
// 生options/credentialはpage内にとどめる。user.id/residency以外はServerの要求を維持する。
|
||||
const fixture = await page.evaluate(async ({ handle, resident }) => {
|
||||
const post = async (path: string, body: unknown) => {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("synthetic legacy fixture HTTP rejected");
|
||||
}
|
||||
return await response.json();
|
||||
};
|
||||
const wire = await post("/api/auth/passkeys/registration/options", {
|
||||
handle,
|
||||
display_name: "Synthetic native migration",
|
||||
browser_origin: location.origin,
|
||||
});
|
||||
const bytes = (value: string) =>
|
||||
Uint8Array.from(
|
||||
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
|
||||
(c) => c.charCodeAt(0),
|
||||
);
|
||||
const options = wire.public_key.publicKey;
|
||||
const random = crypto.getRandomValues(new Uint8Array(32));
|
||||
const stable = bytes(options.user.id);
|
||||
const differs = stable.length !== random.length ||
|
||||
stable.some((byte: number, index: number) => byte !== random[index]);
|
||||
options.challenge = bytes(options.challenge);
|
||||
options.user.id = random;
|
||||
options.excludeCredentials = options.excludeCredentials?.map((
|
||||
item: { id: string },
|
||||
) => ({ ...item, id: bytes(item.id) }));
|
||||
options.authenticatorSelection = {
|
||||
...options.authenticatorSelection,
|
||||
residentKey: resident ? "required" : "discouraged",
|
||||
requireResidentKey: resident,
|
||||
};
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: options,
|
||||
}) as PublicKeyCredential | null;
|
||||
if (
|
||||
!credential ||
|
||||
!(credential.response instanceof AuthenticatorAttestationResponse)
|
||||
) throw new Error("native attestation missing");
|
||||
const encode = (value: ArrayBuffer) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(value))).replaceAll(
|
||||
"+",
|
||||
"-",
|
||||
).replaceAll("/", "_").replaceAll("=", "");
|
||||
const issued = await post("/api/auth/passkeys/registration/complete", {
|
||||
challenge_id: wire.challenge_id,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
rawId: encode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: encode(credential.response.attestationObject),
|
||||
clientDataJSON: encode(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports(),
|
||||
},
|
||||
extensions: credential.getClientExtensionResults(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: issued.user,
|
||||
authentication_reference: issued.authentication_reference,
|
||||
randomDiffersFromStable: differs,
|
||||
};
|
||||
}, {
|
||||
handle: `${handlePrefix}-${resident ? "resident" : "nonresident"}`,
|
||||
resident,
|
||||
});
|
||||
check(
|
||||
fixture.randomDiffersFromStable,
|
||||
"fixture must use a legacy random handle",
|
||||
);
|
||||
const user: User = fixture.user;
|
||||
await owner(page, user, fixture.authentication_reference);
|
||||
const initial = await keys(page, user);
|
||||
check(
|
||||
initial.length === 1 && initial[0].normal_login_verified_at === null,
|
||||
"legacy key initially unverified",
|
||||
);
|
||||
const oldReference = initial[0].key_reference;
|
||||
await logout(page);
|
||||
phase = `older-key-login-before-resident-${resident}`;
|
||||
await login(page, user, true);
|
||||
|
||||
phase = `same-authenticator-excluded-resident-${resident}`;
|
||||
const sameStarted = performance.now();
|
||||
const add = () =>
|
||||
page.getByRole("button", {
|
||||
name: `Add passkey to ${user.handle}`,
|
||||
exact: true,
|
||||
});
|
||||
const optionsResponse = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
||||
);
|
||||
await add().click();
|
||||
check(
|
||||
(await optionsResponse).status() === 200,
|
||||
"addition options status",
|
||||
);
|
||||
const excluded = page.getByRole("alert").filter({
|
||||
hasText: "[authenticator/excluded]",
|
||||
});
|
||||
await excluded.waitFor({ state: "visible" });
|
||||
check(
|
||||
(await excluded.innerText()).includes(
|
||||
"different device, security key, or passkey provider",
|
||||
),
|
||||
"exclusion needs a concrete alternative",
|
||||
);
|
||||
check(
|
||||
(await excluded.innerText()).includes("do not delete"),
|
||||
"exclusion must preserve working access",
|
||||
);
|
||||
check(
|
||||
completions() === 0,
|
||||
"native exclusion must not submit completion",
|
||||
);
|
||||
const afterExcluded = await keys(page, user);
|
||||
check(
|
||||
afterExcluded.length === 1 &&
|
||||
afterExcluded[0].key_reference === oldReference,
|
||||
"exclusion changed old key",
|
||||
);
|
||||
await owner(page, user);
|
||||
rows.push({
|
||||
resident,
|
||||
authenticator: "same",
|
||||
outcome: "native-exclusion-no-completion-old-key-preserved",
|
||||
elapsed_ms: Math.round(performance.now() - sameStarted),
|
||||
});
|
||||
|
||||
phase = `other-authenticator-pending-resident-${resident}`;
|
||||
const otherStarted = performance.now();
|
||||
// CDPの仮想認証器選択は物理providerの選択UIを再現しない。別targetに別認証器を置き、
|
||||
// 旧targetと旧鍵は生存させる。共有contextの既存sessionを使い、cookie/鍵の移送はしない。
|
||||
const legacyPage = page;
|
||||
const legacyCdp = cdp;
|
||||
page = await context.newPage();
|
||||
page.on("pageerror", () => ++pageErrors);
|
||||
page.on("request", (r) => {
|
||||
if (
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
||||
) ++completionPosts;
|
||||
});
|
||||
cdp = await context.newCDPSession(page);
|
||||
await cdp.send("WebAuthn.enable", { enableUI: false });
|
||||
const newer = await authenticator(cdp, true, false);
|
||||
await account(page);
|
||||
// options応答後、native操作はCTAPのpresence=falseで止める。timeout/sleepでpromptを推測しない。
|
||||
const beforeAddition = await owner(page, user);
|
||||
const additionOptions = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
||||
);
|
||||
const additionComplete = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
||||
);
|
||||
await add().click();
|
||||
const optionResponse = await additionOptions;
|
||||
check(
|
||||
optionResponse.status() === 200,
|
||||
"other authenticator options status",
|
||||
);
|
||||
let optionsInput = optionResponse.request().postDataJSON();
|
||||
check(
|
||||
optionsInput.expected_user_id === user.user_id &&
|
||||
optionsInput.expected_account_id === user.account_id &&
|
||||
optionsInput.expected_authentication_reference ===
|
||||
beforeAddition.authentication_reference,
|
||||
"addition must fence displayed owner and session",
|
||||
);
|
||||
let wire = await optionResponse.json();
|
||||
check(
|
||||
wire.public_key.publicKey.excludeCredentials.length === 1,
|
||||
"old credential must remain excluded",
|
||||
);
|
||||
check(
|
||||
wire.public_key.publicKey.authenticatorSelection.userVerification ===
|
||||
"required",
|
||||
"addition must require UV",
|
||||
);
|
||||
check(
|
||||
wire.public_key.publicKey.authenticatorSelection.residentKey ===
|
||||
"required",
|
||||
"product addition must require residency",
|
||||
);
|
||||
const pending = page.getByRole("status").filter({
|
||||
hasText: "Waiting for passkey addition",
|
||||
});
|
||||
await pending.waitFor({ state: "visible" });
|
||||
check(
|
||||
await page.getByRole("heading", { name: "Account", exact: true })
|
||||
.isVisible(),
|
||||
"account hidden while native pending",
|
||||
);
|
||||
check(
|
||||
await add().isDisabled() && completions() === 0,
|
||||
"pending must prevent duplicate/early completion",
|
||||
);
|
||||
for (
|
||||
const viewport of [{ width: 1280, height: 900 }, {
|
||||
width: 390,
|
||||
height: 844,
|
||||
}]
|
||||
) {
|
||||
await page.setViewportSize(viewport);
|
||||
await pending.scrollIntoViewIfNeeded();
|
||||
check(
|
||||
await pending.isVisible(),
|
||||
"pending must remain visible at viewport",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: join(
|
||||
artifacts,
|
||||
`native-addition-pending-${resident}-${viewport.width}.png`,
|
||||
),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
// presence=falseで既に待機中のCTAP要求を後からtrueにして解放できるとは仮定しない。
|
||||
// pending/取消と成功を分ける。送信前の停止を確認し、別ceremonyを明示開始する。
|
||||
await page.getByRole("button", { name: "Stop waiting", exact: true })
|
||||
.click();
|
||||
await page.getByRole("alert").filter({
|
||||
hasText: "Completion was not sent.",
|
||||
}).waitFor();
|
||||
check(
|
||||
completions() === 0 && (await keys(page, user)).length === 1,
|
||||
"cancel before touch must not save or complete",
|
||||
);
|
||||
await presence(cdp, newer, true);
|
||||
const freshOptions = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
||||
);
|
||||
await add().click();
|
||||
const freshResponse = await freshOptions;
|
||||
check(freshResponse.status() === 200, "fresh addition options status");
|
||||
optionsInput = freshResponse.request().postDataJSON();
|
||||
wire = await freshResponse.json();
|
||||
phase = `other-authenticator-completion-resident-${resident}`;
|
||||
const complete = await additionComplete;
|
||||
check(complete.status() === 200, "native addition complete status");
|
||||
const issued = await complete.json();
|
||||
check(
|
||||
issued.user.user_id === user.user_id &&
|
||||
issued.user.account_id === user.account_id &&
|
||||
typeof issued.authentication_reference === "string",
|
||||
"addition complete owner/reference",
|
||||
);
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Passkey added to your account",
|
||||
}).waitFor({ state: "visible" });
|
||||
await owner(page, user, issued.authentication_reference);
|
||||
check(completions() === 1, "addition must submit exactly once");
|
||||
// 元のbinding referenceはreceipt照合用。Cookie発行後も同じ現在ownerから確認する。
|
||||
const receipt = await page.request.post(
|
||||
`${origin}/api/auth/passkeys/addition/result`,
|
||||
{
|
||||
headers: { Origin: origin },
|
||||
data: {
|
||||
challenge_id: wire.challenge_id,
|
||||
expected_user_id: user.user_id,
|
||||
expected_account_id: user.account_id,
|
||||
expected_authentication_reference:
|
||||
optionsInput.expected_authentication_reference,
|
||||
},
|
||||
},
|
||||
);
|
||||
check(
|
||||
receipt.status() === 200,
|
||||
"saved addition receipt after Cookie issuance",
|
||||
);
|
||||
const saved = await receipt.json();
|
||||
check(
|
||||
saved.status === "saved" && saved.user.user_id === user.user_id &&
|
||||
saved.user.account_id === user.account_id,
|
||||
"receipt must identify saved owner",
|
||||
);
|
||||
const added = await keys(page, user);
|
||||
check(
|
||||
added.length === 2 &&
|
||||
added.some((key) => key.key_reference === oldReference),
|
||||
"addition must retain older key",
|
||||
);
|
||||
const newKey = added.find((key) => key.key_reference !== oldReference);
|
||||
check(
|
||||
newKey && saved.key.key_reference === newKey.key_reference &&
|
||||
newKey.normal_login_verified_at === null,
|
||||
"storage must not assert normal login",
|
||||
);
|
||||
await logout(page);
|
||||
phase = `handle-free-login-resident-${resident}`;
|
||||
await login(page, user, false);
|
||||
const verified = await keys(page, user);
|
||||
check(
|
||||
verified.length === 2 &&
|
||||
verified.find((key) => key.key_reference === newKey.key_reference)
|
||||
?.normal_login_verified_at != null,
|
||||
"actual handle-free verifier success must mark the new key",
|
||||
);
|
||||
check(
|
||||
verified.find((key) => key.key_reference === oldReference)
|
||||
?.normal_login_verified_at === null,
|
||||
"normal login must not mark the unused legacy key",
|
||||
);
|
||||
await logout(page);
|
||||
phase = `older-key-login-after-resident-${resident}`;
|
||||
await presence(cdp, newer, false);
|
||||
page = legacyPage;
|
||||
cdp = legacyCdp;
|
||||
await presence(cdp, old, true);
|
||||
await login(page, user, true);
|
||||
const retained = await keys(page, user);
|
||||
check(
|
||||
retained.length === 2 &&
|
||||
retained.find((key) => key.key_reference === oldReference)
|
||||
?.last_used_at != null,
|
||||
"older key must remain usable after migration",
|
||||
);
|
||||
check(
|
||||
retained.find((key) => key.key_reference === newKey.key_reference)
|
||||
?.normal_login_verified_at != null,
|
||||
"older login must preserve new key verification",
|
||||
);
|
||||
check(pageErrors === 0, "product page emitted an uncaught error");
|
||||
rows.push({
|
||||
resident,
|
||||
authenticator: "other",
|
||||
outcome:
|
||||
"saved-correct-owner-handle-free-verified-older-login-preserved",
|
||||
elapsed_ms: Math.round(performance.now() - otherStarted),
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
join(artifacts, "native-migration-result.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "passed",
|
||||
boundary:
|
||||
"real Backend/static shell/native Chromium CTAP2; synthetic legacy creation override only",
|
||||
chromium: browser.version(),
|
||||
rows,
|
||||
elapsed_ms: Math.round(performance.now() - started),
|
||||
viewports: ["1280x900", "390x844"],
|
||||
physical_smartphone: "unconfirmed",
|
||||
physical_authenticator: "unconfirmed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.error(
|
||||
`native-migration: PASS ${rows.length} matrix rows; ${
|
||||
Math.round(performance.now() - started)
|
||||
}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostic = error instanceof NativeCheckError
|
||||
? error.message
|
||||
: "browser-or-tool-operation-failed";
|
||||
await Deno.writeTextFile(
|
||||
join(artifacts, "native-migration-result.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "failed",
|
||||
phase,
|
||||
diagnostic,
|
||||
rows,
|
||||
elapsed_ms: Math.round(performance.now() - started),
|
||||
physical_smartphone: "unconfirmed",
|
||||
physical_authenticator: "unconfirmed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
// Playwrightの例外にはrequest/引数を含み得るため本文やstackを保存しない。
|
||||
console.error(
|
||||
`native-migration: FAIL phase=${phase}; completed_rows=${rows.length}; elapsed_ms=${
|
||||
Math.round(performance.now() - started)
|
||||
}`,
|
||||
);
|
||||
Deno.exitCode = 1;
|
||||
} finally {
|
||||
Deno.removeSignalListener("SIGTERM", terminate);
|
||||
await browser?.close();
|
||||
await server.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 実Backendへの透過proxyと本番SPA配信をbrowser suite間で共有する。
|
||||
// Cookie/Origin/署名検証/認可は置換しない。fixture入口は呼出側が明示的に渡す。
|
||||
import { extname, join, normalize } from "@std/path";
|
||||
|
||||
export async function serveProductionShell(
|
||||
backend: string,
|
||||
origin: string,
|
||||
build: string,
|
||||
fixture?: (request: Request) => Promise<Response | undefined>,
|
||||
) {
|
||||
for (const address of [backend, origin]) {
|
||||
const url = new URL(address);
|
||||
if (
|
||||
url.protocol !== "http:" ||
|
||||
!["localhost", "127.0.0.1"].includes(url.hostname) || !url.port
|
||||
) {
|
||||
throw new Error("Browser E2E requires explicit owned loopback endpoints");
|
||||
}
|
||||
}
|
||||
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",
|
||||
};
|
||||
return Deno.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(new URL(origin).port),
|
||||
onListen() {},
|
||||
}, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const special = await fixture?.(request);
|
||||
if (special) return special;
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
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"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1074,6 +1074,27 @@ impl WorkerE2eEnvironment {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// browser toolだけに必要なtool探索設定を引き継ぎ、profile・HOME・XDG・一時領域は
|
||||
/// このfixtureへ閉じ込める。DENO_DIR/PLAYWRIGHT_BROWSERS_PATHはインストール済みtoolの
|
||||
/// 供給元であり、製品データや認証情報を引き継ぐ経路にはしない。
|
||||
pub fn browser_command(&self, binary: &Path) -> Result<Command> {
|
||||
let snapshot: std::collections::HashMap<_, _> = std::env::vars_os().collect();
|
||||
let mut command = self.isolated_command(binary);
|
||||
for name in ["PATH", "DENO_DIR", "PLAYWRIGHT_BROWSERS_PATH"] {
|
||||
if let Some(value) = snapshot.get(std::ffi::OsStr::new(name)) {
|
||||
command.env(name, value);
|
||||
}
|
||||
}
|
||||
let temporary = self.root.join("browser-tmp");
|
||||
let cache = self.root.join("browser-cache");
|
||||
fs::create_dir_all(&temporary)?;
|
||||
fs::create_dir_all(&cache)?;
|
||||
command
|
||||
.env("TMPDIR", temporary)
|
||||
.env("XDG_CACHE_HOME", cache);
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn isolated_command(&self, binary: &Path) -> Command {
|
||||
let mut command = Command::new(binary);
|
||||
command
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
|
||||
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
|
||||
//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
||||
//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが
|
||||
//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
|
||||
//! 実Server CLIにはstatic assets設定経路がない。明示選択browser_sessionsは本番static shellを
|
||||
//! loopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
|
||||
//! native_migrationは同じshellと実BackendをChromium native WebAuthn/CTAP2へ接続する。
|
||||
//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。
|
||||
|
||||
use std::error::Error;
|
||||
@@ -21,8 +22,8 @@ use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
use server_api::{
|
||||
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest,
|
||||
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
|
||||
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyAdditionOptionsRequest,
|
||||
PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
|
||||
PasskeyRegistrationOptionsResponse, WhoamiResponse,
|
||||
};
|
||||
#[path = "../src/resident_authenticator.rs"]
|
||||
@@ -30,8 +31,12 @@ mod resident_model;
|
||||
use resident_model::ResidentAuthenticator;
|
||||
use yoi_e2e::WorkerE2eEnvironment;
|
||||
|
||||
#[path = "auth_passkeys/browser_process.rs"]
|
||||
mod browser_process;
|
||||
#[path = "auth_passkeys/browser_sessions.rs"]
|
||||
mod browser_sessions;
|
||||
#[path = "auth_passkeys/native_migration.rs"]
|
||||
mod native_migration;
|
||||
|
||||
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
|
||||
const BROWSER_ORIGIN: &str = "http://localhost:5173";
|
||||
@@ -270,6 +275,29 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// fixtureも現在の本人/認証bindingをwhoamiから取得する。期待値は対象を選ぶ権限ではない。
|
||||
fn addition_expectation(
|
||||
request: RequestBuilder,
|
||||
artifacts: &Path,
|
||||
) -> Result<PasskeyAdditionOptionsRequest> {
|
||||
let identity: WhoamiResponse = decode(send(
|
||||
request,
|
||||
"read addition owner and authentication reference",
|
||||
artifacts,
|
||||
)?)?;
|
||||
let actor = identity
|
||||
.actor
|
||||
.ok_or("addition fixture requires an authenticated actor")?;
|
||||
Ok(PasskeyAdditionOptionsRequest {
|
||||
expected_user_id: actor.user_id,
|
||||
expected_account_id: actor.account_id,
|
||||
expected_authentication_reference: identity
|
||||
.authentication_reference
|
||||
.ok_or("missing addition authentication reference")?,
|
||||
browser_origin: Some(BROWSER_ORIGIN.to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
fn host_admin_login(
|
||||
environment: &WorkerE2eEnvironment,
|
||||
client: &Client,
|
||||
@@ -291,11 +319,17 @@ fn host_admin_login(
|
||||
let bearer = setup["access_token"]
|
||||
.as_str()
|
||||
.ok_or("host setup omitted initial bearer")?;
|
||||
let addition_body = addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.bearer_auth(bearer),
|
||||
artifacts,
|
||||
)?;
|
||||
let options: PasskeyRegistrationOptionsResponse = decode(send(
|
||||
client
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.bearer_auth(bearer)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_body),
|
||||
"host-created administrator bearer starts actor-derived passkey addition",
|
||||
artifacts,
|
||||
)?)?;
|
||||
@@ -348,6 +382,12 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
policy(&client, base, artifacts, false)?;
|
||||
let mut admin_key = ResidentAuthenticator::new(true);
|
||||
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||
let admin_addition = addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.header(COOKIE, &admin_cookie),
|
||||
artifacts,
|
||||
)?;
|
||||
policy(&client, base, artifacts, false)?;
|
||||
set_policy(&client, base, artifacts, &admin_cookie, true)?;
|
||||
let whoami: Value = decode(send(
|
||||
@@ -379,7 +419,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
client
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&admin_addition),
|
||||
"unauthenticated addition options",
|
||||
artifacts,
|
||||
)?,
|
||||
@@ -445,12 +485,18 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
)?)?;
|
||||
assert!(options.public_key.public_key.allow_credentials.is_empty());
|
||||
|
||||
let addition_body = addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.header(COOKIE, &cookie),
|
||||
artifacts,
|
||||
)?;
|
||||
for (field, value) in [
|
||||
("handle", &other.user.handle),
|
||||
("user_id", &other.user.user_id),
|
||||
("account_id", &other.user.account_id),
|
||||
] {
|
||||
let mut body = json!({"browser_origin": BROWSER_ORIGIN});
|
||||
let mut body = serde_json::to_value(&addition_body)?;
|
||||
body[field] = json!(value);
|
||||
rejected(
|
||||
send(
|
||||
@@ -470,7 +516,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
let mut request = client
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, &cookie)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN}));
|
||||
.json(&addition_body);
|
||||
if let Some(origin) = origin {
|
||||
request = request.header(ORIGIN, origin);
|
||||
}
|
||||
@@ -490,7 +536,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, &cookie)
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_body),
|
||||
"authenticated self addition options",
|
||||
artifacts,
|
||||
)?)?;
|
||||
@@ -591,7 +637,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, revoked_cookie)
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_body),
|
||||
"logout prevents further self addition with the old cookie",
|
||||
artifacts,
|
||||
)?,
|
||||
@@ -1797,7 +1843,8 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
||||
client.post(format!("{base}/api/auth/device-login/approve")).header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"user_code": flow["user_code"]})),
|
||||
client.post(format!("{base}/api/auth/passkeys/addition/options")).header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN, "expected_user_id": issue.user.user_id,
|
||||
"expected_account_id": issue.user.account_id, "expected_authentication_reference": "unauthenticated-fixture-not-a-credential"})),
|
||||
];
|
||||
for mut request in requests {
|
||||
if let Some(bearer) = bearer {
|
||||
@@ -1901,7 +1948,12 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, &cookie)
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.header(COOKIE, &cookie),
|
||||
artifacts,
|
||||
)?),
|
||||
"start distinct authenticated self-addition ceremony",
|
||||
artifacts,
|
||||
)?)?;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! browser suiteの子process回収と実行期限を共有する。診断にraw IPCを複製しない。
|
||||
use super::*;
|
||||
use std::process::{Child, ExitStatus};
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub(super) struct BrowserProcess(pub(super) Option<Child>);
|
||||
impl BrowserProcess {
|
||||
pub(super) fn child(&mut self) -> &mut Child {
|
||||
self.0.as_mut().expect("browser process retained")
|
||||
}
|
||||
|
||||
pub(super) fn wait_bounded(mut self, timeout: Duration) -> Result<ExitStatus> {
|
||||
let mut child = self.0.take().expect("browser process retained");
|
||||
let pid = child.id() as libc::pid_t;
|
||||
let (send, receive) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _ = send.send(child.wait());
|
||||
});
|
||||
let result = receive.recv_timeout(timeout);
|
||||
if result.is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = waiter.join();
|
||||
Ok(result.map_err(|_| "browser E2E timed out; see sanitized browser diagnostic")??)
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,11 @@
|
||||
//! 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::browser_process::BrowserProcess;
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::Stdio;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
use yoi_workspace_server::auth::{
|
||||
@@ -62,38 +63,6 @@ fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result<Value>
|
||||
]}))
|
||||
}
|
||||
|
||||
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
|
||||
@@ -132,7 +101,8 @@ fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit
|
||||
drop(reservation);
|
||||
let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?;
|
||||
let mut process = BrowserProcess(Some(
|
||||
Command::new("deno")
|
||||
environment
|
||||
.browser_command(Path::new("deno"))?
|
||||
.current_dir(&root)
|
||||
.args([
|
||||
"run",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 実Backend・本番SPA・Chromium native WebAuthn + CTAP2による旧鍵移行。
|
||||
//! random user handle × resident=false/true × 同じ/別の認証器を検査する。
|
||||
//! 旧鍵fixtureのuser.id/residency指定だけを変更し、実attestationを本番verifier/storeへ送る。
|
||||
//! 現行登録を旧挙動と偽らず、SQL直書き・認証API/mock navigator置換はしない。
|
||||
//! 起動/DB/HOME/XDG/port/回収はWorkerE2eEnvironment、browser profileはその一時領域が所有する。
|
||||
//! DenoとPlaywright Chromium、最新production assets/binariesを呼出側が用意する。
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys native_ctap2 -- --ignored`
|
||||
//! 暗黙buildを避ける実行時は既存providerの3つのbinary overrideを全て指定する。
|
||||
//! DENO_DIR/PLAYWRIGHT_BROWSERS_PATHは事前取得済みtoolの供給元として明示する。
|
||||
//! 物理鍵/実スマホ/provider選択は未確認。CI自動配線の代用にはしない。
|
||||
use super::browser_process::BrowserProcess;
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::process::Stdio;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires production assets/binaries, cached Deno dependencies and Playwright Chromium"]
|
||||
fn native_ctap2_legacy_random_handle_residency_exclusion_and_other_authenticator_migration()
|
||||
-> Result {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()?;
|
||||
let build = root.join("web/workspace/build");
|
||||
if !build.join("index.html").is_file() {
|
||||
return Err("native browser E2E requires an existing production Web build".into());
|
||||
}
|
||||
let mut environment = WorkerE2eEnvironment::new()?;
|
||||
let reservation = TcpListener::bind("127.0.0.1:0")?;
|
||||
let origin = format!("http://localhost:{}", reservation.local_addr()?.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")?;
|
||||
let response = http_client()?
|
||||
.put(format!("{base}/api/hosting/settings"))
|
||||
.bearer_auth(bearer)
|
||||
.json(&json!({"self_registration_enabled": true}))
|
||||
.send()?;
|
||||
if !response.status().is_success() {
|
||||
return Err("native fixture could not enable synthetic self-registration".into());
|
||||
}
|
||||
// Server public_urlと同一originを使う。API proxyだけでOrigin/Cookieを書き換えない。
|
||||
drop(reservation);
|
||||
let stderr =
|
||||
std::fs::File::create(environment.artifacts_dir.join("native-browser.stderr.log"))?;
|
||||
let mut command = environment.browser_command(Path::new("deno"))?;
|
||||
command
|
||||
.current_dir(&root)
|
||||
.args([
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--no-lock",
|
||||
"--cached-only",
|
||||
"--allow-env",
|
||||
"--allow-net=127.0.0.1,localhost",
|
||||
"--allow-read",
|
||||
"--allow-write",
|
||||
"--allow-run",
|
||||
"--allow-sys",
|
||||
"tests/e2e/browser/native_passkey_migration.ts",
|
||||
base,
|
||||
&origin,
|
||||
build.to_str().ok_or("non-UTF8 build path")?,
|
||||
&environment.account_handle,
|
||||
environment
|
||||
.artifacts_dir
|
||||
.to_str()
|
||||
.ok_or("non-UTF8 artifacts path")?,
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(stderr);
|
||||
let process = BrowserProcess(Some(command.spawn()?));
|
||||
let status = process.wait_bounded(Duration::from_secs(180))?;
|
||||
if !status.success() {
|
||||
return Err(format!(
|
||||
"native CTAP2 matrix failed; sanitized diagnostics: {}",
|
||||
environment
|
||||
.artifacts_dir
|
||||
.join("native-browser.stderr.log")
|
||||
.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let report: Value = serde_json::from_slice(&std::fs::read(
|
||||
environment
|
||||
.artifacts_dir
|
||||
.join("native-migration-result.json"),
|
||||
)?)?;
|
||||
if report["status"] != "passed" || report["rows"].as_array().map(Vec::len) != Some(4) {
|
||||
return Err("native browser did not confirm all four matrix rows".into());
|
||||
}
|
||||
let cleanup = environment.cleanup()?;
|
||||
assert!(
|
||||
cleanup.cleanup_success,
|
||||
"owned native browser environment cleanup failed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user