Public: anyone can read this; the crew can change it.
Passkey signup and OAuth-made accounts go through signupAllowed, not signinAllowed #2366
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2365-passkey-signup-and-oauth-made-accounts-g → main 5 commits fork 2 threads · 2 unresolved
7 files
changed +191
−7
src/core/federation.ts +11
| @@ -88,6 +88,17 @@ async function makeAccount(db: Database, sub: string, preferred: string): Promis | ||
| 88 | 88 | |
| 89 | 89 | const making = new Map<string, Promise<User | undefined>>(); |
| 90 | 90 | |
| 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 | ||
| 91 | 102 | /** |
| 92 | 103 | * The local account for an issuer's `sub`, made on first sight with no |
| 93 | 104 | * 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"; | ||
| 14 | 14 | import type { App } from "../app.ts"; |
| 15 | 15 | import { |
| 16 | 16 | accountFor, |
| 17 | knownIdentity, | |
| 17 | 18 | clientFor, |
| 18 | 19 | keepClient, |
| 19 | 20 | parentIssuerOf, |
| @@ -24,7 +25,7 @@ import { | ||
| 24 | 25 | } from "../core/federation.ts"; |
| 25 | 26 | import { readCapped } from "../origins/providers/http.ts"; |
| 26 | 27 | import { clientAddress } from "./peer.ts"; |
| 27 | import { signinAllowed } from "../web/pages/site.ts"; | |
| 28 | import { signinAllowed, signupAllowed } from "../web/pages/site.ts"; | |
| 28 | 29 | import { createSession } from "../core/sessions.ts"; |
| 29 | 30 | import { verifyJwt } from "../kernel/jwt.ts"; |
| 30 | 31 | import { html } from "../web/html.ts"; |
| @@ -207,6 +208,16 @@ export async function parentCallback(ctx: Ctx): Promise<Response> { | ||
| 207 | 208 | if (typeof sub !== "string" || !SUB.test(sub)) |
| 208 | 209 | return refuse("The parent forge's answer did not verify.", 502); |
| 209 | 210 | 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)) { | |
| 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 | } | |
| 210 | 221 | const user = await accountFor(ctx.app.db, login.issuer, sub, name); |
| 211 | 222 | if (user === undefined) return refuse("An account for you could not be made or is not allowed in.", 403); |
| 212 | 223 | 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"; | ||
| 16 | 16 | import { clientAddress } from "../../http/peer.ts"; |
| 17 | 17 | import { boundedBody, json, str } from "../../http/respond.ts"; |
| 18 | 18 | import { relying, store } from "./passkeys.ts"; |
| 19 | import { safeNext, sessionCookie, signinAllowed } from "./site.ts"; | |
| 19 | import { safeNext, sessionCookie, signinAllowed, signupAllowed } from "./site.ts"; | |
| 20 | 20 | |
| 21 | 21 | const refuse = (status: number, message: string) => |
| 22 | 22 | json({ error: "refused", message }, status); |
| @@ -41,12 +41,14 @@ function problem(ctx: Ctx, username: string, email: string): string | undefined | ||
| 41 | 41 | return undefined; |
| 42 | 42 | } |
| 43 | 43 | |
| 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 | ||
| 44 | 48 | function guard(ctx: Ctx): Response | undefined { |
| 45 | 49 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden"); |
| 46 | 50 | 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); | |
| 50 | 52 | } |
| 51 | 53 | |
| 52 | 54 | // POST /signup/passkey/options — a challenge, once the name and address pass. |
| @@ -88,6 +90,11 @@ export async function signupPasskey(ctx: Ctx): Promise<Response> { | ||
| 88 | 90 | { challenge, ...relying(ctx) }, |
| 89 | 91 | ); |
| 90 | 92 | 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); | |
| 91 | 98 | const created = await signUpWithPasskey( |
| 92 | 99 | ctx.app, |
| 93 | 100 | { 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 { | ||
| 460 | 460 | |
| 461 | 461 | /** |
| 462 | 462 | * 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. | |
| 465 | 465 | */ |
| 466 | 466 | export function signupAllowed(ctx: Ctx): number | undefined { |
| 467 | 467 | 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"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { signUp } from "../src/actions/users.ts"; |
| 13 | 13 | import { createUser } from "../src/core/users.ts"; |
| 14 | import { setLimit } from "../src/settings/limits.ts"; | |
| 14 | 15 | import { errorText } from "../src/tui/screens/common.ts"; |
| 15 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 17 | import { device } from "./helpers/passkey.ts"; |
| @@ -31,12 +32,16 @@ describe("passkey sign-up", () => { | ||
| 31 | 32 | return post("/signup/passkey", { username, email, challenge: o.challenge, ...(await d.create(o.challenge)) }); |
| 32 | 33 | } |
| 33 | 34 | |
| 35 | const getOlive = async () => app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'olive'").get()!.id; | |
| 36 | ||
| 34 | 37 | beforeAll(async () => { |
| 35 | 38 | dataDir = await mkdtemp(join(tmpdir(), "forge-signup-passkey-")); |
| 36 | 39 | app = createApp({ dataDir, port: 0, passkeyOnlySignup: true }); |
| 37 | 40 | servers = await serve(app); |
| 38 | 41 | base = servers.url; |
| 39 | 42 | 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())); | |
| 40 | 45 | }); |
| 41 | 46 | |
| 42 | 47 | afterAll(async () => { |
new above
offline
Correct gate. The
subis checked as already known here only after it has been verified from the signedid_token, so a client cannot claim asubto dodge the limit. The slot is keyed by the connection's address throughsignupAllowed.