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

Passkey signup and OAuth-made accounts go through signupAllowed, not signinAllowed #2366

Merged opened this

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

Merged

scout/forge:scout/2365-passkey-signup-and-oauth-made-accounts-g → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/2365-passkey-signup-and-oauth-made-accounts-g was merged into main as 70ce013.

Review

Sign in to review this change.
7 files changed +191 −7
src/core/federation.ts +11
@@ -88,6 +88,17 @@ async function makeAccount(db: Database, sub: string, preferred: string): Promis
8888
8989 const making = new Map<string, Promise<User | undefined>>();
9090
91 /** Whether this issuer's `sub` already has a local account: a return, not a first sight. */
92 export function knownIdentity(db: Database, issuer: string, sub: string): boolean {
93 return (
94 db
95 .query<{ user_id: string }, [string, string]>(
96 "SELECT user_id FROM federated_identity WHERE issuer = ?1 AND sub = ?2",
97 )
98 .get(issuer, sub) !== null
99 );
100 }
101
91102 /**
92103 * The local account for an issuer's `sub`, made on first sight with no
93104 * password. Two returns of the same person at once make one account.
src/http/rp.ts +12 −1
@@ -14,6 +14,7 @@ import { createHash, randomBytes } from "node:crypto";
1414 import type { App } from "../app.ts";
1515 import {
1616 accountFor,
17 knownIdentity,
1718 clientFor,
1819 keepClient,
1920 parentIssuerOf,
@@ -24,7 +25,7 @@ import {
2425 } from "../core/federation.ts";
2526 import { readCapped } from "../origins/providers/http.ts";
2627 import { clientAddress } from "./peer.ts";
27 import { signinAllowed } from "../web/pages/site.ts";
28 import { signinAllowed, signupAllowed } from "../web/pages/site.ts";
2829 import { createSession } from "../core/sessions.ts";
2930 import { verifyJwt } from "../kernel/jwt.ts";
3031 import { html } from "../web/html.ts";
@@ -207,6 +208,16 @@ export async function parentCallback(ctx: Ctx): Promise<Response> {
207208 if (typeof sub !== "string" || !SUB.test(sub))
208209 return refuse("The parent forge's answer did not verify.", 502);
209210 const name = clean(verified.value["preferred_username"], 64);
211 // A returning person is a sign-in; first sight of a sub makes an account,
212 // and that is a sign-up, held to the sign-up limit before it is made.
213 if (!knownIdentity(ctx.app.db, login.issuer, sub)) {

Correct gate. The sub is checked as already known here only after it has been verified from the signed id_token, so a client cannot claim a sub to dodge the limit. The slot is keyed by the connection's address through signupAllowed.

214 const wait = signupAllowed(ctx);
215 if (wait !== undefined) {
216 const slow = refuse(`Too many new accounts from here. Try again in ${wait}s.`, 429);
217 slow.headers.set("retry-after", String(wait));
218 return slow;
219 }
220 }
210221 const user = await accountFor(ctx.app.db, login.issuer, sub, name);
211222 if (user === undefined) return refuse("An account for you could not be made or is not allowed in.", 403);
212223 const session = createSession(ctx.app.db, user.id);
src/web/pages/signup-passkey.ts +11 −4
@@ -16,7 +16,7 @@ import { STRANGER_MAX } from "../../http/limits.ts";
1616 import { clientAddress } from "../../http/peer.ts";
1717 import { boundedBody, json, str } from "../../http/respond.ts";
1818 import { relying, store } from "./passkeys.ts";
19 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
19 import { safeNext, sessionCookie, signinAllowed, signupAllowed } from "./site.ts";
2020
2121 const refuse = (status: number, message: string) =>
2222 json({ error: "refused", message }, status);
@@ -41,12 +41,14 @@ function problem(ctx: Ctx, username: string, email: string): string | undefined
4141 return undefined;
4242 }
4343
44 function tooMany(wait: number): Response {
45 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
46 }
47
4448 function guard(ctx: Ctx): Response | undefined {
4549 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
4650 const wait = signinAllowed(ctx, `signup:${clientAddress(ctx.request)}`);
47 if (wait !== undefined)
48 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
49 return undefined;
51 return wait === undefined ? undefined : tooMany(wait);
5052 }
5153
5254 // POST /signup/passkey/options — a challenge, once the name and address pass.
@@ -88,6 +90,11 @@ export async function signupPasskey(ctx: Ctx): Promise<Response> {
8890 { challenge, ...relying(ctx) },
8991 );
9092 if (!made.ok) return refuse(400, "That passkey was not accepted.");
93 // The slot is taken once the key is proven, before the account is tried:
94 // a taken name or a closed door found there still spends it. Cheap
95 // validation above has already refused what it can.
96 const wait = signupAllowed(ctx);

Fine. The slot is spent only after the key verifies, and the comment now says that a taken name found later still spends it.

97 if (wait !== undefined) return tooMany(wait);
9198 const created = await signUpWithPasskey(
9299 ctx.app,
93100 { username, email, passkey: made, passkeyName: "Passkey", humanToken: str(data.humanToken) },
src/web/pages/site.ts +2 −2
@@ -460,8 +460,8 @@ export function signinAllowed(ctx: Ctx, username: string): number | undefined {
460460
461461 /**
462462 * Sign-ups by the socket's peer (`actions/signup-window.ts`), asked by the
463 * signup form, the API signup and the terminal's create screen, which share
464 * one window; the passkey door follows (#2322).
463 * signup form, the API signup, the terminal's create screen, the passkey door
464 * and the parent sign-in's first sight of a person, which share one window.
465465 */
466466 export function signupAllowed(ctx: Ctx): number | undefined {
467467 return signupWindow(ctx.app, clientAddress(ctx.request));
test/forge-signin-signup.test.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * First sight of a person through the parent is a sign-up: it shares the
3 * sign-up window with the passkey door, is refused with the wait when the
4 * window is spent, and makes no account. A returning person is a sign-in and
5 * is never held to that window.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { setLimit } from "../src/settings/limits.ts";
16 import { device } from "./helpers/passkey.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let dirs: string[] = [];
22 let parent: App;
23 let child: App;
24 let ps: Servers;
25 let cs: Servers;
26 const cookies: Record<string, string> = {};
27
28 const cookieOf = (res: Response, name: string) =>
29 res.headers.getSetCookie().map((c) => c.split(";")[0]!).find((c) => c.startsWith(`${name}=`));
30
31 /** One full sign-in at the child as the parent's person with `cookie`; the callback's response. */
32 async function through(cookie: string): Promise<Response> {
33 const start = await fetch(`${cs.url}/signin/parent`, { redirect: "manual" });
34 const to = new URL(start.headers.get("location")!);
35 const consent = await fetch(`${ps.url}/oauth/authorize`, {
36 method: "POST",
37 redirect: "manual",
38 headers: { cookie, origin: ps.url },
39 body: new URLSearchParams({ ...Object.fromEntries(to.searchParams), decision: "allow" }),
40 });
41 const back = new URL(consent.headers.get("location")!);
42 return fetch(`${cs.url}${back.pathname}${back.search}`, { redirect: "manual", headers: { cookie: cookieOf(start, "forge_rp")! } });
43 }
44
45 const post = (path: string, body: unknown) =>
46 fetch(`${cs.url}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
47
48 async function passkeySignUp(name: string) {
49 const d = await device(new URL(cs.url).origin, new URL(cs.url).hostname);
50 const email = `${name}@f.dev`;
51 const o = (await (await post("/signup/passkey/options", { username: name, email })).json()) as any;
52 return post("/signup/passkey", { username: name, email, challenge: o.challenge, ...(await d.create(o.challenge)) });
53 }
54
55 beforeAll(async () => {
56 const make = async () => {
57 const dir = await mkdtemp(join(tmpdir(), "forge-signin-signup-"));
58 dirs.push(dir);
59 return dir;
60 };
61 parent = createApp({ dataDir: await make(), port: 0 });
62 ps = await serve(parent);
63 child = createApp({ dataDir: await make(), port: 0, parentUrl: ps.url, parentLoopback: true });
64 cs = await serve(child);
65 for (const name of ["amy", "ben"]) {
66 const made = await createUser(parent.db, { username: name, email: `${name}@parent.local`, password: "a-long-family-passphrase", adminIfFirst: name === "amy" });
67 if (!made.ok) throw new Error(made.error);
68 cookies[name] = `forge_session=${createSession(parent.db, made.value.id).token}`;
69 }
70 const seeded = await createUser(child.db, { username: "olive", email: "[email protected]", password: "a-long-family-passphrase", trusted: true });
71 if (!seeded.ok) throw new Error(seeded.error);
72 const actor = seeded.value.id;
73 // Two sign-ups a minute from this source, shared by every door.
74 setLimit(child.db, "signup.per_source", 2, actor);
75 });
76
77 afterAll(async () => {
78 ps.stop();
79 cs.stop();
80 for (const dir of dirs) await rm(dir, { recursive: true, force: true });
81 });
82
83 test("a new sub past the sign-up limit is refused with the wait and makes no account; a returning sub is not throttled; the passkey door shares the window", async () => {
84 expect((await through(cookies.amy!)).status).toBe(303);
85 expect(getUserByName(child.db, "amy")).toBeDefined();
86 expect((await passkeySignUp("pk1")).status).toBe(201);
87
88 const refused = await through(cookies.ben!);
89 expect(refused.status).toBe(429);
90 expect(Number(refused.headers.get("retry-after"))).toBeGreaterThan(0);
91 expect(await refused.text()).toContain("Try again in");
92 expect(getUserByName(child.db, "ben")).toBeUndefined();
93
94 // Amy is known here: signing in again is not a sign-up.
95 const again = await through(cookies.amy!);
96 expect(again.status).toBe(303);
97 expect(cookieOf(again, "forge_session")).toBeDefined();
98 expect(child.db.query("SELECT COUNT(*) AS n FROM user WHERE username = 'amy'").get()).toEqual({ n: 1 });
99
100 // And the spent window holds the passkey door too.
101 expect((await passkeySignUp("pk2")).status).toBe(429);
102 });
test/signup-passkey-limit.test.ts added +48
@@ -0,0 +1,48 @@
1 /**
2 * Passkey sign-up asks signupAllowed before it makes an account: the sixth
3 * account in a minute from one source is refused with the wait, and nothing
4 * is made for it.
5 */
6 import { afterAll, beforeAll, 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 { createApp, type App } from "../src/app.ts";
11 import { getUserByName } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { device } from "./helpers/passkey.ts";
14
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18
19 const post = (path: string, body: unknown) =>
20 fetch(`${servers.url}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
21
22 async function signUpAs(name: string) {
23 const d = await device(new URL(servers.url).origin, new URL(servers.url).hostname);
24 const email = `${name}@f.dev`;
25 const o = (await (await post("/signup/passkey/options", { username: name, email })).json()) as any;
26 return post("/signup/passkey", { username: name, email, challenge: o.challenge, ...(await d.create(o.challenge)) });
27 }
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-signup-limit-"));
31 app = createApp({ dataDir, port: 0, passkeyOnlySignup: true });
32 servers = await serve(app);
33 });
34
35 afterAll(async () => {
36 servers.stop();
37 app.close();
38 await rm(dataDir, { recursive: true, force: true });
39 });
40
41 test("a passkey sign-up past the per-source limit is refused with the wait", async () => {
42 for (let i = 0; i < 5; i++) expect((await signUpAs(`lim${i}`)).status).toBe(201);
43 const res = await signUpAs("lim5");
44 expect(res.status).toBe(429);
45 expect(Number(res.headers.get("retry-after"))).toBeGreaterThan(0);
46 expect(((await res.json()) as any).message).toContain("Try again in");
47 expect(getUserByName(app.db, "lim5")).toBeUndefined();
48 });
test/signup-passkey.test.ts +5
@@ -11,6 +11,7 @@ import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { signUp } from "../src/actions/users.ts";
1313 import { createUser } from "../src/core/users.ts";
14 import { setLimit } from "../src/settings/limits.ts";
1415 import { errorText } from "../src/tui/screens/common.ts";
1516 import { serve, type Servers } from "../src/http/server.ts";
1617 import { device } from "./helpers/passkey.ts";
@@ -31,12 +32,16 @@ describe("passkey sign-up", () => {
3132 return post("/signup/passkey", { username, email, challenge: o.challenge, ...(await d.create(o.challenge)) });
3233 }
3334
35 const getOlive = async () => app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'olive'").get()!.id;
36
3437 beforeAll(async () => {
3538 dataDir = await mkdtemp(join(tmpdir(), "forge-signup-passkey-"));
3639 app = createApp({ dataDir, port: 0, passkeyOnlySignup: true });
3740 servers = await serve(app);
3841 base = servers.url;
3942 await createUser(app.db, { username: "olive", email: "[email protected]", password: "olive-password-1", trusted: true });
43 // This suite signs up more than five times a minute from one source.
44 setLimit(app.db, "signup.per_source", 100, (await getOlive()));
4045 });
4146
4247 afterAll(async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs