feat: activate provisional users with scoped passkey enrollment

This commit is contained in:
2026-10-16 08:48:35 +09:00
parent af008f802d
commit d22899d39e
34 changed files with 4278 additions and 49 deletions
@@ -0,0 +1,256 @@
/// <reference lib="dom" />
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Locator } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
// 本番shellとclientを接続する合成HTTP/WebAuthn試験。Server認証/atomic保存のE2Eではない。
Deno.test("activation standalone shell supports issuance, reissue, denial, restart and confirmed-only activation", async () => {
const processes = await startOwnedProcesses(
[{
id: "activation-browser",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/activation-browser/fixture-server.ts",
"15381",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15381/health",
}],
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
`${root}/target/web-ux/activation-interactions/processes`,
[],
);
const browser = await chromium.launch();
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const requested: string[] = [];
const errors: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/")) {
requested.push(
`${request.method()} ${new URL(request.url()).pathname}`,
);
}
});
page.on("pageerror", () => errors.push("pageerror"));
const go = async (path: string, mode: string) => {
await page.goto(`http://127.0.0.1:15381${path}?fixture=${mode}`);
};
const noOverflow = async () =>
assertEquals(
await page.evaluate(() =>
document.documentElement.scrollWidth > innerWidth
),
false,
);
const linkStyle = (link: Locator) =>
link.evaluate((element) => {
const style = getComputedStyle(element);
const probe = document.createElement("span");
probe.hidden = true;
element.append(probe);
const token = (name: string) => {
probe.style.color = `var(${name})`;
return getComputedStyle(probe).color;
};
const result = {
color: style.color,
outline: style.outlineColor,
width: style.outlineWidth,
kind: style.outlineStyle,
underline: style.textDecorationLine,
strong: token("--text-strong"),
text: token("--text"),
accent: token("--accent"),
};
probe.remove();
return result;
});
const checkLink = async (link: Locator) => {
await page.mouse.move(0, 0);
const normal = await linkStyle(link);
assertEquals(normal.color, normal.strong);
assert(normal.underline.includes("underline"));
await link.hover();
const hovered = await linkStyle(link);
assertEquals(hovered.color, hovered.text);
await page.mouse.move(0, 0);
await page.keyboard.press("Tab");
await link.focus();
const focused = await linkStyle(link);
assertEquals(focused.kind, "solid");
assertEquals(focused.width, "2px");
assertEquals(focused.outline, focused.accent);
};
await go("/hosting/settings", "admin");
await page.getByRole("link", {
name: "Create accounts or reissue activation codes",
}).click();
await page.getByLabel("Account ID (handle)").waitFor();
await checkLink(
page.getByRole("link", { name: "Hosting settings", exact: true }),
);
const handle = page.getByLabel("Account ID (handle)");
await handle.fill(" ALICE ");
const issue = page.getByRole("button", {
name: "Create account and issue code",
});
await handle.press("Tab");
assert(
await issue.evaluate((el) =>
el === document.activeElement &&
getComputedStyle(el).outlineStyle !== "none"
),
);
await page.keyboard.press("Enter");
await page.getByRole("heading", { name: "Activation code issued" })
.waitFor();
await checkLink(
page.getByRole("link", { name: "/activate", exact: true }),
);
assertEquals(await page.locator(".secret").count(), 1);
await noOverflow();
await page.getByRole("button", { name: "Hide code" }).click();
assertEquals(await page.locator(".secret").count(), 0);
await page.getByLabel("Operation").selectOption("reissue");
await page.getByRole("button", { name: "Reissue activation code" })
.click();
await page.getByRole("heading", { name: "Activation code issued" })
.waitFor();
assert(
requested.includes("POST /api/hosting/accounts/activation-code"),
);
for (const mode of ["anonymous", "ordinary"]) {
const before = requested.filter((path) =>
path.startsWith("POST ")
).length;
await go("/hosting/accounts", mode);
await page.getByText(
mode === "anonymous"
? "Sign in with a hosting administrator account to issue activation codes."
: "Hosting administrator access is required.",
).waitFor();
assertEquals(
await page.getByLabel("Account ID (handle)").count(),
0,
);
assertEquals(
requested.filter((path) => path.startsWith("POST ")).length,
before,
);
await noOverflow();
}
const fillActivation = async () => {
await page.getByLabel("Account ID (handle)").fill("alice");
await page.getByLabel("Activation code", { exact: true }).fill(
"synthetic-input-only",
);
await page.getByRole("button", {
name: "Register Passkey and activate",
}).click();
};
await go("/activate", "activation-pending");
await page.getByLabel("Activation code", { exact: true }).waitFor();
await checkLink(
page.getByRole("link", {
name:
"Already registered or completion uncertain? Check Passkey login from Account",
}),
);
await fillActivation();
await page.getByText(
"Passkey registration pending. Account activation has not been confirmed.",
).waitFor();
assertEquals(
await page.getByLabel("Activation code", { exact: true })
.inputValue(),
"",
);
assertEquals(
requested.filter((path) =>
path === "POST /api/auth/activation/complete"
).length,
0,
);
await page.getByRole("button", { name: "Cancel registration" })
.click();
await page.getByRole("alert").waitFor();
assertEquals(await page.getByText(/You are signed in/).count(), 0);
await noOverflow();
await page.reload();
await page.getByRole("button", {
name: "Register Passkey and activate",
}).waitFor();
assertEquals(
await page.getByLabel("Activation code", { exact: true })
.inputValue(),
"",
);
for (
const mode of [
"activation-cancel",
"activation-unknown",
"activation-reject",
]
) {
await go("/activate", mode);
await fillActivation();
await page.getByRole("alert").waitFor();
assertEquals(await page.getByText(/You are signed in/).count(), 0);
await noOverflow();
}
await go("/activate", "activation-success");
await fillActivation();
await page.getByText(
"Passkey registration and activation confirmed for alice. You are signed in.",
).waitFor();
assertEquals(await page.locator(".app-shell").count(), 0);
await noOverflow();
assertEquals(
requested.some((path) => path.includes("/api/workspaces")),
false,
);
assertEquals(
requested.some((path) =>
path.includes("/addition/") || path.includes("/registration/") ||
path.includes("/device")
),
false,
);
assertEquals(
await page.evaluate(() =>
[...Object.values(localStorage), ...Object.values(sessionStorage)]
.some((value) => value.includes("synthetic-"))
),
false,
);
assertEquals(new URL(page.url()).searchParams.has("code"), false);
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
} finally {
await browser.close();
await stopOwnedProcesses(processes);
}
});
@@ -0,0 +1,221 @@
// 合成HTTPとWebAuthn境界だけを使い、本番build/shellをshared web-uxで採取する。
// ServerE2Eとは別物。secret fieldは撮影前に隠し、snapshot/診断もredactする。
import { resolve } from "@std/path";
import { capture } from "../../../../tools/web-ux/src/capture.ts";
import type {
Interaction,
RouteScenario,
Scenario,
} from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
Deno.env.set("WEB_UX_SYNTHETIC_ACTIVATION_INPUT", "synthetic-input-only");
const ready = (selector: string) => ({ kind: "selector" as const, selector });
const click = (selector: string): Interaction => ({
action: "click",
selector,
});
const fill = (selector: string, value: string): Interaction => ({
action: "fill",
selector,
value,
});
const activation = [
fill("#activation-handle", "alice"),
fill("#activation-code", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"),
click('button:has-text("Register Passkey and activate")'),
];
const route = (
id: string,
path: string,
selector: string,
capturePoints: RouteScenario["capturePoints"] = [{
id: "initial",
label: id,
fullPage: true,
}],
): RouteScenario => ({
id,
label: id,
path,
goal: "Check production shell and state/copy; not Server authorization.",
dataState: `Synthetic ${id}. No real code, account, credential or session.`,
ready: ready(selector),
capturePoints,
});
const routes: RouteScenario[] = [
route(
"settings",
"/hosting/settings?fixture=admin",
'a[href="/hosting/accounts"]',
),
route("admin", "/hosting/accounts?fixture=admin", "#account-handle:enabled", [
{ id: "initial", label: "ID-only creation", fullPage: true },
{
id: "link-focus",
label: "Hosting settings link keyboard focus",
interaction: [{ action: "press", selector: "summary", key: "Tab" }],
ready: ready('a[href="/hosting/settings"]:focus-visible'),
fullPage: true,
},
{
id: "issued",
label: "Issued; code redacted",
interaction: [
fill("#account-handle", "alice"),
click('button:has-text("Create account and issue code")'),
],
ready: ready("#issued-heading"),
fullPage: true,
},
{
id: "reissue",
label: "Explicit reissue and invalidation warning",
interaction: [click('button:has-text("Hide code")'), {
action: "press",
selector: "#account-operation",
key: "ArrowDown",
}],
ready: ready('button:has-text("Reissue activation code")'),
fullPage: true,
},
]),
route(
"anonymous",
"/hosting/accounts?fixture=anonymous",
'a[href="/account"]',
),
route(
"ordinary",
"/hosting/accounts?fixture=ordinary",
'p:has-text("Hosting administrator access is required.")',
),
route("loading", "/hosting/accounts?fixture=loading", '[role="status"]'),
route(
"access-error",
"/hosting/accounts?fixture=access-error",
'[role="alert"]',
),
route(
"issue-error",
"/hosting/accounts?fixture=issue-error",
"#account-handle:enabled",
[{
id: "unconfirmed",
label: "Intentional HTTP409; unconfirmed issuance",
interaction: [
fill("#account-handle", "alice"),
click('button:has-text("Create account and issue code")'),
],
ready: ready('[role="alert"]'),
fullPage: true,
}],
),
route(
"activation-pending",
"/activate?fixture=activation-pending",
"#activation-code:enabled",
[
{
id: "initial",
label: "ID and code before mandatory Passkey",
fullPage: true,
},
{
id: "link-focus",
label: "Account recovery link keyboard focus",
interaction: [{
action: "press",
selector: "summary",
key: "Shift+Tab",
}],
ready: ready('a[href="/account"]:focus-visible'),
fullPage: true,
},
{
id: "pending",
label: "Pending prompt, no activation claim",
interaction: activation,
ready: ready('button:has-text("Cancel registration")'),
fullPage: true,
},
{
id: "cancelled",
label: "Cancelled; code cleared and restart guidance",
interaction: [click('button:has-text("Cancel registration")')],
ready: ready('[role="alert"]'),
fullPage: true,
},
],
),
...[
"activation-cancel",
"activation-unknown",
"activation-reject",
"activation-success",
].map((mode) =>
route(mode, `/activate?fixture=${mode}`, "#activation-code:enabled", [{
id: "result",
label: mode,
interaction: activation,
ready: ready(
mode === "activation-success" ? '[role="status"]' : '[role="alert"]',
),
fullPage: true,
}])
),
];
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const scenario: Scenario = {
schemaVersion: 1,
id: `activation-${colorScheme}-${width}`,
title: "Activation synthetic projection on production build",
baseUrl: "http://127.0.0.1:15382",
colorScheme,
reducedMotion: "reduce",
personas: [{
id: "fixture",
label: "Synthetic HTTP/WebAuthn only",
auth: { kind: "anonymous" },
}],
viewports: [{ width, height: 900, label: `${width}` }],
routes,
redact: {
selectors: [".secret"],
text: ["synthetic-output-only", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"],
},
processes: [{
id: "activation-projection",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/activation-browser/fixture-server.ts",
"15382",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15382/health",
}],
};
const scenarioPath =
`${root}/target/web-ux/activation-${colorScheme}-${width}.json`;
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
const result = await capture({
scenarioPath,
outputDirectory: `${root}/target/web-ux`,
runId: `activation-after-v3-${colorScheme}-${width}`,
});
console.log(
JSON.stringify({
colorScheme,
width,
status: result.status,
captures: result.captures.length,
}),
);
}
}
@@ -0,0 +1,125 @@
// 本番buildを配信するUI投影fixture。実際のcode検証、認証、DB、cookie発行ではない。
// request本文/credential/secretを記録しない。fixture cookieは公開状態名だけ。
import { extname, join, resolve } from "@std/path";
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
const [port, build] = Deno.args;
if (!port || !build) throw new Error("Expected port and build root");
const root = resolve(build);
const mime: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".woff2": "font/woff2",
".png": "image/png",
};
const user = {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
};
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
const selected = url.searchParams.get("fixture");
const mode = selected ??
request.headers.get("cookie")?.match(/activation-fixture=([\w-]+)/)?.[1] ??
"admin";
const path = url.pathname;
if (path === "/__ui_fixture/webauthn.js") {
const source = mode === "activation-pending"
? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>new Promise(()=>{})});`
: mode === "activation-cancel"
? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>{throw new DOMException('Cancelled','NotAllowedError')}});`
: setupWebAuthnProjection;
return new Response(source, {
headers: { "content-type": "text/javascript" },
});
}
if (path === "/api/workspaces") return new Response(null, { status: 503 });
if (path === "/api/auth/whoami") {
return Response.json({
actor: mode === "anonymous"
? null
: { ...user, auth_method: "browser_session" },
});
}
if (path === "/api/hosting/access") {
if (mode === "loading") return await new Promise<Response>(() => {});
if (mode === "access-error") return new Response(null, { status: 503 });
return Response.json({
is_admin: mode !== "ordinary" && mode !== "anonymous",
});
}
if (path === "/api/hosting/settings") {
return Response.json({ self_registration_enabled: false });
}
if (
path === "/api/hosting/accounts" ||
path === "/api/hosting/accounts/activation-code"
) {
if (mode === "issue-error") return new Response(null, { status: 409 });
// codeは暗号素材でない固定の投影値。captureは.secretを隠しsnapshotからも除外する。
return Response.json({
user,
code: "synthetic-output-only",
expires_at: new Date(Date.now() + 900000).toISOString(),
});
}
if (path === "/api/auth/activation/options") {
if (mode === "activation-reject") {
return new Response(null, { status: 403 });
}
return Response.json({
challenge_id: "ui-projection-only",
public_key: {
challenge: "dWk",
rp: { name: "UI fixture" },
user: { id: "dWk", name: "alice", displayName: "Alice" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
});
}
if (path === "/api/auth/activation/complete") {
if (mode === "activation-unknown") {
return new Response(null, { status: 503 });
}
return Response.json({ user });
}
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
const file = join(root, decodeURIComponent(path));
if (!file.startsWith(root + "/")) return new Response(null, { status: 400 });
let bytes: Uint8Array;
let extension = extname(file);
try {
bytes = await Deno.readFile(file);
} catch (error) {
if (
!(error instanceof Deno.errors.NotFound) &&
!(error instanceof Deno.errors.IsADirectory)
) throw error;
bytes = await Deno.readFile(join(root, "index.html"));
extension = ".html";
}
if (extension === ".html") {
bytes = new TextEncoder().encode(
new TextDecoder().decode(bytes).replace(
"<head>",
'<head><script src="/__ui_fixture/webauthn.js"></script>',
),
);
}
const headers = new Headers({
"content-type": mime[extension] ?? "application/octet-stream",
"cache-control": "no-store",
});
if (selected) {
headers.set(
"set-cookie",
`activation-fixture=${selected}; Path=/; SameSite=Strict`,
);
}
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
});
@@ -101,10 +101,10 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
await page.keyboard.press("Enter");
const edit = page.getByRole("button", { name: "Edit registration policy" });
await edit.waitFor();
assertEquals(
await page.locator('a[href="/hosting/settings"]').getAttribute("aria-current"),
"page",
);
// Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。
// Workspace未作成でも管理できることと整合させる。
assertEquals(await nav.count(), 0);
await page.getByRole("heading", { name: "Hosting settings", exact: true }).waitFor();
if (
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
width < 768
+26 -1
View File
@@ -1,5 +1,30 @@
import { assertEquals } from "jsr:@std/assert";
import { ssr } from "../src/routes/+layout.ts";
import { load, ssr } from "../src/routes/+layout.ts";
Deno.test("activation and hosting shells never require Workspace catalog authority", async () => {
for (
const path of [
"/activate",
"/hosting/accounts",
"/hosting/settings",
"/hosting/setup",
]
) {
let calls = 0;
const event = {
url: new URL(path, "https://workspace.test"),
fetch: () => {
calls++;
throw new Error("Workspace API must not be called");
},
} as unknown as Parameters<typeof load>[0];
assertEquals(await load(event).deferredCatalog, {
ok: true,
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
});
assertEquals(calls, 0);
}
});
Deno.test("workspace frontend serves deep links through the client SPA", () => {
assertEquals(ssr, false);