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

Linked sign-in methods read "Link an account you have" and never offer to create one #2904

Merged opened this

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

Merged

scout/forge:scout/2883-linked-sign-in-methods-read-link-an-acco → main 5 commits fork 8 threads · 8 unresolved

  • Merged. scout/2883-linked-sign-in-methods-read-link-an-acco was merged into main as 1c7cc79.

Review

Sign in to review this change.
11 files changed +209 −173
README.md +3 −2
@@ -346,14 +346,15 @@ The relying party is the address the browser used (or
346346 Sign-up is a username, an email address and a passkey (`/signup`, `src/web/pages/signup-passkey.ts`); the running server refuses a password-only sign-up on the form, `POST /api/v1/users` and the terminal (`passkeyOnlySignup`, set in `main.ts`). Accounts that already have a password still sign in. 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.
347347
348348 A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned
349 forge's `parent.json`) offers "Continue with <parent>" on `/signin`. The forge
349 forge's `parent.json`) lists the parent under "Link an account you have" on `/signin`. The forge
350350 registers itself once at the parent's `/oauth/register` (the client id is kept
351351 in `rp_client`), sends the person through the parent's authorize page with
352352 PKCE, and on return checks state (also bound to the browser by an HttpOnly
353353 cookie), nonce, `iss`, `aud`, `exp` and the ES256 signature against the
354354 parent's JWKS (`kernel/jwt.ts`; keys are cached ten minutes and an unknown
355355 `kid` refetches them once a minute at most). The parent's `sub` — never the
356 username — maps to a local account made on first sight with no password
356 username — maps to a local account that is already linked to it; a `sub` nobody
357 holds makes no user and ends on `/signup` (#2883)
357358 (`core/federation.ts`; `http/rp.ts` has the two routes). No dependency added.
358359 The parent's answers are read by bytes and refused past 16 KiB (64 KiB for the
359360 key set), and starting a sign-in is throttled like a password attempt.
src/core/federation.ts +13 −73
@@ -12,8 +12,7 @@ import { join } from "node:path";
1212 import { sha256 } from "../kernel/hash.ts";
1313 import { parentUrlProblem } from "./parent_releases.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
15 import { createUser, getUserById, getUserByName, isActive, type User } from "./users.ts";
16 import type { SignupSlot } from "../actions/signup-window.ts";
15 import { getUserById, isActive, type User } from "./users.ts";
1716
1817 /** Where the parent sends a person back; the address registered at the parent. */
1918 export const CALLBACK_PATH = "/signin/parent/callback";
@@ -78,79 +77,20 @@ export function takeLogin(db: Database, state: string, now = Date.now()): Login
7877 };
7978 }
8079
81 /** A local username for a parent's person: their name if free and fit, else made unique by their `sub`. */
82 async function makeAccount(db: Database, sub: string, preferred: string, slot: SignupSlot): Promise<User | undefined> {
83 const base = preferred.toLowerCase().replace(/[^a-z0-9.-]/g, "").replace(/^[.-]+/, "").slice(0, 30);
84 const tail = sub.toLowerCase().replace(/[^a-z0-9]/g, "").slice(-8);
85 for (const username of [base, `${base}-${tail}`, `user-${tail}`]) {
86 if (username === "" || username.startsWith("-")) continue;
87 if (getUserByName(db, username) !== undefined) continue;
88 const made = await createUser(db, {
89 username,
90 // Unique, never mailed: `.invalid` is reserved and resolves nowhere.
91 email: `${sub.toLowerCase()}@sso.invalid`,
92 displayName: preferred,
93 origin: "parent",
94 slot,
95 });
96 if (made.ok) return made.value;
97 if (made.error === "signup_closed") return undefined;
98 }
99 return undefined;
100 }
101
102 const making = new Map<string, Promise<User | undefined>>();
103
104 /** Whether this issuer's `sub` already has a local account: a return, not a first sight. */
105 export function knownIdentity(db: Database, issuer: string, sub: string): boolean {
106 return (
107 db
108 .query<{ user_id: string }, [string, string]>(
109 "SELECT user_id FROM federated_identity WHERE issuer = ?1 AND sub = ?2",
110 )
111 .get(issuer, sub) !== null
112 );
113 }
114
11580 /**
116 * The local account for an issuer's `sub`, made on first sight with no
117 * password — a stranger's sign-up, so only with a place in the sign-up
118 * window and while signup is open. Two returns of the same person at once
119 * make one account.
81 * The local account an issuer's `sub` is linked to, if it is active. A
82 * linked method signs in an account that already exists and never makes
83 * one: an identity nobody here holds gets nothing (#2883).
12084 */
121 export function accountFor(
122 db: Database,
123 issuer: string,
124 sub: string,
125 preferred: string,
126 slot: SignupSlot | undefined,
127 ): Promise<User | undefined> {
128 const key = `${issuer}\0${sub}`;
129 const pending = making.get(key);
130 if (pending !== undefined) return pending;
131 const attempt = (async (): Promise<User | undefined> => {
132 const known = db
133 .query<{ user_id: string }, [string, string]>(
134 "SELECT user_id FROM federated_identity WHERE issuer = ?1 AND sub = ?2",
135 )
136 .get(issuer, sub);
137 if (known !== null) {
138 const user = getUserById(db, known.user_id);
139 return user !== undefined && isActive(user) ? user : undefined;
140 }
141 if (slot === undefined) return undefined;
142 const made = await makeAccount(db, sub, preferred, slot);
143 if (made === undefined) return undefined;
144 db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)").run(
145 issuer,
146 sub,
147 made.id,
148 );
149 return made;
150 })();
151 making.set(key, attempt);
152 void attempt.finally(() => making.delete(key));
153 return attempt;
85 export function linkedAccount(db: Database, issuer: string, sub: string): User | undefined {
86 const known = db
87 .query<{ user_id: string }, [string, string]>(
88 "SELECT user_id FROM federated_identity WHERE issuer = ?1 AND sub = ?2",
89 )
90 .get(issuer, sub);
91 if (known === null) return undefined;
92 const user = getUserById(db, known.user_id);
93 return user !== undefined && isActive(user) ? user : undefined;
15494 }
15595
15696 /** The spawn record's upstream, read once per forge: it does not change while the process runs. */
src/core/users.ts +1 −5
@@ -124,7 +124,7 @@ export type CreateUserError =
124124 * code, and `seed`, which no request reaches. Every other origin makes a person who is not an admin, even on a
125125 * forge with no admin at all: no sign-up becomes the admin by being first.
126126 */
127 export type StrangerOrigin = "signup" | "passkey" | "parent";
127 export type StrangerOrigin = "signup" | "passkey";
128128 export type AdminOrigin = "claim" | "first-admin" | "seed";
129129 export type ExemptOrigin = AdminOrigin | "everyday" | "agent" | "spawn";
130130 export type AccountOrigin =
@@ -133,7 +133,6 @@ export type AccountOrigin =
133133 * signup is `invite` it opens the door once, spent with the account.
134134 */
135135 | { origin: "signup" | "passkey"; slot: SignupSlot; invite?: string | undefined }
136 | { origin: "parent"; slot: SignupSlot }
137136 | { origin: "edge" }
138137 | { origin: Exclude<ExemptOrigin, AdminOrigin> }
139138 | ({ origin: AdminOrigin } & AdminFields);
@@ -240,9 +239,6 @@ function admission(db: Database, input: AccountOrigin): Admission {
240239 return newAccountsClosed(db, input.invite) || input.invite === undefined
241240 ? REFUSED
242241 : { admitted: true, spend: input.invite };
243 case "parent":
244 if (input.slot.spent) throw new Error("a sign-up slot makes one account");
245 return newAccountsClosed(db) ? REFUSED : ADMITTED;
246242 case "edge":
247243 return newAccountsClosed(db) ? REFUSED : ADMITTED;
248244 case "claim":
src/http/rp.ts +12 −19
@@ -1,20 +1,20 @@
11 /**
2 * Continue with the parent forge (#748): this forge as a relying party of
2 * Sign in through the parent forge (#748): this forge as a relying party of
33 * the OpenID provider its parent already is. `/signin/parent` registers
44 * this forge at the parent once (the client id is kept), then sends the
55 * person there with a state, a nonce and a PKCE challenge; the state also
66 * rides in an HttpOnly cookie so only the browser that began can finish.
77 * `/signin/parent/callback` trades the code, checks the id_token (state,
88 * nonce, iss, aud, exp, signature against the parent's JWKS) and signs in
9 * the local account the parent's `sub` maps to, made on first sight.
9 * the local account the parent's `sub` is linked to; a `sub` nobody here
10 * holds makes no account and ends on `/signup`.
1011 * Both are GETs that change nothing a stranger could use: a callback with
1112 * no matching cookie and state is refused before anything is fetched.
1213 */
1314 import { createHash, randomBytes } from "node:crypto";
1415 import type { App } from "../app.ts";
1516 import {
16 accountFor,
17 knownIdentity,
17 linkedAccount,
1818 clientFor,
1919 keepClient,
2020 parentIssuerOf,
@@ -26,7 +26,7 @@ import {
2626 } from "../core/federation.ts";
2727 import { readCapped } from "../origins/providers/http.ts";
2828 import { clientAddress } from "./peer.ts";
29 import { signinAllowed, signupSlot } from "../web/pages/site.ts";
29 import { signinAllowed } from "../web/pages/site.ts";
3030 import { createSession } from "../core/sessions.ts";
3131 import { verifyJwt } from "../kernel/jwt.ts";
3232 import { html } from "../web/html.ts";
@@ -171,10 +171,6 @@ async function begin(ctx: Ctx, linkUser?: string): Promise<Response> {
171171 return new Response(null, { status: 303, headers });
172172 }
173173
174 const clean = (s: unknown, max: number): string =>
175 // eslint-disable-next-line no-control-regex
176 typeof s === "string" ? s.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, max) : "";
177
178174 export async function parentCallback(ctx: Ctx): Promise<Response> {
179175 const q = ctx.url.searchParams;
180176 const state = q.get("state") ?? "";
@@ -236,17 +232,14 @@ export async function parentCallback(ctx: Ctx): Promise<Response> {
236232 back.headers.set("cache-control", "no-store");
237233 return back;
238234 }
239 const name = clean(verified.value["preferred_username"], 64);
240 // A returning person is a sign-in; first sight of a sub makes an account,
241 // and that is a sign-up, held to the sign-up limit before it is made.
242 const slot = knownIdentity(ctx.app.db, login.issuer, sub) ? undefined : signupSlot(ctx);
243 if (typeof slot === "number") {
244 const slow = refuse(`Too many new accounts from here. Try again in ${slot}s.`, 429);
245 slow.headers.set("retry-after", String(slot));
246 return slow;
235 // A linked method signs in an account that exists; it never makes one.
236 const user = linkedAccount(ctx.app.db, login.issuer, sub);
237 if (user === undefined) {
238 const done = redirect("/signup?linked=unknown");
239 done.headers.append("set-cookie", clear);
240 done.headers.set("cache-control", "no-store");
241 return done;
247242 }
248 const user = await accountFor(ctx.app.db, login.issuer, sub, name, slot);
249 if (user === undefined) return refuse("An account for you could not be made or is not allowed in.", 403);
250243 const session = createSession(ctx.app.db, user.id);
251244 const done = redirect("/", sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url));
252245 done.headers.append("set-cookie", clear);
src/web/pages/site.ts +30 −33
@@ -24,6 +24,7 @@ import { hasAnyUser, verifyPasswordThrottled, type User } from "../../core/users
2424 import { isThemeName } from "../../design/themes.ts";
2525 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
2626 import { studioDoor, studioHome } from "../studio/home.ts";
27 import { parentIssuerOf } from "../../core/federation.ts";
2728 import { activeEdgeToken, edgeIdentity, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts";
2829 import { sshCommand } from "../../ssh/command.ts";
2930 import { hostOf, pageHead, signInFirst } from "../chrome.ts";
@@ -46,7 +47,6 @@ import {
4647 import { existsSync } from "node:fs";
4748 import { join } from "node:path";
4849 import { hasAdmin } from "../../spawn/claim.ts";
49 import { parentIssuerOf } from "../../core/federation.ts";
5050 import { getSignup, type SignupMode } from "../../settings/signup.ts";
5151
5252 export function sessionCookie(
@@ -232,6 +232,23 @@ const SIGNUP_SENTENCES: Record<SignupMode, string | undefined> = {
232232 closed: "This forge is closed to new accounts.",
233233 };
234234
235 /**
236 * The linked sign-in methods (the platform edge, the parent forge) are doors
outdated

The comment breaks mid-sentence. Reflow it into one sentence.

237 * for an account that already exists and never make one (#2883).
238 */
239 function linkedMethods(edge: string | undefined, next: string | undefined, parent: string | undefined): Html | null {
240 if (edge === undefined && parent === undefined) return null;
outdated

With this early return, the two when(edge, …) calls below are always true. Once the parent door is back, return null only when neither method exists, and wrap each method in its own when.

241 return html`<section class="stack mt" id="linked-methods">
242 <h2>Link an account you have</h2>
243 <p class="muted small">These sign in an account that already exists and never make one.</p>
244 ${when(edge, (name) => html`<p class="muted small">Accounts made through ${name} sign in there and have no password here. The form above is for an account that was given one.</p>`)}
245 <div class="stack">
246 ${when(edge, (name) => btn(`Sign in through ${name}`, { href: edgeLoginUrl(next ?? "/"), variant: "outline" }))}
247 ${when(parent, (url) => btn(`Sign in through ${new URL(url).host}`, { href: "/signin/parent", variant: "outline" }))}
248 </div>
249 </section>`;
250 }
251
235252 function authForm(
236253 ctx: Ctx,
237254 kind: "signin" | "signup",
@@ -251,32 +268,20 @@ function authForm(
251268 // of the whole user table — and the sign-in screen paints on every session.
252269 const firstAccount = kind === "signup" && !hasAnyUser(ctx.app.db);
253270
254 // Behind a platform edge, that is the door: the button first, and the
255 // password form kept for the accounts that have one — an account the edge
256 // made has none, and the note says so before anyone types.
257 // A newcomer the edge vouched for is already through it: no button to go
271 // A newcomer the edge vouched for is already through it: no door to go
258272 // round again, and the name is theirs alone.
259273 const vouched = edgeStrangerLogin(ctx.request, activeEdgeToken(ctx.app), ctx.auth.user);
260274 const edge =
261275 activeEdgeToken(ctx.app) === undefined || vouched !== undefined
262276 ? undefined
263277 : (ctx.app.config.edgeName ?? "the platform");
264 const throughEdge = when(
265 edge,
266 (name) =>
267 html`<div class="stack mb">
268 ${btn(`Continue with ${name}`, {
269 href: edgeLoginUrl(next ?? "/"),
270 variant: "primary",
271 size: "lg",
272 })}
273 <p class="muted small">
274 ${kind === "signin"
275 ? `Accounts made through ${name} sign in there and have no password here. The form below is for an account that was given one.`
276 : `Signing in through ${name} does not make an account here; the form below does.`}
277 </p>
278 </div>`,
279 );
278 const unknownLink =
279 kind === "signup" && ctx.url.searchParams.get("linked") === "unknown"
280 ? html`<p class="muted small mb" id="linked-unknown" role="status">
281 No account here is linked to that sign-in, and a linked sign-in never makes one.
282 To get an account, make one with the form below.
outdated

High: this sentence promises something that doesn't exist. Settings links only the open-platform.sh edge login (/settings/edge/link). Nothing links a parent-forge identity, and after this pull nothing writes federated_identity at all. Either build the parent link in settings, or say what is actually true here.

Minor, not blocking: on a forge whose sign-up is closed or invite, "make one with the form below" points at a form that will refuse them. The #signup-mode sentence covers that state on /signin, so this could reuse SIGNUP_SENTENCES[getSignup(db)] and only offer the form when sign-up is open. Worth a follow-up if it bothers anyone.

283 </p>`
284 : null;
280285
281286 // No door makes a password-only account: sign-up is a name, an address and
282287 // a passkey, made in the browser (`signup-passkey.ts`).
@@ -298,7 +303,7 @@ function authForm(
298303 ${when(SIGNUP_SENTENCES[getSignup(ctx.app.db)], (said) => html`<p class="muted small mb" id="signup-mode">${said}</p>`)}`,
299304 );
300305
301 const body = html`${throughEdge}
306 const body = html`${unknownLink}
302307 ${passkeyFirst}
303308 <form method="post" action="${action}">
304309 ${formField({
@@ -404,15 +409,7 @@ function authForm(
404409 kind === "signin",
405410 () => html`<p class="mt"><a class="small" href="/recover">Lost your device?</a></p>`,
406411 )}
outdated

High: this drops the parent door that every existing parent-made account (no password, no passkey) needs to sign in. Pass the parent issuer in as well and render Sign in through <host> → /signin/parent here. An unknown sub is now harmless: it makes no user and ends on /signup.

407 ${when(
408 kind === "signin" ? parentIssuerOf(ctx.app.config) : undefined,
409 (parent) => html`<p class="mt">
410 ${btn(`Continue with ${new URL(parent).host}`, {
411 href: "/signin/parent",
412 variant: "outline",
413 })}
414 </p>`,
415 )}
412 ${kind === "signin" ? linkedMethods(edge, next, parentIssuerOf(ctx.app.config)) : null}
416413 ${claimable
417414 ? html`<p class="muted small mt" id="claim-door">
418415 Was this forge made for you? <a href="/claim">Claim it with your code</a>.
@@ -484,8 +481,8 @@ export function signinAllowed(ctx: Ctx, key: string): number | undefined {
484481
485482 /**
486483 * Sign-ups by the socket's peer (`actions/signup-window.ts`), asked by the
487 * signup form, the API signup, the terminal's create screen, the passkey door
488 * and the parent sign-in's first sight of a person, which share one window.
484 * signup form, the API signup, the terminal's create screen and
485 * the passkey door, which share one window.
489486 */
490487 export function signupAllowed(ctx: Ctx): number | undefined {
491488 return signupWindow(ctx.app, clientAddress(ctx.request));
test/edge-auth.test.ts +3 −2
@@ -170,12 +170,13 @@ describe("a forge behind the platform edge", () => {
170170 expect(response.headers.get("location")).toBe("/_plat/login?rd=%2Fnew");
171171 });
172172
173 test("the forge's own sign-in page leads with the edge and says who has no password", async () => {
173 test("the forge's own sign-in page offers the edge as a linked method and says who has no password", async () => {
174174 const page = await fetch(`${base}/signin?next=%2Fnew`).then((r) =>
175175 r.text(),
176176 );
177177 expect(page).toContain('href="/_plat/login?rd=%2Fnew"');
178 expect(page).toContain("Continue with the platform");
178 expect(page).toContain("Link an account you have");
179 expect(page).not.toContain("Continue with");
179180 expect(page).toContain("have no password here");
180181 // The password form is still there for an account that was given one.
181182 expect(page).toContain('name="password"');
test/forge-signin-signup.test.ts +22 −20
@@ -1,8 +1,7 @@
11 /**
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.
2 * The parent's sign-in makes no account, so it never touches the sign-up
3 * window the passkey door spends: a linked person signs in, an unknown one
4 * is sent to /signup.
65 */
76 import { afterAll, beforeAll, expect, test } from "bun:test";
87 import { mkdtemp, rm } from "node:fs/promises";
@@ -80,23 +79,26 @@ afterAll(async () => {
8079 for (const dir of dirs) await rm(dir, { recursive: true, force: true });
8180 });
8281
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);
82 test("a linked sub signs in and is not throttled; an unknown sub makes no account and spends no sign-up window; the passkey door keeps the window", async () => {
83 const amy = await createUser(child.db, { origin: "seed", username: "amy", email: "amy@child.local", password: "a-long-family-passphrase", trusted: true });
84 if (!amy.ok) throw new Error(amy.error);
85 const amyParent = getUserByName(parent.db, "amy")!;
86 child.db
87 .query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)")
88 .run(new URL(ps.url).origin, amyParent.id, amy.value.id);
89 for (let i = 0; i < 3; i++) {
90 const done = await through(cookies.amy!);
91 expect(done.status).toBe(303);
92 expect(cookieOf(done, "forge_session")).toBeDefined();
93 }
8794
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");
95 const unknown = await through(cookies.ben!);
96 expect(unknown.status).toBe(303);
97 expect(unknown.headers.get("location")).toStartWith("/signup");
9298 expect(getUserByName(child.db, "ben")).toBeUndefined();
9399
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);
100 // The window is untouched by all of that: two passkey sign-ups, then the wait.
101 expect((await passkeySignUp("pk1")).status).toBe(201);
102 expect((await passkeySignUp("pk2")).status).toBe(201);
103 expect((await passkeySignUp("pk3")).status).toBe(429);
102104 });
test/forge-signin.test.ts +16 −6
@@ -93,6 +93,12 @@ describe("sign in through the parent forge", () => {
9393 });
9494 if (!made.ok) throw new Error(made.error);
9595 alice = made.value.id;
96 // A linked method signs in an account that exists: alice has one here.
97 const local = await createUser(child.db, { origin: "seed", username: "alice", email: "[email protected]" });
98 if (!local.ok) throw new Error(local.error);
99 child.db
100 .query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)")
101 .run(new URL(parentServers.url).origin, alice, local.value.id);
96102 aliceCookie = `forge_session=${createSession(parent.db, alice).token}`;
97103 });
98104 afterAll(async () => {
@@ -103,7 +109,9 @@ describe("sign in through the parent forge", () => {
103109
104110 test("only a forge with a parent offers the button", async () => {
105111 const onChild = await (await fetch(`${childServers.url}/signin`)).text();
106 expect(onChild).toContain(`Continue with ${new URL(parentServers.url).host}`);
112 expect(onChild).toContain("Link an account you have");
113 expect(onChild).toContain(`Sign in through ${new URL(parentServers.url).host}`);
114 expect(onChild).not.toContain("Continue with");
107115 expect(onChild).toContain('href="/signin/parent"');
108116 expect(await (await fetch(`${parentServers.url}/signin`)).text()).not.toContain("Continue with");
109117 const none = await fetch(`${parentServers.url}/signin/parent`, { redirect: "manual" });
@@ -198,18 +206,20 @@ describe("sign in through the parent forge", () => {
198206 expect(tokens()).toEqual(before);
199207 });
200208
201 test("a parent's name that is taken here does not take over the local account", async () => {
209 test("a parent's person nobody here holds gets no account, even when their name is taken here", async () => {
202210 const bob = await createUser(parent.db, { origin: "seed", username: "bob", email: "[email protected]", password: PASSWORD });
203211 if (!bob.ok) throw new Error(bob.error);
204212 const local = await createUser(child.db, { origin: "seed", username: "bob", email: "[email protected]", password: PASSWORD });
205213 if (!local.ok) throw new Error(local.error);
214 const users = () => child.db.query("SELECT COUNT(*) AS n FROM user").get();
215 const before = users();
206216 const { callback, state } = await begin(`forge_session=${createSession(parent.db, bob.value.id).token}`);
207217 const done = await finish(callback, state);
208218 expect(done.status).toBe(303);
209 const row = child.db
210 .query<{ user_id: string }, [string]>("SELECT user_id FROM federated_identity WHERE sub = ?1")
211 .get(bob.value.id)!;
212 expect(row.user_id).not.toBe(local.value.id);
219 expect(done.headers.get("location")).toStartWith("/signup");
220 expect(cookieOf(done, "forge_session")).toBeUndefined();
221 expect(child.db.query("SELECT COUNT(*) AS n FROM federated_identity WHERE sub = ?1").get(bob.value.id)).toEqual({ n: 0 });
222 expect(users()).toEqual(before);
213223 });
214224
215225 test("a callback is refused without its browser's cookie, with another state, or a second time", async () => {
test/linked-signin.test.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * Linked sign-in methods (#2883) are for an account you already have: the
3 * pages say so and never offer to "Continue with" one, and a first sign-in
4 * through one for an identity no account holds makes no user and ends on
5 * /signup with a sentence on how to get an account.
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 } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let dirs: string[] = [];
20 let parent: App;
21 let child: App;
22 let ps: Servers;
23 let cs: Servers;
24 let stranger = "";
25
26 beforeAll(async () => {
27 const make = async () => {
28 const dir = await mkdtemp(join(tmpdir(), "forge-linked-"));
29 dirs.push(dir);
30 return dir;
31 };
32 parent = createApp({ dataDir: await make(), port: 0 });
33 ps = await serve(parent);
34 child = createApp({ dataDir: await make(), port: 0, parentUrl: ps.url, parentLoopback: true, edgeToken: "edge-secret", edgeName: "the platform" });
35 cs = await serve(child);
36 const made = await createUser(parent.db, { origin: "seed", username: "newcomer", email: "[email protected]", password: "a-long-family-passphrase", adminIfFirst: true });
37 if (!made.ok) throw new Error(made.error);
38 stranger = `forge_session=${createSession(parent.db, made.value.id).token}`;
39 });
40
41 afterAll(async () => {
42 ps.stop();
43 cs.stop();
44 for (const dir of dirs) await rm(dir, { recursive: true, force: true });
45 });
46
47 test("no sign-in or sign-up page says Continue with; linked methods sit under their heading", async () => {
48 for (const path of ["/signin", "/signup", "/signin?next=%2Fnew"]) {
49 const text = await (await fetch(`${cs.url}${path}`, { redirect: "follow" })).text();
50 expect(text).not.toContain("Continue with");
51 }
52 const text = await (await fetch(`${cs.url}/signin`)).text();
53 expect(text).toContain("<h2>Link an account you have</h2>");
54 expect(text).toContain("never make one");
55 expect(text).toContain('href="/signin/parent"');
56 expect(text).toContain(`Sign in through ${new URL(ps.url).host}`);
57 expect(text).toContain("/_plat/login");
58 });
59
60 test("an unknown linked identity creates no user and ends on /signup saying how to get an account", async () => {
61 const users = () => child.db.query("SELECT COUNT(*) AS n FROM user").get();
62 const before = users();
63 const start = await fetch(`${cs.url}/signin/parent`, { redirect: "manual" });
64 const to = new URL(start.headers.get("location")!);
65 const consent = await fetch(`${ps.url}/oauth/authorize`, {
66 method: "POST",
67 redirect: "manual",
68 headers: { cookie: stranger, origin: ps.url },
69 body: new URLSearchParams({ ...Object.fromEntries(to.searchParams), decision: "allow" }),
70 });
71 const back = new URL(consent.headers.get("location")!);
72 const rp = start.headers.getSetCookie().map((c) => c.split(";")[0]!).find((c) => c.startsWith("forge_rp="))!;
73 const done = await fetch(`${cs.url}${back.pathname}${back.search}`, { redirect: "manual", headers: { cookie: rp } });
74 expect(done.status).toBe(303);
75 expect(done.headers.getSetCookie().some((c) => c.startsWith("forge_session=") && !c.startsWith("forge_session=;"))).toBe(false);
76 expect(users()).toEqual(before);
77 const landing = done.headers.get("location")!;
78 expect(landing.startsWith("/signup")).toBe(true);
79 const text = await (await fetch(`${cs.url}${landing}`, { headers: { "x-plat-edge-token": "edge-secret", "x-plat-user": "newcomer" } })).text();
80 expect(text).toContain("make one with the form below");
81 expect(users()).toEqual(before);
82 });
83
84 test("a parent identity with an existing link still signs in", async () => {
85 const mine = await createUser(child.db, { origin: "seed", username: "linked-me", email: "[email protected]" });
86 if (!mine.ok) throw new Error(mine.error);
87 const sub = (parent.db.query("SELECT id FROM user WHERE username = 'newcomer'").get() as { id: string }).id;
88 child.db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)").run(new URL(ps.url).origin, sub, mine.value.id);
89 const start = await fetch(`${cs.url}/signin/parent`, { redirect: "manual" });
90 const to = new URL(start.headers.get("location")!);
91 const consent = await fetch(`${ps.url}/oauth/authorize`, {
92 method: "POST",
93 redirect: "manual",
94 headers: { cookie: stranger, origin: ps.url },
95 body: new URLSearchParams({ ...Object.fromEntries(to.searchParams), decision: "allow" }),
96 });
97 const back = new URL(consent.headers.get("location")!);
98 const rp = start.headers.getSetCookie().map((c) => c.split(";")[0]!).find((c) => c.startsWith("forge_rp="))!;
99 const done = await fetch(`${cs.url}${back.pathname}${back.search}`, { redirect: "manual", headers: { cookie: rp } });
100 expect(done.headers.get("location")).toBe("/");
101 expect(done.headers.getSetCookie().some((c) => c.startsWith("forge_session=") && !c.startsWith("forge_session=;"))).toBe(true);
102 });
test/no-signup-admin.test.ts +1 −1
@@ -96,7 +96,7 @@ test("the platform edge makes no account, so no admin", async () => {
9696 });
9797
9898 test("no other origin makes the admin of a forge with none, whatever its door", async () => {
99 for (const origin of ["signup", "passkey", "parent"] as const) {
99 for (const origin of ["signup", "passkey"] as const) {
100100 const app = await fresh();
101101 const made = await createUser(app.db, { origin, slot: fixtureSlot(app), username: origin, email: `${origin}@f.test` });
102102 expect(made.ok && made.value.isAdmin).toBe(false);
test/signup-gate-parent.test.ts +6 −12
@@ -71,25 +71,20 @@ afterAll(async () => {
7171 for (const dir of dirs) await rm(dir, { recursive: true, force: true });
7272 });
7373
74 const shut: [SignupMode, string][] = [["closed", "alice"], ["invite", "bea"]];
75 for (const [mode, name] of shut) {
74 const modes: [SignupMode, string][] = [["closed", "alice"], ["invite", "bea"], ["open", "cleo"]];
75 for (const [mode, name] of modes) {
7676 test(`signup ${mode}: a parent's person new to the child gets no account and no session`, async () => {
7777 setSignup(child.db, mode, owner);
78 const before = child.db.query("SELECT COUNT(*) AS n FROM user").get();
7879 const done = await through(name);
79 expect(done.status).toBe(403);
80 expect(done.status).toBe(303);
81 expect(done.headers.get("location")).toStartWith("/signup");
8082 expect(cookieOf(done, "forge_session")).toBe("");
8183 expect(getUserByName(child.db, name)).toBeUndefined();
84 expect(child.db.query("SELECT COUNT(*) AS n FROM user").get()).toEqual(before);
8285 });
8386 }
8487
85 test("signup open: the same door makes the account", async () => {
86 setSignup(child.db, "open", owner);
87 const done = await through("cleo");
88 expect(done.status).toBe(303);
89 expect(cookieOf(done, "forge_session")).not.toBe("");
90 expect(getUserByName(child.db, "cleo")).toBeDefined();
91 });
92
9388 /** Each place in `src` that makes an account, and the origin it says. */
9489 async function callers(): Promise<Record<string, string[]>> {
9590 const found: Record<string, string[]> = {};
@@ -120,7 +115,6 @@ test("every caller of createUser says where its account comes from", async () =>
120115 "src/actions/spawn.ts": ["spawn", "spawn"],
121116 "src/actions/users.ts": ["signup", "passkey"],
122117 "src/chaos/world.ts": ["seed"],
123 "src/core/federation.ts": ["parent"],
124118 "src/web/pages/claim.ts": ["claim"],
125119 "src/web/pages/everyday.ts": ["everyday"],
126120 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs