feat: offer ID-free passkey login and explicit legacy migration
This commit is contained in:
@@ -17,10 +17,16 @@ type Harness = {
|
||||
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 legacyLogin = async (page: Page) => {
|
||||
await page.getByText("Use an older key with account ID", { exact: true })
|
||||
.click();
|
||||
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
|
||||
await page.getByRole("button", { name: "Log in with older key", 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");
|
||||
@@ -134,13 +140,17 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
await page.evaluate(() => history.length),
|
||||
historyBefore + 1,
|
||||
);
|
||||
const id = page.getByLabel("Account ID (handle)", { exact: true });
|
||||
await id.waitFor();
|
||||
const button = page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
});
|
||||
await button.waitFor();
|
||||
assertEquals(await page.locator("input:visible").count(), 0);
|
||||
assertEquals(
|
||||
await page.getByLabel("Display name", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(await page.locator("input").count(), 1);
|
||||
assertEquals(await page.locator("details[open]").count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("link", {
|
||||
name: "Create a new account",
|
||||
@@ -152,12 +162,9 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
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,
|
||||
});
|
||||
await button.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
assert(
|
||||
await button.evaluate((element) =>
|
||||
element === document.activeElement &&
|
||||
@@ -166,6 +173,12 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
await at(page, "/account");
|
||||
assertEquals(
|
||||
mutations.find((entry) =>
|
||||
entry.path === "/api/auth/passkeys/login/options"
|
||||
)?.keys,
|
||||
["browser_origin"],
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
@@ -448,6 +461,105 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
assertEquals(mutations, []);
|
||||
}),
|
||||
);
|
||||
await t.step(
|
||||
"explicit older-key login sends a handle, returns to device approval without approving",
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go("/login?returnTo=%2Flogin%2Fdevice%3Fuser_code%3DABCD-EFGH");
|
||||
await legacyLogin(page);
|
||||
await at(page, "/login/device?user_code=ABCD-EFGH");
|
||||
await page.getByRole("button", {
|
||||
name: "Approve device login",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
mutations.find((entry) =>
|
||||
entry.path === "/api/auth/passkeys/login/options"
|
||||
)?.keys,
|
||||
["browser_origin", "handle"],
|
||||
);
|
||||
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
|
||||
await page.getByRole("button", {
|
||||
name: "Approve device login",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByText(
|
||||
"Device login approved. You can return to the CLI.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
}),
|
||||
);
|
||||
await t.step(
|
||||
"explicit compatibility does not require input for a later normal login",
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go("/login", "login-cancel");
|
||||
await legacyLogin(page);
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(
|
||||
count(mutations, "/api/auth/passkeys/login/complete"),
|
||||
0,
|
||||
);
|
||||
await login(page);
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(
|
||||
mutations.filter((entry) =>
|
||||
entry.path === "/api/auth/passkeys/login/options"
|
||||
).map((entry) => entry.keys),
|
||||
[
|
||||
["browser_origin", "handle"],
|
||||
["browser_origin"],
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
await t.step(
|
||||
"pending older-key login blocks both forms and ignores late credentials after unmount",
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go("/login", "login-pending");
|
||||
await legacyLogin(page);
|
||||
await page.waitForFunction(() =>
|
||||
typeof (window as unknown as { __authFixtureRelease?: unknown })
|
||||
.__authFixtureRelease === "function"
|
||||
);
|
||||
for (const name of ["Log in with passkey", "Log in with older key"]) {
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name, exact: true }).isEnabled(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
await page.locator("form").evaluateAll((forms) => {
|
||||
for (const form of forms) {
|
||||
form.dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
assertEquals(count(mutations, "/api/auth/passkeys/login/options"), 1);
|
||||
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()
|
||||
);
|
||||
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(
|
||||
"leaving a pending login never completes or redirects after navigation",
|
||||
() =>
|
||||
@@ -534,7 +646,15 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
: purpose === "addition"
|
||||
? "/account"
|
||||
: "/login";
|
||||
for (const failure of ["cancel", "error", "http-error", "expired"]) {
|
||||
for (
|
||||
const failure of [
|
||||
"cancel",
|
||||
"unsupported",
|
||||
"error",
|
||||
"http-error",
|
||||
"expired",
|
||||
]
|
||||
) {
|
||||
await t.step(
|
||||
`${purpose} ${failure} is visible and does not claim successful authentication`,
|
||||
() =>
|
||||
@@ -552,7 +672,8 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
path === route || (purpose === "addition" && path === "/login"),
|
||||
);
|
||||
if (
|
||||
failure === "cancel" || failure === "error" ||
|
||||
failure === "cancel" || failure === "unsupported" ||
|
||||
failure === "error" ||
|
||||
failure === "http-error"
|
||||
) {
|
||||
assertEquals(
|
||||
|
||||
@@ -24,9 +24,12 @@ const fill = (selector: string, value: string): Interaction => ({
|
||||
value,
|
||||
});
|
||||
const id = 'label:has-text("ID (handle)") input';
|
||||
const login = [
|
||||
const loginButton = 'button:text-is("Log in with passkey")';
|
||||
const login = [click(loginButton)];
|
||||
const legacyLogin = [
|
||||
click('summary:text-is("Use an older key with account ID")'),
|
||||
fill(id, "alice"),
|
||||
click('button:text-is("Log in with passkey")'),
|
||||
click('button:text-is("Log in with older key")'),
|
||||
];
|
||||
const register = [
|
||||
fill(id, "alice"),
|
||||
@@ -53,23 +56,32 @@ const route = (
|
||||
capturePoints,
|
||||
});
|
||||
const routes: RouteScenario[] = [
|
||||
route("login", "/login?fixture=anonymous", `${id}:enabled`, [
|
||||
route("login", "/login?fixture=anonymous", `${loginButton}:enabled`, [
|
||||
{
|
||||
id: "initial",
|
||||
label: "ID-only login; registration and activation entries",
|
||||
label: "ID-free primary login; explicit older-key disclosure",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "keyboard-focus",
|
||||
label: "Login keyboard focus",
|
||||
fullPage: true,
|
||||
interaction: [fill(id, "alice"), {
|
||||
interaction: [{ action: "press", selector: loginButton, key: "Tab" }, {
|
||||
action: "press",
|
||||
selector: id,
|
||||
key: "Tab",
|
||||
selector: 'summary:text-is("Use an older key with account ID")',
|
||||
key: "Shift+Tab",
|
||||
}],
|
||||
ready: ready('button:text-is("Log in with passkey"):focus-visible'),
|
||||
},
|
||||
{
|
||||
id: "legacy-expanded",
|
||||
label: "Explicit old-key compatibility and safe migration guidance",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
click('summary:text-is("Use an older key with account ID")'),
|
||||
],
|
||||
ready: ready(`${id}:visible`),
|
||||
},
|
||||
]),
|
||||
route(
|
||||
"account-redirect",
|
||||
@@ -139,7 +151,7 @@ const routes: RouteScenario[] = [
|
||||
label: "Dedicated login with device returnTo",
|
||||
fullPage: true,
|
||||
interaction: [click('a:text-is("Log in to continue")')],
|
||||
ready: ready(`${id}:enabled`),
|
||||
ready: ready(`${loginButton}:enabled`),
|
||||
},
|
||||
{
|
||||
id: "authenticated-unapproved",
|
||||
@@ -203,7 +215,7 @@ const routes: RouteScenario[] = [
|
||||
},
|
||||
]),
|
||||
...["login", "registration", "addition"].flatMap((purpose) =>
|
||||
["pending", "cancel", "error", "expired"].map((state) => {
|
||||
["pending", "cancel", "unsupported", "error", "expired"].map((state) => {
|
||||
const path = purpose === "registration"
|
||||
? "/register"
|
||||
: purpose === "addition"
|
||||
@@ -219,6 +231,8 @@ const routes: RouteScenario[] = [
|
||||
`${path}?fixture=${purpose}-${state}`,
|
||||
purpose === "addition"
|
||||
? 'button:text-is("Add passkey to alice")'
|
||||
: purpose === "login"
|
||||
? `${loginButton}:enabled`
|
||||
: `${id}:enabled`,
|
||||
[{
|
||||
id: "result",
|
||||
@@ -232,6 +246,23 @@ const routes: RouteScenario[] = [
|
||||
);
|
||||
})
|
||||
),
|
||||
...["pending", "cancel", "unsupported", "error", "expired"].map((state) =>
|
||||
route(
|
||||
`legacy-login-${state}`,
|
||||
`/login?fixture=login-${state}`,
|
||||
`${loginButton}:enabled`,
|
||||
[{
|
||||
id: "result",
|
||||
label:
|
||||
`Explicit compatibility ${state}; not confirmed login or enrollment`,
|
||||
fullPage: true,
|
||||
interaction: legacyLogin,
|
||||
ready: ready(
|
||||
state === "pending" ? '[role="status"]' : '[role="alert"]',
|
||||
),
|
||||
}],
|
||||
)
|
||||
),
|
||||
route(
|
||||
"activation-entry",
|
||||
"/login?fixture=activation-pending",
|
||||
|
||||
@@ -34,14 +34,25 @@ const mime: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
const options = (login: boolean) => ({
|
||||
const options = (login: boolean, legacy = false) => ({
|
||||
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 }],
|
||||
},
|
||||
public_key: login
|
||||
? {
|
||||
challenge: "dWk",
|
||||
allowCredentials: legacy ? [{ type: "public-key", id: "dWk" }] : [],
|
||||
userVerification: "required",
|
||||
}
|
||||
: {
|
||||
challenge: "dWk",
|
||||
rp: { name: "UI projection" },
|
||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
requireResidentKey: true,
|
||||
userVerification: "required",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
@@ -70,6 +81,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
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("-unsupported")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Synthetic unsupported key','NotSupportedError')}});`;
|
||||
} 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')}});`;
|
||||
@@ -176,7 +190,12 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
if (purpose === "addition") state.signedIn = false;
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
if (stage === "options") return Response.json(options(purpose === "login"));
|
||||
if (stage === "options") {
|
||||
const body = await request.json();
|
||||
return Response.json(
|
||||
options(purpose === "login", typeof body.handle === "string"),
|
||||
);
|
||||
}
|
||||
state.signedIn = true;
|
||||
return Response.json({ user });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user