Public: anyone can read this; the crew can change it.

Removing your last passkey is refused when the account has no password, on Settings and through MCP alike #8049

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8043-removing-your-last-passkey-is-refused-wh → main 1 commit fork

  • Merged. scout/8043-removing-your-last-passkey-is-refused-wh was merged into main as 7538283.

Review

Sign in to review this change.
5 files changed +169 −5
src/actions/passkeys.ts added +27
@@ -0,0 +1,27 @@
1 /**
2 * Removing one's own passkey, with the one rule both doors share (#8043):
3 * Settings → Passkeys and MCP `whoami_can` refuse to remove the account's
4 * last passkey when no password could sign it in instead. An account with
5 * no passkey at all (edge sign-in) never reaches the rule: there is nothing
6 * of its own to remove.
7 */
8 import type { App } from "../app.ts";
9 import { listPasskeys, removePasskey } from "../core/passkeys.ts";
10 import { hasPassword, type User } from "../core/users.ts";
11 import { err, ok, type Result } from "../kernel/result.ts";
12
13 export const LAST_WAY_IN = "That is your only way to sign in. Add another passkey or set a password first.";
14
15 export function removeOwnPasskey(
16 app: App,
17 user: User,
18 id: string,
19 ): Result<void, "not_found" | "last_way_in"> {
20 // One transaction: two removals racing cannot each see the other's key.
21 return app.db.transaction((): Result<void, "not_found" | "last_way_in"> => {
22 const keys = listPasskeys(app.db, user.id);
23 if (!keys.some((k) => k.id === id)) return err("not_found");
24 if (keys.length === 1 && !hasPassword(app.db, user.id)) return err("last_way_in");
25 return removePasskey(app.db, user.id, id) ? ok() : err("not_found");
26 })();
27 }
src/core/users.ts +13
@@ -520,6 +520,19 @@ export async function verifyPasswordThrottled(
520520 return checked;
521521 }
522522
523 /**
524 * Whether some password could verify for this account: what `checkPassword`
525 * would ever accept. An empty hash never parses, and an org or agent never
526 * signs in with one whatever is stored.
527 */
528 export function hasPassword(db: Database, userId: string): boolean {
529 return (
530 db
531 .query("SELECT 1 FROM user WHERE id = ?1 AND password_hash <> '' AND kind <> 'org' AND is_agent = 0")
532 .get(userId) !== null
533 );
534 }
535
523536 /** Constant-shape verification: always burns an argon2 verify, hit or miss. */
524537 async function checkPassword(
525538 db: Database,
src/http/mcp/tools/whoami_can.ts +5 −2
@@ -1,5 +1,6 @@
11 import { ABILITIES, roleOf, rowOf } from "../../../core/roles.ts";
2 import { listPasskeys, removePasskey } from "../../../core/passkeys.ts";
2 import { LAST_WAY_IN, removeOwnPasskey } from "../../../actions/passkeys.ts";
3 import { listPasskeys } from "../../../core/passkeys.ts";
34 import { scopeAllows } from "../../../core/tokens.ts";
45 import { good, bad, type Tool } from "../shared.ts";
56
@@ -28,7 +29,9 @@ export const tool: Tool = {
2829 return bad("forbidden", "removing a passkey needs an admin-scope token");
2930 if (typeof remove !== "string" || remove === "" || remove.length > 64)
3031 return bad("bad_args", "remove is a passkey id");
31 if (!removePasskey(ctx.app.db, user.id, remove)) return bad("not_found", "No such passkey.");
32 const gone = removeOwnPasskey(ctx.app, user, remove);
33 if (!gone.ok)
34 return gone.error === "last_way_in" ? bad("refused", LAST_WAY_IN) : bad("not_found", "No such passkey.");
3235 return good({ id: remove, says: "Passkey removed." });
3336 }
3437 return good({
src/web/pages/passkeys.ts +5 −3
@@ -15,11 +15,11 @@ import {
1515 notePasskeyUsed,
1616 passkeyIds,
1717 passkeyRoom,
18 removePasskey,
1918 PASSKEY_NAME_MAX,
2019 } from "../../core/passkeys.ts";
2120 import { RateLimiter } from "../../core/ratelimit.ts";
2221 import { createSession } from "../../core/sessions.ts";
22 import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts";
2323 import { recoveryAccount, requestRecovery, spendRecoveryLink } from "../../actions/verify.ts";
2424 import { getUserById, isActive } from "../../core/users.ts";
2525 import { crossOrigin, type Ctx } from "../../http/context.ts";
@@ -157,11 +157,13 @@ export async function passkeyDelete(ctx: Ctx): Promise<Response> {
157157 if (!ctx.auth.user) return signInFirst(ctx);
158158 const form = await readForm(ctx, "admin");
159159 if (form instanceof Response) return form;
160 const gone = removePasskey(ctx.app.db, ctx.auth.user.id, ctx.params.id ?? "");
160 const gone = removeOwnPasskey(ctx.app, ctx.auth.user, ctx.params.id ?? "");
161161 return flashTo(
162162 ctx,
163163 "/settings/passkeys",
164 gone ? { message: "Passkey removed." } : { tone: "danger", message: "No such passkey." },
164 gone.ok
165 ? { message: "Passkey removed." }
166 : { tone: "danger", message: gone.error === "last_way_in" ? LAST_WAY_IN : "No such passkey." },
165167 );
166168 }
167169
test/passkeys-last-way-in.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * An account with no password cannot remove its last passkey (#8043), on
3 * Settings → Passkeys and through MCP `whoami_can` alike; a second passkey
4 * or a password lets the removal through.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { LAST_WAY_IN } from "../src/actions/passkeys.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { addPasskey, listPasskeys } from "../src/core/passkeys.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17
18 describe("the last way in", () => {
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let n = 0;
23 let rpc = 1;
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-last-way-in-"));
27 app = createApp({ dataDir, port: 0, url: "https://forge.example" });
28 servers = await serve(app);
29 });
30 afterAll(async () => {
31 servers.stop();
32 app.close();
33 await rm(dataDir, { recursive: true, force: true });
34 });
35
36 const person = async (password?: string): Promise<User> => {
37 const name = `p${n++}`;
38 const made = await createUser(app.db, { username: name, email: `${name}@f.dev`, origin: "seed", trusted: true, ...(password ? { password } : {}) });
39 if (!made.ok) throw new Error(made.error);
40 return made.value;
41 };
42 const key = (user: User) => {
43 const added = addPasskey(app.db, user.id, "phone", { id: `cred-${n++}`, alg: "ES256", key: { kty: "EC", crv: "P-256", x: "x", y: "y" }, signCount: 0 });
44 if (!added.ok) throw new Error(added.error);
45 return added.value;
46 };
47 const listed = (user: User, id: string) => listPasskeys(app.db, user.id).some((k) => k.id === id);
48
49 const viaPage = async (user: User, id: string) => {
50 const cookie = `forge_session=${createSession(app.db, user.id).token}`;
51 const res = await fetch(`${servers.url}/settings/passkeys/${id}/delete`, { method: "POST", headers: { cookie }, redirect: "manual" });
52 expect(res.status).toBe(303);
53 const carried = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
54 return (await fetch(`${servers.url}/settings/passkeys`, { headers: { cookie: `${cookie}; ${carried}` } })).text();
55 };
56 const viaMcp = async (user: User, id: string) => {
57 const res = await fetch(`${servers.url}/mcp`, {
58 method: "POST",
59 headers: {
60 "content-type": "application/json",
61 accept: "application/json, text/event-stream",
62 authorization: `Bearer ${createToken(app.db, user.id, "t", "admin").token}`,
63 },
64 body: JSON.stringify({ jsonrpc: "2.0", id: rpc++, method: "tools/call", params: { name: "whoami_can", arguments: { kind: "passkeys", remove: id } } }),
65 });
66 const body: any = await res.json();
67 return { isError: body.result.isError === true, text: body.result.content[0].text as string };
68 };
69
70 test("the page refuses a passkey-only account's single passkey", async () => {
71 const user = await person();
72 const id = key(user);
73 const page = await viaPage(user, id);
74 expect(page).toContain(LAST_WAY_IN);
75 expect(page).not.toContain("Passkey removed.");
76 expect(listed(user, id)).toBe(true);
77 });
78
79 test("MCP refuses a passkey-only account's single passkey", async () => {
80 const user = await person();
81 const id = key(user);
82 const res = await viaMcp(user, id);
83 expect(res.isError).toBe(true);
84 expect(res.text).toContain("refused");
85 expect(res.text).toContain(LAST_WAY_IN);
86 expect(listed(user, id)).toBe(true);
87 });
88
89 test("with a second passkey the removal goes through, page and MCP", async () => {
90 const user = await person();
91 const a = key(user);
92 const b = key(user);
93 const c = key(user);
94 expect(await viaPage(user, a)).toContain("Passkey removed.");
95 expect(listed(user, a)).toBe(false);
96 expect((await viaMcp(user, b)).isError).toBe(false);
97 expect(listed(user, b)).toBe(false);
98 // Now c is the last one, and it stays.
99 expect((await viaMcp(user, c)).text).toContain(LAST_WAY_IN);
100 expect(listed(user, c)).toBe(true);
101 });
102
103 test("with a password the last passkey can go, page and MCP", async () => {
104 const one = await person("a-long-passphrase");
105 const a = key(one);
106 expect(await viaPage(one, a)).toContain("Passkey removed.");
107 expect(listed(one, a)).toBe(false);
108 const two = await person("a-long-passphrase");
109 const b = key(two);
110 expect((await viaMcp(two, b)).isError).toBe(false);
111 expect(listed(two, b)).toBe(false);
112 });
113
114 test("an unknown passkey is still not found", async () => {
115 const user = await person();
116 expect(await viaPage(user, "nope")).toContain("No such passkey.");
117 expect((await viaMcp(user, "nope")).text).toContain("not_found");
118 });
119 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs