Public: anyone can read this; the crew can change it.
The claim page takes a username and a password; email optional #2987
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2924-the-claim-page-takes-a-username-and-a-pa → main 2 commits fork 2 threads · 2 unresolved
6 files
changed +132
−45
README.md +1 −1
| @@ -350,7 +350,7 @@ to `/recover`: give the email on your account and, if it is verified, a link | ||
| 350 | 350 | that lasts 30 minutes and works once registers a new passkey and signs you in. |
| 351 | 351 | The relying party is the address the browser used (or |
| 352 | 352 | `FORGE_URL`), so a passkey does not follow a forge to another hostname. |
| 353 | Sign-up is a username and a password of at least 12 characters (`/signup`, `POST /api/v1/users` with `{username, password, email?}`, the terminal), hashed with argon2id; the email is optional and, when given, gets the verify link (an account with none has nothing to prove). A passkey (`src/web/pages/signup-passkey.ts`, which does take an email) is offered beside the password. Nothing in the path touches an edge, parent or Forgejo account. An operator can still refuse password sign-ups with `passkeyOnlySignup`. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`) make their account with a passkey too (`web/passkey-ceremony.ts`, challenges from `POST /claim/options` and `POST /settings/everyday/options`); neither reads a password, and without JavaScript the page says a passkey needs it. A claim post with no passkey is refused and does not spend the link. | |
| 353 | Sign-up is a username and a password of at least 12 characters (`/signup`, `POST /api/v1/users` with `{username, password, email?}`, the terminal), hashed with argon2id; the email is optional and, when given, gets the verify link (an account with none has nothing to prove). A passkey (`src/web/pages/signup-passkey.ts`, which does take an email) is offered beside the password. Nothing in the path touches an edge, parent or Forgejo account. An operator can still refuse password sign-ups with `passkeyOnlySignup`. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) takes the same: a username and a password with the email optional, or a passkey (which needs an email; challenges from `POST /claim/options`), and a claim with neither a long enough password nor a passkey is refused without spending the link. An admin's everyday account (`web/pages/everyday.ts`) still makes its account with a passkey only (`web/passkey-ceremony.ts`, challenges from `POST /settings/everyday/options`): it reads no password, and without JavaScript the page says a passkey needs it. | |
| 354 | 354 | |
| 355 | 355 | A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned |
| 356 | 356 | forge's `parent.json`) lists the parent under "Link an account you have" on `/signin`. The forge |
src/actions/users.ts +2 −2
| @@ -90,10 +90,10 @@ export const MIN_SIGNUP_PASSWORD = 12; | ||
| 90 | 90 | * unique and never empty, and `.invalid` is a name no mail can reach |
| 91 | 91 | * (the same device the crew's addresses use). |
| 92 | 92 | */ |
| 93 | const noEmail = (username: string): string => `${username.toLowerCase()}@no-email.invalid`; | |
| 93 | export const noEmail = (username: string): string => `${username.toLowerCase()}@no-email.invalid`; | |
| 94 | 94 | |
| 95 | 95 | /** Whether an address is one a person could have given: plausible and not a stand-in. */ |
| 96 | const givenEmail = (email: string): boolean => plausibleEmail(email) && !isStandInEmail(email); | |
| 96 | export const givenEmail = (email: string): boolean => plausibleEmail(email) && !isStandInEmail(email); | |
| 97 | 97 | |
| 98 | 98 | /** What a person is told when the check fails. */ |
| 99 | 99 | export const HUMAN_CHECK_MESSAGE = |
src/web/pages/claim.ts +55 −27
| @@ -1,14 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * `/claim`: the page a spawned child's link opens (`spawn/claim.ts`). |
| 3 | 3 | * The link is `/claim#<token>`, so the secret never leaves the browser in a |
| 4 | * request line. The path: the person a forge was made for opens the link they were sent, | |
| 5 | * reads who will be able to see it, makes their account with a passkey | |
| 6 | * (`passkey-ceremony.ts`; there is no password), and lands in their forge | |
| 7 | * as its admin. The GET only shows the form — a link unfurled | |
| 8 | * in a chat app must not spend itself — and the POST, same-origin, makes | |
| 9 | * the account and burns the link in one step. A link that is used, expired | |
| 10 | * or made up says which, plainly; a forge that was not spawned has no | |
| 11 | * claim page at all. | |
| 4 | * request line. The person a forge was made for opens it, reads who will be | |
| 5 | * able to see the forge, and makes their account with a username and a | |
| 6 | * password (email optional, the checks signup's own) or with a passkey | |
| 7 | * (`passkey-ceremony.ts`; that one needs an email). They land in their | |
| 8 | * forge as its admin. | |
| 9 | * | |
| 10 | * The GET only shows the form — a link unfurled in a chat app must not | |
| 11 | * spend itself — and the POST, same-origin, makes the account and burns the | |
| 12 | * link in one step. A link that is used, expired or made up says which, | |
| 13 | * plainly; a forge that was not spawned has no claim page at all. | |
| 12 | 14 | * |
| 13 | 15 | * Who keeps access is never silent. By default the spawner's account leaves |
| 14 | 16 | * the organisation's Owners when the forge is claimed (the new owner can |
| @@ -16,6 +18,8 @@ | ||
| 16 | 18 | * so before anyone submits, and where to remove it. |
| 17 | 19 | */ |
| 18 | 20 | |
| 21 | import { givenEmail, MIN_SIGNUP_PASSWORD, noEmail } from "../../actions/users.ts"; | |
| 22 | import type { Credential } from "../../auth/webauthn.ts"; | |
| 19 | 23 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| 20 | 24 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 21 | 25 | import { clientAddress } from "../../http/peer.ts"; |
| @@ -55,7 +59,7 @@ interface Values { | ||
| 55 | 59 | /** The claim code, from the form's body. */ |
| 56 | 60 | readonly token?: string; |
| 57 | 61 | } |
| 58 | type Errors = Partial<Record<"username" | "email" | "passkey", string>>; | |
| 62 | type Errors = Partial<Record<"username" | "email" | "password" | "passkey", string>>; | |
| 59 | 63 | |
| 60 | 64 | function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response { |
| 61 | 65 | const says = { |
| @@ -166,18 +170,29 @@ function claimForm( | ||
| 166 | 170 | type: "email", |
| 167 | 171 | value: values.email ?? "", |
| 168 | 172 | autocomplete: "email", |
| 169 | required: true, | |
| 173 | hint: "Optional. A passkey needs one.", | |
| 170 | 174 | ...(errors.email ? { error: errors.email } : {}), |
| 171 | 175 | })} |
| 176 | ${formField({ | |
| 177 | label: "Password", | |
| 178 | name: "password", | |
| 179 | type: "password", | |
| 180 | autocomplete: "new-password", | |
| 181 | minlength: MIN_SIGNUP_PASSWORD, | |
| 182 | hint: `At least ${MIN_SIGNUP_PASSWORD} characters.`, | |
| 183 | ...(errors.password ? { error: errors.password } : {}), | |
| 184 | })} | |
| 172 | 185 | ${passkeyFields()} |
| 173 | <p class="muted small mb"> | |
| 174 | You sign in with a passkey: a fingerprint, face or device PIN. | |
| 175 | There is no password to remember. | |
| 176 | </p> | |
| 177 | <p class="passkey-note mb" id="passkey-make-note" role="alert">${errors.passkey ?? ""}</p> | |
| 178 | <noscript><p class="muted small">Making the passkey needs JavaScript.</p></noscript> | |
| 179 | 186 | <div class="form-actions"> |
| 180 | <button type="submit" id="passkey-make" class="btn" data-variant="primary" data-options="/claim/options">Claim this forge with a passkey</button> | |
| 187 | <button type="submit" class="btn" data-variant="primary">Claim this forge</button> | |
| 188 | </div> | |
| 189 | <div class="passkey-on"> | |
| 190 | <p class="muted small mb"> | |
| 191 | Or skip the password: a passkey signs you in with a fingerprint, | |
| 192 | face or device PIN. It needs an email address above. | |
| 193 | </p> | |
| 194 | <p class="passkey-note mb" id="passkey-make-note" role="alert">${errors.passkey ?? ""}</p> | |
| 195 | <button type="button" id="passkey-make" class="btn" data-variant="outline" data-options="/claim/options" data-needs-email>Claim with a passkey instead</button> | |
| 181 | 196 | </div> |
| 182 | 197 | </form>`, |
| 183 | 198 | )} |
| @@ -216,14 +231,18 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> { | ||
| 216 | 231 | }); |
| 217 | 232 | } |
| 218 | 233 | |
| 219 | /** Whether a name and address are fit for the first admin; else the words. */ | |
| 220 | function problems(ctx: Ctx, username: string, email: string): Errors { | |
| 234 | /** | |
| 235 | * Whether a name and address are fit for the first admin; else the words. | |
| 236 | * The address is optional unless a passkey is being made. | |
| 237 | */ | |
| 238 | function problems(ctx: Ctx, username: string, email: string, needEmail: boolean): Errors { | |
| 221 | 239 | const errors: Errors = {}; |
| 222 | 240 | if (!USERNAME.test(username)) |
| 223 | 241 | errors.username = "Usernames are letters, digits, dots, dashes."; |
| 224 | 242 | else if (getUserByName(ctx.app.db, username)) |
| 225 | 243 | errors.username = "That username is taken."; |
| 226 | if (!email.includes("@")) errors.email = "An email address, please."; | |
| 244 | if (email !== "" ? !givenEmail(email) : needEmail) | |
| 245 | errors.email = "That does not look like an email address."; | |
| 227 | 246 | return errors; |
| 228 | 247 | } |
| 229 | 248 | |
| @@ -244,7 +263,7 @@ export async function claimOptions(ctx: Ctx): Promise<Response> { | ||
| 244 | 263 | if (state !== "ok") |
| 245 | 264 | return refuse(state === "unknown" ? 404 : 410, state === "unknown" ? "This is not a claim link." : `This link is ${state === "owned" ? "already claimed" : state}.`); |
| 246 | 265 | const username = str(data.username).trim(); |
| 247 | const bad = problems(ctx, username, str(data.email).trim()); | |
| 266 | const bad = problems(ctx, username, str(data.email).trim(), true); | |
| 248 | 267 | const first = bad.username ?? bad.email; |
| 249 | 268 | if (first !== undefined) return refuse(422, first); |
| 250 | 269 | return json(makeOptions(ctx, "claim", username)); |
| @@ -279,18 +298,27 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 279 | 298 | }, |
| 280 | 299 | 422, |
| 281 | 300 | ); |
| 282 | const errors = problems(ctx, values.username, values.email); | |
| 301 | const password = typeof form.get("password") === "string" ? (form.get("password") as string) : ""; | |
| 302 | const withKey = field(form, "attestationObject") !== ""; | |
| 303 | const errors = problems(ctx, values.username, values.email.trim(), withKey); | |
| 304 | if (!withKey && password.length < MIN_SIGNUP_PASSWORD) | |
| 305 | errors.password = `Passwords need at least ${MIN_SIGNUP_PASSWORD} characters.`; | |
| 283 | 306 | if (Object.keys(errors).length > 0) return again(errors); |
| 284 | // The key, or nothing: a password in the form is not read at all. | |
| 285 | const key = await readPasskey(ctx, "claim", form); | |
| 286 | if (!key.ok) return again({ passkey: PASSKEY_WORDS[key.error] }); | |
| 307 | // A key in the form is the passkey door; none is the password door. | |
| 308 | let credential: Credential | undefined; | |
| 309 | if (withKey) { | |
| 310 | const key = await readPasskey(ctx, "claim", form); | |
| 311 | if (!key.ok) return again({ passkey: PASSKEY_WORDS[key.error] }); | |
| 312 | credential = key.value; | |
| 313 | } | |
| 287 | 314 | |
| 288 | 315 | // Spent first, in one statement: of two people on one link, one wins. |
| 289 | 316 | const spent = burnClaim(ctx.app.db, orgId, token); |
| 290 | 317 | if (spent.state !== "ok") return dead(ctx, spent.state); |
| 291 | 318 | const made = await createUser(ctx.app.db, { |
| 292 | 319 | username: values.username, |
| 293 | email: values.email, | |
| 320 | email: values.email.trim() === "" ? noEmail(values.username) : values.email.trim(), | |
| 321 | ...(credential === undefined ? { password } : {}), | |
| 294 | 322 | isAdmin: true, |
| 295 | 323 | // The one-time claim link the owner handed out is the proof. |
| 296 | 324 | trusted: true, |
| @@ -306,7 +334,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 306 | 334 | : { username: "That username is taken." }, |
| 307 | 335 | ); |
| 308 | 336 | } |
| 309 | if (!addPasskey(ctx.app.db, made.value.id, "Passkey", key.value).ok) { | |
| 337 | if (credential !== undefined && !addPasskey(ctx.app.db, made.value.id, "Passkey", credential).ok) { | |
| 310 | 338 | discardFreshUser(ctx.app.db, made.value.id); |
| 311 | 339 | unburnClaim(ctx.app.db, orgId, token); |
| 312 | 340 | return again({ passkey: PASSKEY_WORDS.taken }); |
src/web/passkey-client.ts +8
| @@ -127,6 +127,14 @@ export const passkeyJs = /* js */ ` | ||
| 127 | 127 | wire('passkey-make', function () { |
| 128 | 128 | var el = doc.getElementById('passkey-make'), form = el.form; |
| 129 | 129 | function val(n) { var f = form.elements[n]; return f ? f.value : ''; } |
| 130 | var note = doc.getElementById('passkey-make-note'); | |
| 131 | // A passkey needs an address where the password does not: say so here, not after a round trip. | |
| 132 | if (el.hasAttribute('data-needs-email') && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(val('email').trim())) { | |
| 133 | if (note) note.textContent = 'A passkey needs an email address above.'; | |
| 134 | if (form.elements.email) form.elements.email.focus(); | |
| 135 | el.disabled = false; | |
| 136 | return Promise.resolve(); | |
| 137 | } | |
| 130 | 138 | if (form.reportValidity && !form.reportValidity()) { el.disabled = false; return Promise.resolve(); } |
| 131 | 139 | var who = { token: val('token'), username: val('username'), email: val('email') }; |
| 132 | 140 | return post(el.getAttribute('data-options'), who).then(function (o) { |
test/claim-link.test.ts +57 −10
| @@ -5,8 +5,7 @@ | ||
| 5 | 5 | * the browser and posts it in the body. Any site admin of the parent can |
| 6 | 6 | * re-issue a link for a child nobody has claimed; the old one dies at once. |
| 7 | 7 | * Through MCP the link stays out of the agent's conversation unless asked |
| 8 | * for there on purpose (#839). The account a link makes has a passkey and | |
| 9 | * no password (#1890). | |
| 8 | * for there on purpose (#839). The account a link makes has a password or a passkey (#2924). | |
| 10 | 9 | */ |
| 11 | 10 | |
| 12 | 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| @@ -42,6 +41,7 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 42 | 41 | let bobRw = ""; |
| 43 | 42 | let first = ""; |
| 44 | 43 | let second = ""; |
| 44 | let kaiBody = ""; | |
| 45 | 45 | |
| 46 | 46 | const reissue = (token: string, name = "maria", origin?: string) => |
| 47 | 47 | fetch(`${parentServers.url}/api/v1/forges/${name}/claim-link`, { |
| @@ -224,15 +224,28 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 224 | 224 | expect(live.status).toBe(200); |
| 225 | 225 | // A claim challenge, kept to try at the everyday form once signed in. |
| 226 | 226 | const claimOnly = await passkeyFields(childServers.url, "/claim/options", takenForm); |
| 227 | // A password and no passkey is refused, and spends nothing. | |
| 228 | const password = await claim({ | |
| 229 | token: tokenOf(second), | |
| 230 | username: "maria-owner", | |
| 231 | email: "[email protected]", | |
| 232 | password: PASSWORD, | |
| 227 | // A password too short is refused, and spends nothing. | |
| 228 | const weak = await claim({ token: tokenOf(second), username: "maria-owner", password: "short" }); | |
| 229 | expect(weak.status).toBe(422); | |
| 230 | expect(await weak.text()).toContain("Passwords need at least 12 characters."); | |
| 231 | expect(getUserByName(child.db, "maria-owner")).toBeUndefined(); | |
| 232 | // A malformed address is refused on the password door, and so is a stand-in typed by hand. | |
| 233 | for (const email of ["not-an-address", "[email protected]"]) { | |
| 234 | const bad = await claim({ token: tokenOf(second), username: "maria-owner", email, password: PASSWORD }); | |
| 235 | expect(bad.status).toBe(422); | |
| 236 | expect(await bad.text()).toContain("That does not look like an email address."); | |
| 237 | } | |
| 238 | // The passkey door needs an address: no challenge without one, and a filled form that lost it is refused. | |
| 239 | const noMail = await fetch(`${childServers.url}/claim/options`, { | |
| 240 | method: "POST", | |
| 241 | headers: { origin: childServers.url, "content-type": "application/json" }, | |
| 242 | body: JSON.stringify({ token: tokenOf(second), username: "maria-owner" }), | |
| 233 | 243 | }); |
| 234 | expect(password.status).toBe(422); | |
| 235 | expect(await password.text()).toContain("Passwords are not taken here."); | |
| 244 | expect(noMail.status).toBe(422); | |
| 245 | const keyed = { token: tokenOf(second), username: "maria-owner", email: "m@x.test" }; | |
| 246 | const lost = await claim({ ...(await passkeyFields(childServers.url, "/claim/options", keyed)), token: keyed.token, username: keyed.username }); | |
| 247 | expect(lost.status).toBe(422); | |
| 248 | expect(await lost.text()).toContain("That does not look like an email address."); | |
| 236 | 249 | expect(getUserByName(child.db, "maria-owner")).toBeUndefined(); |
| 237 | 250 | const ok = await claimWithKey({ |
| 238 | 251 | token: tokenOf(second), |
| @@ -308,6 +321,7 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 308 | 321 | }), |
| 309 | 322 | }); |
| 310 | 323 | expect(made.status).toBe(201); |
| 324 | kaiBody = ((await made.json()) as { bodyId: string }).bodyId; | |
| 311 | 325 | const send = () => |
| 312 | 326 | fetch(`${parentServers.url}/api/v1/forges/kai/claim-link`, { |
| 313 | 327 | method: "POST", |
| @@ -356,4 +370,37 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 356 | 370 | expect(ro.error).toBe(true); |
| 357 | 371 | expect(ro.text).not.toContain("/claim#"); |
| 358 | 372 | }); |
| 373 | ||
| 374 | test("a username, an email and a password make the first admin, who can sign in with it", async () => { | |
| 375 | const kid = createApp({ dataDir: childDirOf(parent, kaiBody), port: 0 }); | |
| 376 | await firstBoot(kid); | |
| 377 | const kidServers = await serve(kid); | |
| 378 | try { | |
| 379 | const reissued = await fetch(`${parentServers.url}/api/v1/forges/kai/claim-link`, { | |
| 380 | method: "POST", | |
| 381 | headers: { authorization: `Bearer ${adminRw}` }, | |
| 382 | }); | |
| 383 | const token = tokenOf(((await reissued.json()) as { claimUrl: string }).claimUrl); | |
| 384 | const res = await fetch(`${kidServers.url}/claim`, { | |
| 385 | method: "POST", | |
| 386 | headers: { origin: kidServers.url }, | |
| 387 | body: new URLSearchParams({ token, username: "kai-owner", email: "[email protected]", password: PASSWORD }), | |
| 388 | redirect: "manual", | |
| 389 | }); | |
| 390 | expect(res.status).toBe(303); | |
| 391 | expect(res.headers.get("set-cookie") ?? "").toContain("forge_session="); | |
| 392 | const owner = getUserByName(kid.db, "kai-owner")!; | |
| 393 | expect(owner.isAdmin).toBe(true); | |
| 394 | expect(owner.email).toBe("[email protected]"); | |
| 395 | expect(await Bun.password.verify(PASSWORD, (kid.db.query("SELECT password_hash AS h FROM user WHERE id = ?1").get(owner.id) as { h: string }).h)).toBe(true); | |
| 396 | } finally { | |
| 397 | kidServers.stop(); | |
| 398 | kid.close(); | |
| 399 | } | |
| 400 | }); | |
| 401 | ||
| 402 | test("the passkey button asks for the email in the browser, before any round trip", async () => { | |
| 403 | const { passkeyJs } = await import("../src/web/passkey-client.ts"); | |
This only asserts the string exists in the script; it would pass if the check were broken. Evaluate the handler against a stub DOM or rename the test to say what it covers. |
||
| 404 | expect(passkeyJs).toContain("data-needs-email"); | |
| 405 | }); | |
| 359 | 406 | }); |
test/spawn-first-boot.test.ts +9 −5
| @@ -296,10 +296,10 @@ describe("a spawned child's first boot", () => { | ||
| 296 | 296 | expect(unspent(token)).toBe(true); |
| 297 | 297 | }); |
| 298 | 298 | |
| 299 | test("a password and no passkey is refused, and the link is not spent on it", async () => { | |
| 300 | const res = await claim(token, { ...maria, password: "a-long-bakery-passphrase" }); | |
| 299 | test("a password too short is refused, and the link is not spent on it", async () => { | |
| 300 | const res = await claim(token, { ...maria, password: "short" }); | |
| 301 | 301 | expect(res.status).toBe(422); |
| 302 | expect(await res.text()).toContain("Passwords are not taken here."); | |
| 302 | expect(await res.text()).toContain("Passwords need at least 12 characters."); | |
| 303 | 303 | expect(getUserByName(child.db, "maria")).toBeUndefined(); |
| 304 | 304 | expect(unspent(token)).toBe(true); |
| 305 | 305 | }); |
| @@ -453,14 +453,18 @@ describe("claims are bound to a spawned forge, and the link is shown once", () = | ||
| 453 | 453 | try { |
| 454 | 454 | const page = (await (await fetch(`${kidServers.url}/claim`)).text()).replace(/\s+/g, " "); |
| 455 | 455 | expect(page).toContain("alice keeps owner access to this forge; you can remove it in your organisation"); |
| 456 | const who = { token, username: "kai-owner", email: "kai@x.test" }; | |
| 456 | // A username and a password, no email: the first admin, signed in. | |
| 457 | 457 | const res = await fetch(`${kidServers.url}/claim`, { |
| 458 | 458 | method: "POST", |
| 459 | 459 | headers: { origin: kidServers.url }, |
| 460 | body: new URLSearchParams({ ...who, ...(await passkeyFields(kidServers.url, "/claim/options", who)) }), | |
| 460 | body: new URLSearchParams({ token, username: "kai-owner", password: "a-long-kai-passphrase" }), | |
| 461 | 461 | redirect: "manual", |
| 462 | 462 | }); |
| 463 | 463 | expect(res.status).toBe(303); |
| 464 | expect(res.headers.get("set-cookie") ?? "").toContain("forge_session="); | |
| 465 | const admin = getUserByName(kid.db, "kai-owner")!; | |
| 466 | expect(admin.isAdmin).toBe(true); | |
| 467 | expect(admin.email).toBe("[email protected]"); | |
| 464 | 468 | const org = getOrg(kid.db, "kai")!; |
| 465 | 469 | const owners = listTeams(kid.db, org.id).find((t) => t.name === "Owners")!; |
| 466 | 470 | expect(listTeamMembers(kid.db, owners.id).map((u) => u.username).sort()).toEqual(["alice", "kai-owner"]); |
new above
offline
Empty email with needEmail gets the "does not look like" wording. Split the cases: empty + needEmail -> "A passkey needs an email address."; non-empty and not givenEmail -> the current text.