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

The claim page takes a username and a password; email optional #2987

Merged opened this

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

Merged

scout/forge:scout/2924-the-claim-page-takes-a-username-and-a-pa → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2924-the-claim-page-takes-a-username-and-a-pa was merged into main as 060d16a.

Review

Sign in to review this change.
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
350350 that lasts 30 minutes and works once registers a new passkey and signs you in.
351351 The relying party is the address the browser used (or
352352 `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.
354354
355355 A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned
356356 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;
9090 * unique and never empty, and `.invalid` is a name no mail can reach
9191 * (the same device the crew's addresses use).
9292 */
93 const noEmail = (username: string): string => `${username.toLowerCase()}@no-email.invalid`;
93 export const noEmail = (username: string): string => `${username.toLowerCase()}@no-email.invalid`;
9494
9595 /** 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);
9797
9898 /** What a person is told when the check fails. */
9999 export const HUMAN_CHECK_MESSAGE =
src/web/pages/claim.ts +55 −27
@@ -1,14 +1,16 @@
11 /**
22 * `/claim`: the page a spawned child's link opens (`spawn/claim.ts`).
33 * 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.
1214 *
1315 * Who keeps access is never silent. By default the spawner's account leaves
1416 * the organisation's Owners when the forge is claimed (the new owner can
@@ -16,6 +18,8 @@
1618 * so before anyone submits, and where to remove it.
1719 */
1820
21 import { givenEmail, MIN_SIGNUP_PASSWORD, noEmail } from "../../actions/users.ts";
22 import type { Credential } from "../../auth/webauthn.ts";
1923 import { crossOrigin, type Ctx } from "../../http/context.ts";
2024 import { STRANGER_MAX } from "../../http/limits.ts";
2125 import { clientAddress } from "../../http/peer.ts";
@@ -55,7 +59,7 @@ interface Values {
5559 /** The claim code, from the form's body. */
5660 readonly token?: string;
5761 }
58 type Errors = Partial<Record<"username" | "email" | "passkey", string>>;
62 type Errors = Partial<Record<"username" | "email" | "password" | "passkey", string>>;
5963
6064 function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response {
6165 const says = {
@@ -166,18 +170,29 @@ function claimForm(
166170 type: "email",
167171 value: values.email ?? "",
168172 autocomplete: "email",
169 required: true,
173 hint: "Optional. A passkey needs one.",
170174 ...(errors.email ? { error: errors.email } : {}),
171175 })}
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 })}
172185 ${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>
179186 <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>
181196 </div>
182197 </form>`,
183198 )}
@@ -216,14 +231,18 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> {
216231 });
217232 }
218233
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 {
221239 const errors: Errors = {};
222240 if (!USERNAME.test(username))
223241 errors.username = "Usernames are letters, digits, dots, dashes.";
224242 else if (getUserByName(ctx.app.db, username))
225243 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.";

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.

227246 return errors;
228247 }
229248
@@ -244,7 +263,7 @@ export async function claimOptions(ctx: Ctx): Promise<Response> {
244263 if (state !== "ok")
245264 return refuse(state === "unknown" ? 404 : 410, state === "unknown" ? "This is not a claim link." : `This link is ${state === "owned" ? "already claimed" : state}.`);
246265 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);
248267 const first = bad.username ?? bad.email;
249268 if (first !== undefined) return refuse(422, first);
250269 return json(makeOptions(ctx, "claim", username));
@@ -279,18 +298,27 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
279298 },
280299 422,
281300 );
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.`;
283306 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 }
287314
288315 // Spent first, in one statement: of two people on one link, one wins.
289316 const spent = burnClaim(ctx.app.db, orgId, token);
290317 if (spent.state !== "ok") return dead(ctx, spent.state);
291318 const made = await createUser(ctx.app.db, {
292319 username: values.username,
293 email: values.email,
320 email: values.email.trim() === "" ? noEmail(values.username) : values.email.trim(),
321 ...(credential === undefined ? { password } : {}),
294322 isAdmin: true,
295323 // The one-time claim link the owner handed out is the proof.
296324 trusted: true,
@@ -306,7 +334,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
306334 : { username: "That username is taken." },
307335 );
308336 }
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) {
310338 discardFreshUser(ctx.app.db, made.value.id);
311339 unburnClaim(ctx.app.db, orgId, token);
312340 return again({ passkey: PASSKEY_WORDS.taken });
src/web/passkey-client.ts +8
@@ -127,6 +127,14 @@ export const passkeyJs = /* js */ `
127127 wire('passkey-make', function () {
128128 var el = doc.getElementById('passkey-make'), form = el.form;
129129 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 }
130138 if (form.reportValidity && !form.reportValidity()) { el.disabled = false; return Promise.resolve(); }
131139 var who = { token: val('token'), username: val('username'), email: val('email') };
132140 return post(el.getAttribute('data-options'), who).then(function (o) {
test/spawn-first-boot.test.ts +9 −5
@@ -296,10 +296,10 @@ describe("a spawned child's first boot", () => {
296296 expect(unspent(token)).toBe(true);
297297 });
298298
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" });
301301 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.");
303303 expect(getUserByName(child.db, "maria")).toBeUndefined();
304304 expect(unspent(token)).toBe(true);
305305 });
@@ -453,14 +453,18 @@ describe("claims are bound to a spawned forge, and the link is shown once", () =
453453 try {
454454 const page = (await (await fetch(`${kidServers.url}/claim`)).text()).replace(/\s+/g, " ");
455455 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.
457457 const res = await fetch(`${kidServers.url}/claim`, {
458458 method: "POST",
459459 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" }),
461461 redirect: "manual",
462462 });
463463 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]");
464468 const org = getOrg(kid.db, "kai")!;
465469 const owners = listTeams(kid.db, org.id).find((t) => t.name === "Owners")!;
466470 expect(listTeamMembers(kid.db, owners.id).map((u) => u.username).sort()).toEqual(["alice", "kai-owner"]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs