feat: separate login registration and own account flows

This commit is contained in:
2026-10-16 09:43:57 +09:00
parent d22899d39e
commit b8b12442a0
26 changed files with 3040 additions and 632 deletions
@@ -172,7 +172,7 @@ Deno.test("activation standalone shell supports issuance, reissue, denial, resta
await checkLink(
page.getByRole("link", {
name:
"Already registered or completion uncertain? Check Passkey login from Account",
"Already registered or completion uncertain? Log in with a Passkey",
}),
);
await fillActivation();
@@ -0,0 +1,704 @@
/// <reference lib="dom" />
// 本番shell/router/clientを合成HTTP/WebAuthnで接続する。Server認証/DBのE2Eではない。
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { type Browser, chromium, type Page } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const origin = "http://127.0.0.1:15391";
type Mutation = { path: string; keys: string[] };
type Harness = {
page: Page;
mutations: Mutation[];
go: (path: string, mode?: string) => Promise<void>;
};
const login = async (page: Page) => {
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
await page.getByRole("button", { name: "Log in with passkey", exact: true })
.click();
};
const register = async (page: Page) => {
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
await page.getByLabel("Display name", { exact: true }).fill("Alice");
await page.getByRole("button", { name: "Register new account", exact: true })
.click();
};
const count = (mutations: Mutation[], path: string) =>
mutations.filter((entry) => entry.path === path).length;
const at = async (page: Page, path: string) => {
await page.waitForURL((url) =>
url.origin === origin && url.pathname + url.search === path
);
};
const noOverflow = async (page: Page) =>
assertEquals(
await page.evaluate(() =>
document.documentElement.scrollWidth > innerWidth
),
false,
);
async function isolated(
browser: Browser,
body: (harness: Harness) => Promise<void>,
colorScheme: "light" | "dark" = "light",
width = 1440,
) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
// 不正returnToでも外部へ接続しない。遷移した事実は下のURL assertionで失敗する。
await context.route("**/*", (route) => {
const url = new URL(route.request().url());
return url.origin === origin ? route.continue() : route.abort();
});
const page = await context.newPage();
const mutations: Mutation[] = [];
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("request", (request) => {
const path = new URL(request.url()).pathname;
if (request.method() === "POST" && path.startsWith("/api/")) {
// credential本文/secretは保持せず、対象を自由指定しない契約のfield名だけを観測する。
mutations.push({
path,
keys: Object.keys(request.postDataJSON() ?? {}).sort(),
});
}
});
await body({
page,
mutations,
go: async (path, mode = "anonymous") => {
const url = new URL(path, origin);
url.searchParams.set("fixture", mode);
await page.goto(url.href);
},
});
await noOverflow(page);
assertEquals(errors, []);
} finally {
await context.close();
}
}
Deno.test("standalone auth separates login, registration, own account and explicit device approval", async (t) => {
const processes = await startOwnedProcesses(
[{
id: "auth-browser",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
"15391",
"web/workspace/build",
],
readyUrl: `${origin}/health`,
}],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${root}/target/web-ux/auth-interactions/processes`,
[],
);
let browser: Browser | undefined;
try {
browser = await chromium.launch();
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const step = (
name: string,
body: (harness: Harness) => Promise<void>,
) =>
t.step(
`${theme}/${width}: ${name}`,
() => isolated(browser!, body, theme, width),
);
await step(
"anonymous account replaces history with login then returns to own account",
async ({ page, go, mutations }) => {
await page.goto(`${origin}/health`);
const historyBefore = await page.evaluate(() => history.length);
await go("/account");
await at(page, "/login?returnTo=%2Faccount");
assertEquals(
await page.evaluate(() => history.length),
historyBefore + 1,
);
const id = page.getByLabel("Account ID (handle)", { exact: true });
await id.waitFor();
assertEquals(
await page.getByLabel("Display name", { exact: true }).count(),
0,
);
assertEquals(await page.locator("input").count(), 1);
assertEquals(
await page.getByRole("link", {
name: "Create a new account",
exact: true,
}).getAttribute("href"),
"/register?returnTo=%2Faccount",
);
await page.getByRole("link", {
name: "Activate an account with a code",
exact: true,
}).waitFor();
await id.fill("alice");
await id.press("Tab");
const button = page.getByRole("button", {
name: "Log in with passkey",
exact: true,
});
assert(
await button.evaluate((element) =>
element === document.activeElement &&
getComputedStyle(element).outlineStyle !== "none"
),
);
await page.keyboard.press("Enter");
await at(page, "/account");
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
assertEquals(await page.locator("input").count(), 0);
await page.getByText("Alice", { exact: true }).first().waitFor();
await noOverflow(page);
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
const additions = mutations.filter((entry) =>
entry.path.includes("/addition/")
);
assertEquals(additions.map((entry) => entry.path), [
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
assertEquals(additions[0].keys, ["browser_origin"]);
assertEquals(additions[1].keys, ["challenge_id", "credential"]);
assertEquals(
mutations.some((entry) => entry.path.includes("/registration/")),
false,
);
await page.getByRole("button", { name: "Log out", exact: true })
.click();
await page.getByRole("button", {
name: "Log in with passkey",
exact: true,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/login");
await page.reload();
await page.getByRole("button", {
name: "Log in with passkey",
exact: true,
}).waitFor();
assertEquals(count(mutations, "/api/auth/logout"), 1);
},
);
await step(
"direct registration has only ID and display name and returns safely",
async ({ page, go, mutations }) => {
await go("/register?returnTo=%2Faccount");
await page.getByLabel("Display name", { exact: true }).waitFor();
assertEquals(await page.locator("input").count(), 2);
await register(page);
await at(page, "/account");
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
assertEquals(
count(mutations, "/api/auth/passkeys/registration/options"),
1,
);
assertEquals(
count(mutations, "/api/auth/passkeys/registration/complete"),
1,
);
assertEquals(
mutations.some((entry) => entry.path.includes("/addition/")),
false,
);
},
);
await step(
"device login restoration never approves without an explicit separate action",
async ({ page, go, mutations }) => {
await go("/login/device?user_code=ABCD-EFGH");
await page.getByLabel("User code", { exact: true }).fill(
"WXYZ-1234",
);
const entry = page.getByRole("link", {
name: "Log in to continue",
exact: true,
});
const href = await entry.getAttribute("href");
assert(href);
const returned = new URL(href, origin).searchParams.get("returnTo");
assert(returned?.startsWith("/login/device?"));
assertEquals(
new URL(returned!, origin).searchParams.get("user_code"),
"WXYZ-1234",
);
assertEquals(
await page.getByLabel("Account ID (handle)", { exact: true })
.count(),
0,
);
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
await entry.click();
await login(page);
await page.waitForURL((url) => url.pathname === "/login/device");
const approve = page.getByRole("button", {
name: "Approve device login",
exact: true,
});
await approve.waitFor();
assertEquals(
await page.getByLabel("User code", { exact: true }).inputValue(),
"WXYZ-1234",
);
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
await approve.click();
await page.getByText(
"Device login approved. You can return to the CLI.",
{ exact: true },
).waitFor();
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
},
);
await step(
"disabled registration retains existing-user login",
async ({ page, go, mutations }) => {
await go("/register", "policy-disabled");
await page.getByText(
"New account registration is disabled. Existing users can still log in.",
{ exact: true },
).waitFor();
assertEquals(
await page.getByRole("button", {
name: "Register new account",
exact: true,
}).count(),
0,
);
assertEquals(
await page.getByLabel("Account ID (handle)", { exact: true })
.count(),
0,
);
const link = page.locator('a[href^="/login"]').first();
await link.click();
await login(page);
await at(page, "/");
await page.goto(`${origin}/account`);
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({ hasText: /added/i })
.waitFor();
assertEquals(
count(mutations, "/api/auth/passkeys/addition/complete"),
1,
);
assertEquals(
mutations.some((entry) => entry.path.includes("/registration/")),
false,
);
},
);
await step(
"registration stopped during ceremony displays HTTP403 and removes registration after policy refresh",
async ({ page, go, mutations }) => {
await go("/register", "policy-stopped");
await register(page);
await page.getByRole("alert").filter({ hasText: "403" }).waitFor();
await page.getByText(
"New account registration is disabled. Existing users can still log in.",
{ exact: true },
).waitFor();
assertEquals(await page.locator("form").count(), 0);
assertEquals(
await page.getByRole("button", {
name: "Register new account",
exact: true,
}).count(),
0,
);
await page.getByRole("link", {
name: "Log in with an existing account",
exact: true,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/register");
assertEquals(
count(mutations, "/api/auth/passkeys/registration/complete"),
1,
);
await noOverflow(page);
},
);
for (
const mode of [
"session-error",
"session-malformed",
"session-loading",
]
) {
await step(
`${mode} does not become known anonymous or allow mutation`,
async ({ page, go, mutations }) => {
await go("/login", mode);
if (mode === "session-loading") {
await page.getByText("Loading session…", { exact: true })
.waitFor();
} else {
await page.getByRole("alert").waitFor();
await page.getByRole("button", {
name: "Retry session",
exact: true,
}).waitFor();
}
const loginButton = page.getByRole("button", {
name: "Log in with passkey",
exact: true,
});
if (await loginButton.count()) {
assertEquals(await loginButton.isEnabled(), false);
}
assertEquals(mutations, []);
},
);
}
}
}
for (
const path of [
"/account",
"/register",
"/login/device?user_code=ABCD-EFGH",
]
) {
await t.step(
`unknown session on ${path} offers only recovery, never anonymous mutations`,
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go(path, "session-error");
await page.getByRole("button", {
name: "Retry session",
exact: true,
}).waitFor();
assertEquals(
await page.getByRole("link", {
name: "Log in to continue",
exact: true,
}).count(),
0,
);
assertEquals(
await page.getByLabel("Account ID (handle)", { exact: true })
.count(),
0,
);
assertEquals(
await page.getByRole("button", {
name:
/^(Add passkey|Register new account|Approve device login)/,
}).count(),
0,
);
assertEquals(
new URL(page.url()).pathname,
new URL(path, origin).pathname,
);
assertEquals(mutations, []);
}),
);
}
await t.step(
"policy lookup failure is unknown availability, not disabled or permitted registration",
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go("/register", "policy-error");
await page.getByRole("alert").waitFor();
assertEquals(
await page.getByRole("button", {
name: "Register new account",
exact: true,
}).count(),
0,
);
await page.getByRole("link", {
name: "Log in with an existing account",
exact: true,
}).waitFor();
assertEquals(mutations, []);
}),
);
await t.step(
"leaving a pending login never completes or redirects after navigation",
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go("/login", "login-pending");
await login(page);
await page.waitForFunction(() =>
typeof (window as unknown as { __authFixtureRelease?: unknown })
.__authFixtureRelease === "function"
);
await page.getByRole("link", {
name: "Create a new account",
exact: true,
}).click();
await page.getByLabel("Display name", { exact: true }).waitFor();
await page.evaluate(() =>
(window as unknown as { __authFixtureRelease: () => void })
.__authFixtureRelease()
);
// 次のpaintまで旧promiseのmicrotaskを進め、固定sleepで完了を推測しない。
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => resolve())
)
)
);
assertEquals(new URL(page.url()).pathname, "/register");
assertEquals(
count(mutations, "/api/auth/passkeys/login/complete"),
0,
);
}),
);
await t.step(
"retry rechecks unknown session before allowing login",
() =>
isolated(browser!, async ({ page, go }) => {
await go("/login", "session-retry");
await page.getByRole("button", { name: "Retry session", exact: true })
.click();
await login(page);
await at(page, "/");
}),
);
for (const route of ["login", "register"]) {
await t.step(
`${route} without returnTo uses root fallback`,
() =>
isolated(browser!, async ({ page, go }) => {
await go(`/${route}`);
await (route === "login" ? login(page) : register(page));
await at(page, "/");
await page.reload();
assertEquals(new URL(page.url()).pathname, "/");
}),
);
for (
const rejected of [
"https://example.invalid/",
"//example.invalid/",
"/\\example.invalid/",
"account",
"/login",
"/register?returnTo=%2Flogin",
"/activate",
"/login/../login",
]
) {
await t.step(
`${route} rejects external or auth-loop returnTo ${rejected}`,
() =>
isolated(browser!, async ({ page, go }) => {
await go(`/${route}?returnTo=${encodeURIComponent(rejected)}`);
await (route === "login" ? login(page) : register(page));
await at(page, "/");
}),
);
}
}
for (const purpose of ["login", "registration", "addition"] as const) {
const route = purpose === "registration"
? "/register"
: purpose === "addition"
? "/account"
: "/login";
for (const failure of ["cancel", "error", "http-error", "expired"]) {
await t.step(
`${purpose} ${failure} is visible and does not claim successful authentication`,
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go(route, `${purpose}-${failure}`);
if (purpose === "login") await login(page);
else if (purpose === "registration") await register(page);
else {await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).click();}
await page.getByRole("alert").waitFor();
const path = new URL(page.url()).pathname;
assert(
path === route || (purpose === "addition" && path === "/login"),
);
if (
failure === "cancel" || failure === "error" ||
failure === "http-error"
) {
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
0,
);
} else {assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
1,
);}
}),
);
}
await t.step(
`${purpose} repeated submit while prompt is pending sends one ceremony`,
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go(route, `${purpose}-pending`);
const label = purpose === "login"
? "Log in with passkey"
: purpose === "registration"
? "Register new account"
: "Add passkey to alice";
if (purpose === "login") await login(page);
else if (purpose === "registration") await register(page);
else {await page.getByRole("button", { name: label, exact: true })
.click();}
await page.waitForFunction(() =>
typeof (window as unknown as { __authFixtureRelease?: unknown })
.__authFixtureRelease === "function"
);
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/options`),
1,
);
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
0,
);
await page.getByRole("button", { name: label, exact: true })
.evaluate((button) => {
button.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
button.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
await page.locator("form").evaluateAll((forms) => {
for (const form of forms) {
form.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
form.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
}
});
await page.evaluate(() =>
(window as unknown as { __authFixtureRelease: () => void })
.__authFixtureRelease()
);
if (purpose === "addition") {
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
} else await at(page, "/");
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/options`),
1,
);
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
1,
);
}),
);
}
await t.step(
"expired device request stays unapproved",
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go("/login/device?user_code=ABCD-EFGH", "device-expired");
await page.getByRole("link", {
name: "Log in to continue",
exact: true,
}).click();
await login(page);
await page.getByRole("button", {
name: "Approve device login",
exact: true,
}).click();
await page.getByRole("alert").filter({ hasText: /invalid|expired/i })
.waitFor();
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
}),
);
await t.step(
"activation entry remains limited until credential completion",
() =>
isolated(browser!, async ({ page, go, mutations }) => {
await go("/login", "activation-pending");
await page.getByRole("link", {
name: "Activate an account with a code",
exact: true,
}).click();
// 同名labelのlogin入力が残る間にfillしない。遷移先の固有controlで同期する。
await page.waitForURL((url) => url.pathname === "/activate");
await page.locator("#activation-handle").fill("alice");
await page.locator("#activation-code").fill(
"synthetic-input-only",
);
await page.getByRole("button", {
name: "Register Passkey and activate",
exact: true,
}).click();
await page.getByText(
"Passkey registration pending. Account activation has not been confirmed.",
{ exact: true },
).waitFor();
assertEquals(count(mutations, "/api/auth/activation/complete"), 0);
assertEquals(
mutations.some((entry) =>
/device-login|addition|registration/.test(entry.path)
),
false,
);
assertEquals(await page.locator(".app-shell").count(), 0);
await page.getByRole("button", {
name: "Cancel registration",
exact: true,
}).click();
await page.getByRole("alert").waitFor();
const link = page.locator('a[href="/login"]');
await link.waitFor();
await link.click();
await page.waitForURL((url) => url.pathname === "/login");
await page.getByRole("button", {
name: "Log in with passkey",
exact: true,
}).waitFor();
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
}),
);
} finally {
await browser?.close();
await stopOwnedProcesses(processes);
}
});
+327
View File
@@ -0,0 +1,327 @@
// shared captureがscreenshot/snapshot/review-contextを採取する。合否と本人目視は呼出元が行う。
// 本番buildに合成HTTP/WebAuthnだけを接続し、撮影専用CSSや認証情報は使わない。
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!, "../../../..");
const phase = Deno.args[0] ?? "after";
if (!/^[a-zA-Z0-9_-]+$/.test(phase)) {
throw new Error("Expected safe capture label");
}
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 id = 'label:has-text("ID (handle)") input';
const login = [
fill(id, "alice"),
click('button:text-is("Log in with passkey")'),
];
const register = [
fill(id, "alice"),
fill('label:has-text("Display name") input', "Alice"),
click('button:text-is("Register new account")'),
];
const route = (
id: string,
path: string,
selector: string,
capturePoints: RouteScenario["capturePoints"] = [{
id: "initial",
label: id,
fullPage: true,
}],
): RouteScenario => ({
id,
label: id,
path,
goal:
"Review standalone production auth UI, navigation, focus, state and copy; not Server authorization.",
dataState: `Synthetic ${id}; no real session, credential or activation code.`,
ready: ready(selector),
capturePoints,
});
const routes: RouteScenario[] = [
route("login", "/login?fixture=anonymous", `${id}:enabled`, [
{
id: "initial",
label: "ID-only login; registration and activation entries",
fullPage: true,
},
{
id: "keyboard-focus",
label: "Login keyboard focus",
fullPage: true,
interaction: [fill(id, "alice"), {
action: "press",
selector: id,
key: "Tab",
}],
ready: ready('button:text-is("Log in with passkey"):focus-visible'),
},
]),
route(
"account-redirect",
"/account?fixture=anonymous",
'button:text-is("Log in with passkey")',
),
route("register", "/register?fixture=anonymous", `${id}:enabled`, [
{
id: "initial",
label: "ID and distinct display name; new account only",
fullPage: true,
},
{
id: "keyboard-focus",
label: "Register keyboard focus",
fullPage: true,
interaction: [
fill(id, "alice"),
fill('label:has-text("Display name") input', "Alice"),
{
action: "press",
selector: 'label:has-text("Display name") input',
key: "Tab",
},
],
ready: ready('button:text-is("Register new account"):focus-visible'),
},
]),
route(
"account",
"/account?fixture=account",
'button:text-is("Add passkey to alice")',
[
{
id: "initial",
label: "Own account only; no target handle field",
fullPage: true,
},
{
id: "addition-confirmed",
label: "Own Passkey addition confirmed",
fullPage: true,
interaction: [click('button:text-is("Add passkey to alice")')],
ready: ready('[role="status"]:has-text("Passkey added")'),
},
{
id: "logout",
label: "Log out returns to login",
fullPage: true,
interaction: [click('button:text-is("Log out")')],
ready: ready('button:text-is("Log in with passkey")'),
},
],
),
route(
"device-anonymous",
"/login/device?user_code=ABCD-EFGH&fixture=anonymous",
'a:text-is("Log in to continue")',
[
{
id: "initial",
label: "User code and login continuation; no implicit approval",
fullPage: true,
},
{
id: "login",
label: "Dedicated login with device returnTo",
fullPage: true,
interaction: [click('a:text-is("Log in to continue")')],
ready: ready(`${id}:enabled`),
},
{
id: "authenticated-unapproved",
label: "Login complete; explicit approval still required",
fullPage: true,
interaction: login,
ready: ready('button:text-is("Approve device login"):enabled'),
},
{
id: "approved",
label: "Separate explicit approval confirmed",
fullPage: true,
interaction: [click('button:text-is("Approve device login")')],
ready: ready(
'text="Device login approved. You can return to the CLI."',
),
},
],
),
route(
"session-loading",
"/login?fixture=session-loading",
'text="Loading session…"',
),
route(
"session-error",
"/login?fixture=session-error",
'button:text-is("Retry session")',
),
route(
"account-session-error",
"/account?fixture=session-error",
'button:text-is("Retry session")',
),
route(
"register-session-error",
"/register?fixture=session-error",
'button:text-is("Retry session")',
),
route(
"device-session-error",
"/login/device?user_code=ABCD-EFGH&fixture=session-error",
'button:text-is("Retry session")',
),
route(
"policy-disabled",
"/register?fixture=policy-disabled",
'text="New account registration is disabled. Existing users can still log in."',
),
route("policy-error", "/register?fixture=policy-error", '[role="alert"]'),
route("policy-stopped", "/register?fixture=policy-stopped", `${id}:enabled`, [
{
id: "completion-403",
label:
"Policy refreshed to disabled after intentional registration HTTP403",
fullPage: true,
interaction: register,
ready: ready(
'text="New account registration is disabled. Existing users can still log in."',
),
},
]),
...["login", "registration", "addition"].flatMap((purpose) =>
["pending", "cancel", "error", "expired"].map((state) => {
const path = purpose === "registration"
? "/register"
: purpose === "addition"
? "/account"
: "/login";
const interaction = purpose === "login"
? login
: purpose === "registration"
? register
: [click('button:text-is("Add passkey to alice")')];
return route(
`${purpose}-${state}`,
`${path}?fixture=${purpose}-${state}`,
purpose === "addition"
? 'button:text-is("Add passkey to alice")'
: `${id}:enabled`,
[{
id: "result",
label: `${purpose} ${state}; not a successful ceremony`,
fullPage: true,
interaction,
ready: ready(
state === "pending" ? '[role="status"]' : '[role="alert"]',
),
}],
);
})
),
route(
"activation-entry",
"/login?fixture=activation-pending",
'a:text-is("Activate an account with a code")',
[
{
id: "entry",
label: "Existing activation route from login",
fullPage: true,
interaction: [click('a:text-is("Activate an account with a code")')],
ready: ready("#activation-code:enabled"),
},
{
id: "limited",
label:
"No ordinary session or device approval before Passkey completion",
fullPage: true,
interaction: [
fill("#activation-handle", "alice"),
fill("#activation-code", "synthetic-input-only"),
click('button:text-is("Register Passkey and activate")'),
],
ready: ready('button:text-is("Cancel registration")'),
},
{
id: "cancelled",
label: "Activation cancellation and login recovery entry",
fullPage: true,
interaction: [click('button:text-is("Cancel registration")')],
ready: ready('[role="alert"]'),
},
],
),
];
await Deno.mkdir(`${root}/target/web-ux`, { recursive: true });
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const scenario: Scenario = {
schemaVersion: 1,
id: `auth-${colorScheme}-${width}`,
title:
"Standalone authentication synthetic projection on production build",
baseUrl: "http://127.0.0.1:15392",
colorScheme,
reducedMotion: "reduce",
personas: [{
id: "fixture",
label: "Synthetic HTTP/WebAuthn only",
auth: { kind: "anonymous" },
}],
viewports: [{ width, height: 900, label: `${width}` }],
routes,
redact: {
// 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。
// 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。
selectors: ["#activation-code:disabled", ".secret"],
text: ["synthetic-input-only"],
},
processes: [{
id: "auth-projection",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
"15392",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15392/health",
}],
};
const scenarioPath =
`${root}/target/web-ux/auth-${phase}-${colorScheme}-${width}.json`;
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
const result = await capture({
scenarioPath,
outputDirectory: `${root}/target/web-ux`,
runId: `auth-${phase}-${colorScheme}-${width}`,
});
console.log(
JSON.stringify({
colorScheme,
width,
status: result.status,
captures: result.captures.length,
}),
);
}
}
@@ -0,0 +1,195 @@
// 本番build/client/shellだけを検証する合成HTTP/WebAuthn境界。
// 実認証・署名・cookie安全性・policyの永続化は検証しない。本文/credentialは記録しない。
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 production build root");
const root = resolve(build);
const user = {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
};
type State = {
mode: string;
signedIn: boolean;
whoamiCalls: number;
selfRegistrationEnabled: boolean;
};
const sessions = new Map<string, State>();
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",
".ico": "image/x-icon",
};
const options = (login: boolean) => ({
challenge_id: "synthetic-challenge-only",
public_key: login ? { challenge: "dWk" } : {
challenge: "dWk",
rp: { name: "UI projection" },
user: { id: "dWk", name: "alice", displayName: "Alice" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
});
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/health") return new Response("ok");
const selected = url.searchParams.get("fixture");
let id = request.headers.get("cookie")?.match(/auth-fixture=([\w-]+)/)?.[1];
// routeごとに独立。クライアントgoto後も同じ合成sessionをwhoamiへ投影する。
if (selected || !id || !sessions.has(id)) {
id = crypto.randomUUID();
sessions.set(id, {
mode: selected ?? "anonymous",
signedIn: selected === "account" ||
selected?.startsWith("addition-") === true,
whoamiCalls: 0,
selfRegistrationEnabled: selected !== "policy-disabled",
});
}
const state = sessions.get(id)!;
const { mode } = state;
if (path === "/__ui_fixture/webauthn.js") {
let extra = "";
if (mode.endsWith("-cancel")) {
extra =
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`;
} else if (mode.endsWith("-error")) {
extra =
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Synthetic authenticator failure','UnknownError')}});`;
} else if (mode.endsWith("-pending")) {
extra =
`for (const method of ['get','create']) { const original=navigator.credentials[method].bind(navigator.credentials); Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{ await new Promise(resolve=>{window.__authFixtureRelease=resolve}); return original(); }}); }`;
}
return new Response(setupWebAuthnProjection + extra, {
headers: {
"content-type": "text/javascript",
"cache-control": "no-store",
},
});
}
if (path === "/api/auth/whoami") {
state.whoamiCalls++;
if (mode === "session-loading") {
return await new Promise<Response>(() => {});
}
if (
mode === "session-error" ||
(mode === "session-retry" && state.whoamiCalls === 1)
) {
return new Response(null, { status: 503 });
}
if (mode === "session-malformed") {
return Response.json({ actor: { handle: "untrusted" } });
}
return Response.json({
actor: state.signedIn
? { ...user, auth_method: "browser_session" }
: null,
});
}
if (path === "/api/auth/registration-policy") {
if (mode === "policy-error") return new Response(null, { status: 503 });
return Response.json({
self_registration_enabled: state.selfRegistrationEnabled,
});
}
if (path === "/api/hosting/access") return Response.json({ is_admin: false });
if (path === "/api/workspaces") {
// fallbackは現行Workspace選択まで。公開トップ/T760の挙動を新設しない。
return state.signedIn
? Response.json([])
: new Response(null, { status: 401 });
}
if (path === "/api/auth/logout") {
state.signedIn = false;
return Response.json({ status: "logged_out" });
}
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 });
return Response.json({ status: "approved", user });
}
const passkey = path.match(
/^\/api\/auth\/passkeys\/(login|registration|addition)\/(options|complete)$/,
);
if (passkey) {
const [, purpose, stage] = passkey;
if (purpose === "addition" && !state.signedIn) {
return new Response(null, { status: 401 });
}
if (purpose === "registration") {
if (mode === "policy-stopped" && stage === "complete") {
state.selfRegistrationEnabled = false;
}
if (!state.selfRegistrationEnabled) {
return new Response(null, { status: 403 });
}
}
if (mode.endsWith("-http-error") && stage === "options") {
return new Response(null, { status: 503 });
}
if (mode === `${purpose}-expired` && stage === "complete") {
if (purpose === "addition") state.signedIn = false;
return new Response(null, { status: 401 });
}
if (stage === "options") return Response.json(options(purpose === "login"));
state.signedIn = true;
return Response.json({ user });
}
if (path === "/api/auth/activation/options") {
return Response.json(options(false));
}
if (path === "/api/auth/activation/complete") {
state.signedIn = true;
return Response.json({ user });
}
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
let file: string;
try {
file = join(root, decodeURIComponent(path));
} catch {
return new Response(null, { status: 400 });
}
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 (extension === ".html") {
headers.set(
"set-cookie",
`auth-fixture=${id}; Path=/; SameSite=Strict; HttpOnly`,
);
}
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
});
@@ -4,7 +4,10 @@
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const evidence = `${root}/target/web-ux/hosting/interactions`;
async function noOverflow(page: Page) {
@@ -63,7 +66,9 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
httpErrors.push(
`${response.status()} ${new URL(response.url()).pathname}`,
);
}
});
page.on(
@@ -80,18 +85,34 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
settingsRequests.push(request.method());
}
});
await page.goto("http://127.0.0.1:15379/account?fixture=admin-on");
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
await page.goto("http://127.0.0.1:15379/?fixture=admin-on");
await page.getByRole("heading", { name: "Workspaces", exact: true })
.waitFor();
if (
await page.getByRole("button", { name: "Show sidebar", exact: true }).isVisible()
) await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
await page.getByRole("button", {
name: "Show sidebar",
exact: true,
}).isVisible()
) {
await page.getByRole("button", {
name: "Show sidebar",
exact: true,
}).click();
}
const nav = page.getByRole("navigation", { name: "Global pages" });
await nav.getByRole("link", { name: "Hosting settings", exact: true }).waitFor();
await nav.getByRole("link", { name: "Hosting settings", exact: true })
.waitFor();
await nav.getByRole("link", { name: "Account", exact: true }).focus();
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Device Login");
assertEquals(
await page.locator(":focus").innerText(),
"Device Login",
);
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Hosting settings");
assertEquals(
await page.locator(":focus").innerText(),
"Hosting settings",
);
assertEquals(
await page.locator(":focus").evaluate((element) =>
getComputedStyle(element).outlineStyle !== "none"
@@ -99,42 +120,80 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
true,
);
await page.keyboard.press("Enter");
const edit = page.getByRole("button", { name: "Edit registration policy" });
const edit = page.getByRole("button", {
name: "Edit registration policy",
});
await edit.waitFor();
// Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。
// Workspace未作成でも管理できることと整合させる。
assertEquals(await nav.count(), 0);
await page.getByRole("heading", { name: "Hosting settings", exact: true }).waitFor();
await page.getByRole("heading", {
name: "Hosting settings",
exact: true,
}).waitFor();
if (
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
await page.getByRole("button", {
name: "Hide sidebar",
exact: true,
}).isVisible() &&
width < 768
) await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
) {
await page.getByRole("button", {
name: "Hide sidebar",
exact: true,
}).click();
}
await noOverflow(page);
await edit.focus();
await page.keyboard.press("Enter");
const checkbox = page.getByRole("checkbox", { name: "Allow new account registration" });
const checkbox = page.getByRole("checkbox", {
name: "Allow new account registration",
});
assertEquals(
await checkbox.evaluate((element) => element === document.activeElement),
await checkbox.evaluate((element) =>
element === document.activeElement
),
true,
);
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
await page.getByRole("button", { name: "Cancel", exact: true })
.click();
assertEquals(
await edit.evaluate((element) =>
element === document.activeElement
),
true,
);
await page.keyboard.press("Enter");
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Save settings");
assertEquals(
await page.locator(":focus").innerText(),
"Save settings",
);
await page.keyboard.press("Enter");
await page.getByRole("status").filter({ hasText: "Hosting settings saved." }).waitFor();
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
await page.getByRole("status").filter({
hasText: "Hosting settings saved.",
}).waitFor();
assertEquals(
await edit.evaluate((element) =>
element === document.activeElement
),
true,
);
await page.getByText("Disabled", { exact: true }).waitFor();
assertEquals(settingsRequests.filter((method) => method === "PUT").length, 1);
assertEquals(
settingsRequests.filter((method) => method === "PUT").length,
1,
);
await noOverflow(page);
for (const mode of ["ordinary", "anonymous"]) {
settingsRequests.length = 0;
await page.goto(`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`);
await page.goto(
`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`,
);
await page.getByText(
mode === "ordinary"
? "Hosting administrator access is required."
@@ -144,41 +203,62 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
assertEquals(await edit.count(), 0);
assertEquals(await checkbox.count(), 0);
assertEquals(
await nav.getByRole("link", { name: "Hosting settings", exact: true }).count(),
await nav.getByRole("link", {
name: "Hosting settings",
exact: true,
}).count(),
0,
);
assertEquals(settingsRequests, []);
await noOverflow(page);
}
await page.goto("http://127.0.0.1:15379/account?fixture=account-off");
await page.goto(
"http://127.0.0.1:15379/register?fixture=account-off",
);
await page.getByText(
"New account registration is disabled. Existing users can still log in.",
).waitFor();
assertEquals(await page.getByRole("button", { name: "Register new account" }).count(), 0);
assertEquals(
await page.getByRole("button", { name: "Log in with passkey" }).isEnabled(),
true,
await page.getByRole("button", { name: "Register new account" })
.count(),
0,
);
await page.getByRole("link", {
name: "Log in with an existing account",
}).waitFor();
await noOverflow(page);
await page.goto("http://127.0.0.1:15379/account?fixture=ordinary-account-off");
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
await page.goto(
"http://127.0.0.1:15379/account?fixture=ordinary-account-off",
);
await page.getByRole("button", { name: "Add passkey to alice" })
.waitFor();
assertEquals(
await page.getByRole("button", { name: "Add passkey to alice" }).isEnabled(),
await page.getByRole("button", { name: "Add passkey to alice" })
.isEnabled(),
true,
);
await page.goto("http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error");
await page.goto(
"http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error",
);
await edit.click();
await checkbox.press("Space");
await page.getByRole("button", { name: "Save settings", exact: true }).click();
await page.getByRole("button", { name: "Save settings", exact: true })
.click();
await page.getByRole("alert").waitFor();
assertEquals(await checkbox.isChecked(), false);
assertEquals(await checkbox.isDisabled(), true);
assertEquals(
await page.getByRole("button", { name: "Save settings", exact: true }).isDisabled(),
await page.getByRole("button", {
name: "Save settings",
exact: true,
}).isDisabled(),
true,
);
await noOverflow(page);
await page.getByRole("button", { name: "Reload settings", exact: true }).click();
await page.getByRole("button", {
name: "Reload settings",
exact: true,
}).click();
await edit.waitFor();
assertEquals(await page.getByRole("alert").count(), 0);
assertEquals(await edit.isEnabled(), true);
@@ -189,7 +269,11 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
"/hosting/settings",
'button:has-text("Retry loading settings")',
],
["account-error", "/account", 'button:has-text("Retry registration availability")'],
[
"account-error",
"/register",
'button:has-text("Retry registration availability")',
],
[
"admin-loading",
"/hosting/settings",
@@ -197,7 +281,7 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
],
[
"account-loading",
"/account",
"/register",
'[role="status"]:has-text("Checking registration availability")',
],
]
@@ -242,7 +326,10 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
}
}
}
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
await Deno.writeTextFile(
`${evidence}/results.json`,
JSON.stringify(results, null, 2),
);
} finally {
await browser.close();
await stopOwnedProcesses(processes);
+5 -1
View File
@@ -1,10 +1,14 @@
import { assertEquals } from "jsr:@std/assert";
import { load, ssr } from "../src/routes/+layout.ts";
Deno.test("activation and hosting shells never require Workspace catalog authority", async () => {
Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => {
for (
const path of [
"/activate",
"/login",
"/register",
"/account",
"/login/device",
"/hosting/accounts",
"/hosting/settings",
"/hosting/setup",