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

Link a parent-forge identity from account settings #2907

Merged opened this

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

Merged

scout/forge:scout/2906-link-a-parent-forge-identity-from-accoun → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/2906-link-a-parent-forge-identity-from-accoun was merged into main as d1e23a3.

Review

Sign in to review this change.
9 files changed +378 −13
README.md +1
@@ -421,6 +421,7 @@ records the login the request carries against the signed-in account, Unlink remo
421421 it (same-origin POSTs). An edge sign-in finds the account by its link first, then by
422422 name; one login links to one account, and a login that is another account's name
423423 cannot be linked. The terminal's "What I can do" screen shows the same line and `u` unlinks (SSH carries no edge header, so linking is on the web page only).
424 On a forge with a parent, settings also show "<parent host>: linked" or "not linked". Link is a same-origin POST that runs the `/signin/parent` PKCE round trip for the signed-in account and, on return, writes its `federated_identity` row (never an account); an identity another account holds, or a second identity for this account, is refused. Unlink removes it. The terminal's "What I can do" shows the line and `p` unlinks; linking needs a browser.
424425 Mail out
425426 (the link that proves a newcomer's email) is SMTP submission over TLS,
426427 configured by the forge-level secrets `MAIL_SMTP_URL`
src/core/federation.ts +61 −5
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
1111 import { join } from "node:path";
1212 import { sha256 } from "../kernel/hash.ts";
1313 import { parentUrlProblem } from "./parent_releases.ts";
14 import { err, ok, type Result } from "../kernel/result.ts";
1415 import { createUser, getUserById, getUserByName, isActive, type User } from "./users.ts";
1516 import type { SignupSlot } from "../actions/signup-window.ts";
1617
@@ -44,6 +45,8 @@ export interface Login {
4445 readonly nonce: string;
4546 readonly issuer: string;
4647 readonly redirectUri: string;
48 /** The signed-in account this round trip links an identity to; none for a sign-in. */
49 readonly linkUser?: string;
4750 }
4851
4952 export function startLogin(db: Database, state: string, login: Login, now = Date.now()): void {
@@ -53,20 +56,26 @@ export function startLogin(db: Database, state: string, login: Login, now = Date
5356 (SELECT state_hash FROM rp_login ORDER BY expires_at LIMIT max(0, (SELECT count(*) FROM rp_login) - ?1))`,
5457 ).run(LOGIN_MAX - 1);
5558 db.query(
56 "INSERT INTO rp_login (state_hash, verifier, nonce, issuer, redirect_uri, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
57 ).run(sha256(state), login.verifier, login.nonce, login.issuer, login.redirectUri, now + LOGIN_TTL_MS);
59 "INSERT INTO rp_login (state_hash, verifier, nonce, issuer, redirect_uri, expires_at, link_user) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
60 ).run(sha256(state), login.verifier, login.nonce, login.issuer, login.redirectUri, now + LOGIN_TTL_MS, login.linkUser ?? null);
5861 }
5962
6063 /** The sign-in a state began, used up by asking: a state works once. */
6164 export function takeLogin(db: Database, state: string, now = Date.now()): Login | undefined {
6265 const row = db
6366 .query<
64 { verifier: string; nonce: string; issuer: string; redirect_uri: string; expires_at: number },
67 { verifier: string; nonce: string; issuer: string; redirect_uri: string; expires_at: number; link_user: string | null },
6568 [string]
66 >("DELETE FROM rp_login WHERE state_hash = ?1 RETURNING verifier, nonce, issuer, redirect_uri, expires_at")
69 >("DELETE FROM rp_login WHERE state_hash = ?1 RETURNING verifier, nonce, issuer, redirect_uri, expires_at, link_user")
6770 .get(sha256(state));
6871 if (row === null || row.expires_at <= now) return undefined;
69 return { verifier: row.verifier, nonce: row.nonce, issuer: row.issuer, redirectUri: row.redirect_uri };
72 return {
73 verifier: row.verifier,
74 nonce: row.nonce,
75 issuer: row.issuer,
76 redirectUri: row.redirect_uri,
77 ...(row.link_user === null ? {} : { linkUser: row.link_user }),
78 };
7079 }
7180
7281 /** A local username for a parent's person: their name if free and fit, else made unique by their `sub`. */
@@ -170,3 +179,50 @@ export function parentIssuerOf(config: {
170179 if (raw === undefined || parentUrlProblem(raw, config.parentLoopback === true) !== null) return undefined;
171180 return new URL(raw).origin;
172181 }
182
183 export type ParentLinkError = "already_linked" | "identity_taken";
184
185 /** The parent `sub` linked to this account at this issuer, if any. */
186 export function linkedParent(db: Database, userId: string, issuer: string): string | undefined {
187 return (
188 db
189 .query<{ sub: string }, [string, string]>("SELECT sub FROM federated_identity WHERE user_id = ?1 AND issuer = ?2")
190 .get(userId, issuer)?.sub ?? undefined
191 );
192 }
193
194 /**
195 * Record the issuer's `sub` against a signed-in account: the row a first
196 * sign-in would have made, written for an account that already exists.
197 * Refused when another account holds the `sub` or this one holds another.
198 */
199 export function linkParent(db: Database, userId: string, issuer: string, sub: string): Result<"linked", ParentLinkError> {
200 const held = db
201 .query<{ user_id: string }, [string, string]>("SELECT user_id FROM federated_identity WHERE issuer = ?1 AND sub = ?2")
202 .get(issuer, sub);
203 if (held !== null) return held.user_id === userId ? ok("linked") : err("identity_taken");
204 if (linkedParent(db, userId, issuer) !== undefined) return err("already_linked");
205 db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)").run(issuer, sub, userId);
206 return ok("linked");
207 }
208
209 /**
210 * Remove the account's link at this issuer. Refused with `last_way_in` when
211 * the account has no password and no passkey: an account parent sign-in made
212 * has the link as its only door, and unlinking it would strand the account.
213 * "none" when there was no link.
214 */
215 export function unlinkParent(db: Database, userId: string, issuer: string): Result<"unlinked" | "none", "last_way_in"> {
216 if (linkedParent(db, userId, issuer) === undefined) return ok("none");
217 const other = db
218 .query<{ n: number }, [string]>(
219 `SELECT (SELECT count(*) FROM user WHERE id = ?1 AND password_hash <> '')
220 + (SELECT count(*) FROM passkey WHERE user_id = ?1) AS n`,
221 )
222 .get(userId);
223 if (other === null || other.n === 0) return err("last_way_in");
224 db.query("DELETE FROM federated_identity WHERE user_id = ?1 AND issuer = ?2").run(userId, issuer);
225 return ok("unlinked");
226 }
227
228 export const LAST_WAY_IN = "Add a passkey or a password first: this link is the only way into your account.";
src/http/rp.ts +32 −3
@@ -22,6 +22,7 @@ import {
2222 startLogin,
2323 takeLogin,
2424 CALLBACK_PATH,
25 linkParent,
2526 } from "../core/federation.ts";
2627 import { readCapped } from "../origins/providers/http.ts";
2728 import { clientAddress } from "./peer.ts";
@@ -29,7 +30,9 @@ import { signinAllowed, signupSlot } from "../web/pages/site.ts";
2930 import { createSession } from "../core/sessions.ts";
3031 import { verifyJwt } from "../kernel/jwt.ts";
3132 import { html } from "../web/html.ts";
32 import { page, redirect } from "../web/layout.ts";
33 import { flashTo, page, redirect } from "../web/layout.ts";
34 import { readForm } from "../web/form.ts";
35 import { signInFirst } from "../web/chrome.ts";
3336 import { sessionCookie } from "../web/pages/site.ts";
3437 import { cookieValue, type Ctx } from "./context.ts";
3538 import { KeySet } from "./jwks.ts";
@@ -118,7 +121,17 @@ const cookieHeader = (value: string, maxAge: number, url: URL, forgeUrl?: string
118121 return `${STATE_COOKIE}=${value}; Path=/signin/parent; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
119122 };
120123
121 export async function parentStart(ctx: Ctx): Promise<Response> {
124 export const parentStart = (ctx: Ctx): Promise<Response> => begin(ctx);
125
126 /** POST /settings/parent/link: the same round trip, begun by a signed-in person to link, not to sign in. */
127 export async function parentLinkStart(ctx: Ctx): Promise<Response> {
128 if (!ctx.auth.user) return signInFirst(ctx);
129 const form = await readForm(ctx, "admin");
130 if (form instanceof Response) return form;
131 return begin(ctx, ctx.auth.user.id);
132 }
133
134 async function begin(ctx: Ctx, linkUser?: string): Promise<Response> {
122135 const issuer = parentIssuer(ctx.app);
123136 if (issuer === undefined) return failure(ctx, "This forge has no parent to sign in through.", 404);
124137 // Each start writes a row and the table is bounded: a flood must not push
@@ -139,7 +152,7 @@ export async function parentStart(ctx: Ctx): Promise<Response> {
139152 const state = rand(24);
140153 const verifier = rand(48);
141154 const nonce = rand(24);
142 startLogin(ctx.app.db, state, { verifier, nonce, issuer, redirectUri });
155 startLogin(ctx.app.db, state, { verifier, nonce, issuer, redirectUri, ...(linkUser === undefined ? {} : { linkUser }) });
143156 const query = new URLSearchParams({
144157 response_type: "code",
145158 client_id: client,
@@ -207,6 +220,22 @@ export async function parentCallback(ctx: Ctx): Promise<Response> {
207220 const sub = verified.value["sub"];
208221 if (typeof sub !== "string" || !SUB.test(sub))
209222 return refuse("The parent forge's answer did not verify.", 502);
223 if (login.linkUser !== undefined) {
224 // A link is for the account that began it, still signed in in this browser: never a sign-in, never a new account.
225 if (ctx.auth.user?.id !== login.linkUser) return refuse("Sign in again, then link from your settings.", 403);
226 const linked = linkParent(ctx.app.db, login.linkUser, login.issuer, sub);
227 const back = flashTo(ctx, "/settings", {
228 message: linked.ok
229 ? `Linked ${new URL(login.issuer).host}.`
230 : linked.error === "identity_taken"
231 ? `That ${new URL(login.issuer).host} identity belongs to another account here.`
232 : `This account is linked to another ${new URL(login.issuer).host} identity. Unlink it first.`,
233 });
234 if (linked.ok) ctx.app.bus.append({ kind: "account.parent_linked", actorId: login.linkUser });
235 back.headers.append("set-cookie", clear);
236 back.headers.set("cache-control", "no-store");
237 return back;
238 }
210239 const name = clean(verified.value["preferred_username"], 64);
211240 // A returning person is a sign-in; first sight of a sub makes an account,
212241 // and that is a sign-up, held to the sign-up limit before it is made.
src/http/server.ts +4 −1
@@ -11,7 +11,7 @@ import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
1111 import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts";
1212 import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts";
1313 import { unsealReleased } from "../actions/sealed.ts";
14 import { parentCallback, parentStart } from "./rp.ts";
14 import { parentCallback, parentLinkStart, parentStart } from "./rp.ts";
1515 import { accessPage } from "../web/pages/access.ts";
1616 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1717 import { systemPage, systemStream } from "../web/pages/system.ts";
@@ -391,6 +391,7 @@ import {
391391 notifyUpdate,
392392 edgeLink,
393393 edgeUnlink,
394 parentUnlink,
394395 accountUpdate,
395396 keyCreate,
396397 keyDelete,
@@ -760,6 +761,8 @@ export function buildRouter(): Router<Handler> {
760761 router.add("POST", "/settings/notify", notifyUpdate);
761762 router.add("POST", "/settings/edge/link", edgeLink);
762763 router.add("POST", "/settings/edge/unlink", edgeUnlink);
764 router.add("POST", "/settings/parent/link", parentLinkStart);
765 router.add("POST", "/settings/parent/unlink", parentUnlink);
763766 router.add("POST", "/settings/email/verify", resendVerifyLink);
764767 router.add("GET", "/settings/password", passwordPage);
765768 router.add("POST", "/settings/password", passwordUpdate);
src/tui/screens/can.ts +20 −3
@@ -8,6 +8,7 @@
88
99 import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts";
1010 import { linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts";
11 import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts";
1112 import { activeEdgeToken } from "../../http/edge.ts";
1213 import type { Ctx, Screen } from "../app/app.ts";
1314 import { binding } from "../app/keys.ts";
@@ -21,21 +22,37 @@ export function canScreen(f: Session, ctx: Ctx): Screen {
2122 const linked = linkedLogin(f.app.db, f.user.id);
2223 return linked === undefined ? "open-platform.sh: not linked" : `open-platform.sh: linked as ${linked}`;
2324 };
25 let refused: string | undefined;
26 const parentLine = (): string | undefined => {
27 const issuer = parentIssuerOf(f.app.config);
28 if (f.user === undefined || issuer === undefined) return undefined;
29 const host = new URL(issuer).host;
30 return linkedParent(f.app.db, f.user.id, issuer) === undefined
31 ? `${host}: not linked (link it from account settings in a browser)`
32 : `${host}: linked`;
33 };
2434 return {
2535 title: "What I can do",
26 keys: [binding(["u"], "unlink open-platform.sh"), binding(["esc"], "back")],
36 keys: [binding(["u"], "unlink open-platform.sh"), binding(["p"], "unlink parent forge"), binding(["esc"], "back")],
2737 render(area, s) {
2838 const c = ctx.theme.c;
2939 if (f.user === undefined) return;
3040 const role = roleOf(f.app.db, f.user);
3141 const row = rowOf(role);
3242 const login = edgeLogin();
33 [`You are ${role} here.`, ...(login === undefined ? [] : [login]), ...(Object.keys(ABILITIES) as Ability[]).map((a) => `${ABILITIES[a]}: ${row[a] ? "yes" : "no"}`)]
43 [`You are ${role} here.`, ...(login === undefined ? [] : [login]), ...(parentLine() === undefined ? [] : [parentLine()!]), ...(refused === undefined ? [] : [refused]), ...(Object.keys(ABILITIES) as Ability[]).map((a) => `${ABILITIES[a]}: ${row[a] ? "yes" : "no"}`)]
3444 .slice(0, Math.max(0, area.h))
3545 .forEach((l, i) => s.text(area.x, area.y + i, truncate(l, area.w), style(c.mutedFg, c.bg), area.x + area.w));
3646 },
3747 key(e) {
38 if (e.ctrl || e.alt || e.key !== "u" || f.user === undefined) return false;
48 if (e.ctrl || e.alt || (e.key !== "u" && e.key !== "p") || f.user === undefined) return false;
49 if (e.key === "p") {
50 const issuer = parentIssuerOf(f.app.config);
outdated

Same lock-out as the page: p must not remove the last credential. Put the guard in unlinkParent so both doors share it.

51 const done = issuer === undefined ? undefined : unlinkParent(f.app.db, f.user.id, issuer);
52 if (done?.ok === false) refused = LAST_WAY_IN;
53 else if (done?.value === "unlinked") f.app.bus.append({ kind: "account.parent_unlinked", actorId: f.user.id });
54 return true;
55 }
3956 if (activeEdgeToken(f.app) !== undefined && unlinkEdgeLogin(f.app.db, f.user.id))
4057 f.app.bus.append({ kind: "account.edge_unlinked", actorId: f.user.id });
4158 return true;
src/web/pages/account.ts +35 −1
@@ -29,6 +29,7 @@ import {
2929 import { forgeHost, noreplyAddress } from "../../core/commit_identity.ts";
3030 import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts";
3131 import { linkEdgeLogin, linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts";
32 import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts";
3233 import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts";
3334 import { listKeys } from "../../core/keys.ts";
3435 import { endAccountSession } from "../../actions/signins.ts";
@@ -149,6 +150,39 @@ function edgeLinkView(ctx: Ctx): Html {
149150 );
150151 }
151152
153 /** The parent forge: linked as its host, or not, with Link (a round trip through the parent) and Unlink. */
154 function parentLinkView(ctx: Ctx): Html {
155 const issuer = parentIssuerOf(ctx.app.config);
156 if (issuer === undefined) return html``;
157 const host = new URL(issuer).host;
158 const linked = linkedParent(ctx.app.db, ctx.auth.user!.id, issuer) !== undefined;
159 return card(
160 linked
161 ? html`<p>${host}: linked</p>
162 <form method="post" action="/settings/parent/unlink">
163 ${btn("Unlink", { type: "submit" })}
164 </form>`
165 : html`<p>${host}: not linked</p>
166 <form method="post" action="/settings/parent/link">
167 ${btn(`Link ${host}`, { type: "submit" })}
168 </form>`,
169 { head: html`<h3>${host}</h3>` },
170 );
171 }
172
173 // POST /settings/parent/unlink
174 export async function parentUnlink(ctx: Ctx): Promise<Response> {
175 if (!ctx.auth.user) return signInFirst(ctx);
176 const form = await readForm(ctx, "admin");
177 if (form instanceof Response) return form;
178 const issuer = parentIssuerOf(ctx.app.config);
179 const done = issuer === undefined ? undefined : unlinkParent(ctx.app.db, ctx.auth.user.id, issuer);
outdated

High: this deletes the only way in for an account that parent sign-in created without a password. Refuse the unlink when the account has no password and no passkey, and flash why. Do the same in src/tui/screens/can.ts.

180 if (done === undefined || (done.ok && done.value === "none")) return flashTo(ctx, "/settings", { message: "Nothing to unlink." });
181 if (!done.ok) return flashTo(ctx, "/settings", { message: LAST_WAY_IN });
outdated

This shows "Unlinked." even when nothing was unlinked. Flash it only on success.

182 ctx.app.bus.append({ kind: "account.parent_unlinked", actorId: ctx.auth.user.id });
183 return flashTo(ctx, "/settings", { message: "Unlinked." });
184 }
185
152186 // POST /settings/edge/link
153187 export async function edgeLink(ctx: Ctx): Promise<Response> {
154188 if (!ctx.auth.user) return signInFirst(ctx);
@@ -303,7 +337,7 @@ export function accountPage(ctx: Ctx): Response {
303337 ctx,
304338 "profile",
305339 "Settings",
306 html`${pair}${profileView(ctx, currentProfile(ctx.auth.user))}${edgeLinkView(ctx)}${notifyView(ctx)}`,
340 html`${pair}${profileView(ctx, currentProfile(ctx.auth.user))}${edgeLinkView(ctx)}${parentLinkView(ctx)}${notifyView(ctx)}`,
307341 200,
308342 // The everyday form makes its account with a passkey (#1890).
309343 pair ? passkeyJs : undefined,
test/tui-can.test.ts +23
@@ -7,6 +7,7 @@ import { mkdtemp, rm } from "node:fs/promises";
77 import { tmpdir } from "node:os";
88 import { join } from "node:path";
99 import { linkEdgeLogin, linkedLogin } from "../src/core/edge_links.ts";
10 import { linkedParent } from "../src/core/federation.ts";
1011 import { createApp, type App as Forge } from "../src/app.ts";
1112 import { setMember } from "../src/core/roles.ts";
1213 import { createUser, getUserByName } from "../src/core/users.ts";
@@ -87,4 +88,26 @@ describe("what I can do in the terminal", () => {
8788 expect(linkedLogin(forge.db, bob.id)).toBeUndefined();
8889 expect(seen).toContain("account.edge_unlinked");
8990 });
91
92 test("the screen shows the parent forge link, and p unlinks it", async () => {
93 const bob = getUserByName(forge.db, "bob")!;
94 const issuer = "https://parent.example";
95 forge.db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, 'sub1', ?2)").run(issuer, bob.id);
96 forge.config.parentUrl = issuer;
97 expect(await shown("bob")).toContain("parent.example: linked");
98 // No password and no passkey: the link is the only way in, and p refuses.
99 forge.db.query("UPDATE user SET password_hash = '' WHERE id = ?1").run(bob.id);
100 const kept = await shown("bob", ["p"]);
101 expect(kept).toContain("parent.example: linked");
102 expect(kept).toContain("Add a passkey or a password first");
103 expect(linkedParent(forge.db, bob.id, issuer)).toBe("sub1");
104 forge.db.query("UPDATE user SET password_hash = 'x' WHERE id = ?1").run(bob.id);
105 const seen: string[] = [];
106 const stop = forge.bus.subscribe((e) => seen.push(e.kind));
107 const after = await shown("bob", ["p"]);
108 stop();
109 expect(after).toContain("parent.example: not linked");
110 expect(linkedParent(forge.db, bob.id, issuer)).toBeUndefined();
111 expect(seen).toContain("account.parent_unlinked");
112 });
90113 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs