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:
2026-10-26 07:18:23 +09:00
parent b284934e34
commit f17b5212c7
66 changed files with 4716 additions and 532 deletions
@@ -41,6 +41,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (path === "/api/workspaces") return new Response(null, { status: 503 });
if (path === "/api/auth/whoami") {
return Response.json({
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -89,7 +90,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (mode === "activation-unknown") {
return new Response(null, { status: 503 });
}
return Response.json({ user });
return Response.json({ user, authentication_reference: "fixture-authentication" });
}
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
const file = join(root, decodeURIComponent(path));
@@ -34,6 +34,11 @@ const register = async (page: Page) => {
await page.getByRole("button", { name: "Register new account", exact: true })
.click();
};
const additionConfirmed = (page: Page) =>
page.getByRole("status").filter({
hasText:
/^Passkey added to your account; the expected session was confirmed\.$/,
});
const count = (mutations: Mutation[], path: string) =>
mutations.filter((entry) => entry.path === path).length;
const at = async (page: Page, path: string) => {
@@ -151,7 +156,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
readyUrl: `${origin}/health`,
}],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${root}/target/web-ux/t767-validation/auth-interactions/processes`,
`${root}/target/t783/web-shell-final/processes`,
[],
);
let browser: Browser | undefined;
@@ -229,9 +234,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
await additionConfirmed(page).waitFor();
const additions = mutations.filter((entry) =>
entry.path.includes("/addition/")
);
@@ -239,7 +242,12 @@ Deno.test("standalone auth separates login, registration, own account and explic
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
assertEquals(additions[0].keys, ["browser_origin"]);
assertEquals(additions[0].keys, [
"browser_origin",
"expected_account_id",
"expected_authentication_reference",
"expected_user_id",
]);
assertEquals(additions[1].keys, ["challenge_id", "credential"]);
assertEquals(
mutations.some((entry) => entry.path.includes("/registration/")),
@@ -357,8 +365,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({ hasText: /added/i })
.waitFor();
await additionConfirmed(page).waitFor();
assertEquals(
count(mutations, "/api/auth/passkeys/addition/complete"),
1,
@@ -784,9 +791,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
.__authFixtureRelease()
);
if (purpose === "addition") {
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
await additionConfirmed(page).waitFor();
} else await at(page, "/dashboard");
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/options`),
@@ -813,8 +818,18 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Approve device login",
exact: true,
}).click();
await page.getByRole("alert").filter({ hasText: /invalid|expired/i })
.waitFor();
await page.getByRole("alert").filter({
hasText:
/^Authentication request was rejected \(400\)\. Check your session or restart the operation\.$/,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/login/device");
assertEquals(
await page.getByText(
"Device login approved. You can return to the CLI.",
{ exact: true },
).count(),
0,
);
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
}),
);
@@ -549,7 +549,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
},
);
await step(
"SPA page access checks once without hiding the admitted page",
"SPA page access checks once and fences key-list reads without hiding the admitted page",
async (page) => {
let checks = 0;
page.on("request", (request) => {
@@ -577,6 +577,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
await page.getByRole("link", { name: "Account", exact: true })
.click();
await requested;
assertEquals(checks, 2);
assertEquals(
await page.getByRole("heading", {
name: "Dashboard",
@@ -594,7 +595,10 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
exact: true,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/account");
assertEquals(checks, 2);
await page.getByRole("list", { name: "Registered keys" })
.waitFor();
// page入場の一回に加え、本人鍵一覧は読取りの前後で本人bindingを確認する。
assertEquals(checks, 4);
} finally {
release();
}
@@ -158,9 +158,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
return new Response(null, { status: 503 });
}
if (mode === "session-malformed") {
return Response.json({ actor: { handle: "untrusted" } });
return Response.json({
authentication_reference: state.signedIn
? "fixture-authentication"
: null,
actor: { handle: "untrusted" },
});
}
return Response.json({
authentication_reference: state.signedIn
? "fixture-authentication"
: null,
actor: state.signedIn
? { ...user, auth_method: "browser_session" }
: null,
@@ -168,11 +176,25 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
? {
kind: "authenticated",
expires_at: state.expiresAt ??
new Date(Date.now() + 86_400_000).toISOString(),
"2099-01-01T00:00:00Z",
}
: { kind: state.expired ? "expired" : "anonymous" },
});
}
if (path === "/api/auth/passkeys") {
return Response.json({
user,
keys: [{
key_reference: "synthetic-safe-key-reference",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
}],
});
}
if (path === "/api/auth/passkeys/addition/result") {
return Response.json({ status: "unknown" });
}
if (path === "/api/auth/registration-policy") {
if (mode === "policy-loading") return await new Promise<Response>(() => {});
if (mode === "policy-error") return new Response(null, { status: 503 });
@@ -227,7 +249,11 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
}
if (path === "/api/auth/device-login/approve") {
if (!state.signedIn) return new Response(null, { status: 401 });
if (mode === "device-expired") return new Response(null, { status: 410 });
if (mode === "device-expired") {
return Response.json({
message: "expired_device_login: device login code expired",
}, { status: 400 });
}
return Response.json({ status: "approved", user });
}
const passkey = path.match(
@@ -263,7 +289,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
state.expired = false;
state.cookieSession = false;
state.expiresAt = null;
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/activation/options") {
return Response.json(options(false));
@@ -273,7 +302,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
state.expired = false;
state.cookieSession = false;
state.expiresAt = null;
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
if (workspaceFixture && path.startsWith("/api/w/")) {
@@ -0,0 +1,208 @@
/// <reference lib="dom" />
// 本番shellと可視状態の試験。HTTP/認証器を合成fixtureへ置換するため、暗号/保存の成功根拠ではない。
// native CTAP2 + 実Backend経路はtests/e2eの別suiteが所有する。
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { type Browser, chromium } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
Deno.test("account addition keeps pending and recovery visible at desktop and mobile widths", async (t) => {
const root = resolve(import.meta.dirname!, "../../../..");
const reservation = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (reservation.addr as Deno.NetAddr).port;
reservation.close();
const origin = `http://127.0.0.1:${port}`;
const out = `${root}/target/web-ux/t783-after`;
const processes = await startOwnedProcesses(
[{
id: "passkey-state",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
String(port),
"web/workspace/build",
],
readyUrl: `${origin}/health`,
}],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${out}/processes`,
[],
);
let browser: Browser | undefined;
const records: unknown[] = [];
try {
browser = await chromium.launch();
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const passed = await t.step(
`${theme}/${width}: visible pending, stop and migration metadata`,
async () => {
const context = await browser!.newContext({
viewport: { width, height: 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
page.setDefaultTimeout(5000);
const pageErrors: string[] = [];
const statuses: number[] = [];
const consoleErrors: string[] = [];
const requestErrors: string[] = [];
page.on(
"requestfailed",
() => requestErrors.push("request-failed"),
);
page.on("pageerror", () => pageErrors.push("page-error"));
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push("console-error");
}
});
page.on("response", (response) => {
if (response.status() >= 400) statuses.push(response.status());
});
await context.route(
"**/*",
(route) =>
new URL(route.request().url()).origin === origin
? route.continue()
: route.abort(),
);
await page.request.get(`${origin}/?fixture=addition-pending`);
const documentResponse = await page.goto(`${origin}/account`);
const add = page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
});
await add.waitFor();
await page.getByRole("list", { name: "Registered keys" })
.waitFor();
await page.screenshot({
path: `${out}/account-${theme}-${width}.png`,
fullPage: true,
});
await add.click();
const pending = page.getByRole("status").filter({
hasText: "Waiting for passkey addition",
});
await pending.waitFor({ state: "visible" });
const box = await pending.boundingBox();
assert(
box && box.y >= 0 && box.y + box.height <= 900,
"pending must be in the initial visible viewport",
);
assertEquals(await add.isDisabled(), true);
assertEquals(
await page.getByText("Loading session…", { exact: true })
.isVisible(),
false,
);
await page.screenshot({
path: `${out}/pending-${theme}-${width}.png`,
fullPage: true,
});
const pendingAccessibility = await page.locator("main")
.ariaSnapshot();
await page.getByRole("button", {
name: "Stop waiting",
exact: true,
}).click();
await page.evaluate(() =>
(window as unknown as { __authFixtureRelease: () => void })
.__authFixtureRelease()
);
await page.getByRole("alert").filter({
hasText: "Completion was not sent",
}).waitFor();
await page.getByRole("button", {
name: "Refresh registered keys",
exact: true,
}).click();
await page.getByText(
"Not yet verified — storage alone does not prove support",
{ exact: true },
).waitFor();
await page.getByText(
"Verify migration before retiring older-key login",
{ exact: true },
).click();
await page.screenshot({
path: `${out}/migration-${theme}-${width}.png`,
fullPage: true,
});
assertEquals(
await page.evaluate(() =>
document.documentElement.scrollWidth > innerWidth
),
false,
);
assertEquals(documentResponse?.status(), 200);
assertEquals(pageErrors, []);
assertEquals(consoleErrors, []);
assertEquals(statuses, []);
assertEquals(requestErrors, []);
const images = [];
for (
const name of [
`account-${theme}-${width}.png`,
`pending-${theme}-${width}.png`,
`migration-${theme}-${width}.png`,
]
) {
const digest = await crypto.subtle.digest(
"SHA-256",
await Deno.readFile(`${out}/${name}`),
);
images.push({
name,
sha256: Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join(""),
});
}
records.push({
route: "/account",
actor: "synthetic Alice",
theme,
width,
documentStatus: documentResponse?.status(),
pageErrors,
consoleErrors,
requestErrors,
statuses,
pendingAccessibility,
visible: await page.locator("main").innerText(),
images,
});
} finally {
await context.close();
}
},
);
if (!passed) {
throw new Error(
"Stop on the first failed visibility condition; investigate before repeating the matrix",
);
}
}
}
} finally {
await browser?.close();
await stopOwnedProcesses(processes);
await Deno.writeTextFile(
`${out}/review-context.json`,
JSON.stringify(records, null, 2),
);
}
});
@@ -431,7 +431,7 @@ export async function sessionNavigation(
},
);
await step(
"challenge 401 leaves an authenticated account in place and permits retry",
"challenge 401 leaves an authenticated account in place but requires result recovery before another addition",
async ({ page, go }) => {
const target = "/account?tab=keys#challenge";
await go(target, "addition-expired");
@@ -451,7 +451,21 @@ export async function sessionNavigation(
name: "Add passkey to alice",
exact: true,
}).isEnabled(),
true,
false,
);
await page.getByRole("button", {
name: "Check addition result",
exact: true,
}).click();
await page.getByRole("status").filter({
hasText: "addition result is still unknown",
}).waitFor();
assertEquals(
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).isEnabled(),
false,
);
},
);
@@ -464,7 +478,11 @@ export async function sessionNavigation(
await at(page, "/");
const wire = await (await page.request.get(`${origin}/api/auth/whoami`))
.json();
assertEquals(wire, { actor: null, session: { kind: "anonymous" } });
assertEquals(wire, {
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
@@ -19,7 +19,7 @@ const mime: Record<string, string> = {
};
const policies = new Map<string, boolean>();
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。
// 送らないので、Bearer有無と直前の合成page条件で応答を選ぶ。header値は読まない。
let setupMode: string | null = null;
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url);
@@ -28,19 +28,27 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) {
setupMode = selected;
}
const tokenRequest = request.headers.has("authorization");
const mode = selected ??
(tokenRequest ? setupMode : null) ??
request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
(url.pathname.startsWith("/api/auth/passkeys/addition/")
? setupMode
: null) ??
"admin-on";
if (selected) policies.delete(mode);
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
const setup = setupProjectionResponse(
url.pathname,
mode,
selected !== null,
tokenRequest,
);
if (setup) return setup;
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
const ordinary = mode.startsWith("ordinary");
if (url.pathname === "/api/auth/whoami") {
return Response.json({
authentication_reference: anonymous ? null : "fixture-authentication",
session: !anonymous
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -53,6 +61,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
},
});
}
if (url.pathname === "/api/auth/passkeys") {
return Response.json({
user: {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
},
keys: [],
});
}
if (url.pathname === "/api/workspaces") return Response.json([]);
if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: !ordinary && !anonymous });
@@ -19,6 +19,7 @@ export function setupProjectionResponse(
path: string,
mode: string,
reset: boolean,
tokenRequest = false,
): Response | null {
if (!mode.startsWith("setup-")) return null;
if (reset) stages.delete(mode);
@@ -28,7 +29,17 @@ export function setupProjectionResponse(
});
}
if (path === "/api/auth/whoami") {
if (tokenRequest) {
return Response.json({
actor: { ...user, auth_method: "api_token" },
authentication_reference: "fixture-token-binding",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
}
return Response.json({
authentication_reference: stages.get(mode) === "login"
? "fixture-authentication"
: null,
session: stages.get(mode) === "login"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -56,7 +67,10 @@ export function setupProjectionResponse(
}
if (path === "/api/auth/passkeys/addition/complete") {
stages.set(mode, "enrolled");
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json({
@@ -66,7 +80,10 @@ export function setupProjectionResponse(
}
if (path === "/api/auth/passkeys/login/complete") {
stages.set(mode, "login");
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
return null;
}
+6 -1
View File
@@ -27,6 +27,7 @@ Deno.test("authentication and hosting shells never require Workspace catalog aut
display_name: "Alice",
auth_method: "browser_session",
},
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
browserSession.invalidate();
@@ -68,7 +69,11 @@ Deno.test("root admission never starts private catalog while anonymous, expired,
if (condition === "loading") await pending;
return condition === "error" || condition === "loading"
? new Response(null, { status: 503 })
: Response.json({ actor: null, session: { kind: condition } });
: Response.json({
actor: null,
authentication_reference: null,
session: { kind: condition },
});
};
let privateCalls = 0;
const event = {