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:
@@ -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"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user