Public: anyone can read this; the crew can change it.
Link a parent-forge identity from account settings #2907
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2906-link-a-parent-forge-identity-from-accoun → main 2 commits fork 4 threads · 4 unresolved
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 | ||
| 421 | 421 | it (same-origin POSTs). An edge sign-in finds the account by its link first, then by |
| 422 | 422 | name; one login links to one account, and a login that is another account's name |
| 423 | 423 | 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. | |
| 424 | 425 | Mail out |
| 425 | 426 | (the link that proves a newcomer's email) is SMTP submission over TLS, |
| 426 | 427 | configured by the forge-level secrets `MAIL_SMTP_URL` |
src/core/federation.ts +61 −5
| @@ -11,6 +11,7 @@ import { readFileSync } from "node:fs"; | ||
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { sha256 } from "../kernel/hash.ts"; |
| 13 | 13 | import { parentUrlProblem } from "./parent_releases.ts"; |
| 14 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 14 | 15 | import { createUser, getUserById, getUserByName, isActive, type User } from "./users.ts"; |
| 15 | 16 | import type { SignupSlot } from "../actions/signup-window.ts"; |
| 16 | 17 | |
| @@ -44,6 +45,8 @@ export interface Login { | ||
| 44 | 45 | readonly nonce: string; |
| 45 | 46 | readonly issuer: string; |
| 46 | 47 | readonly redirectUri: string; |
| 48 | /** The signed-in account this round trip links an identity to; none for a sign-in. */ | |
| 49 | readonly linkUser?: string; | |
| 47 | 50 | } |
| 48 | 51 | |
| 49 | 52 | 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 | ||
| 53 | 56 | (SELECT state_hash FROM rp_login ORDER BY expires_at LIMIT max(0, (SELECT count(*) FROM rp_login) - ?1))`, |
| 54 | 57 | ).run(LOGIN_MAX - 1); |
| 55 | 58 | 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); | |
| 58 | 61 | } |
| 59 | 62 | |
| 60 | 63 | /** The sign-in a state began, used up by asking: a state works once. */ |
| 61 | 64 | export function takeLogin(db: Database, state: string, now = Date.now()): Login | undefined { |
| 62 | 65 | const row = db |
| 63 | 66 | .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 }, | |
| 65 | 68 | [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") | |
| 67 | 70 | .get(sha256(state)); |
| 68 | 71 | 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 | }; | |
| 70 | 79 | } |
| 71 | 80 | |
| 72 | 81 | /** 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: { | ||
| 170 | 179 | if (raw === undefined || parentUrlProblem(raw, config.parentLoopback === true) !== null) return undefined; |
| 171 | 180 | return new URL(raw).origin; |
| 172 | 181 | } |
| 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/core/migrations/20261019180000-parent-link.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | // A sign-in begun by a signed-in person to link their parent-forge identity | |
| 2 | // (#2906): the round trip remembers whose account it is for, so the callback | |
| 3 | // links that account and never makes one. | |
| 4 | export default ` | |
| 5 | ALTER TABLE rp_login ADD COLUMN link_user TEXT; | |
| 6 | `; |
src/http/rp.ts +32 −3
| @@ -22,6 +22,7 @@ import { | ||
| 22 | 22 | startLogin, |
| 23 | 23 | takeLogin, |
| 24 | 24 | CALLBACK_PATH, |
| 25 | linkParent, | |
| 25 | 26 | } from "../core/federation.ts"; |
| 26 | 27 | import { readCapped } from "../origins/providers/http.ts"; |
| 27 | 28 | import { clientAddress } from "./peer.ts"; |
| @@ -29,7 +30,9 @@ import { signinAllowed, signupSlot } from "../web/pages/site.ts"; | ||
| 29 | 30 | import { createSession } from "../core/sessions.ts"; |
| 30 | 31 | import { verifyJwt } from "../kernel/jwt.ts"; |
| 31 | 32 | 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"; | |
| 33 | 36 | import { sessionCookie } from "../web/pages/site.ts"; |
| 34 | 37 | import { cookieValue, type Ctx } from "./context.ts"; |
| 35 | 38 | import { KeySet } from "./jwks.ts"; |
| @@ -118,7 +121,17 @@ const cookieHeader = (value: string, maxAge: number, url: URL, forgeUrl?: string | ||
| 118 | 121 | return `${STATE_COOKIE}=${value}; Path=/signin/parent; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`; |
| 119 | 122 | }; |
| 120 | 123 | |
| 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> { | |
| 122 | 135 | const issuer = parentIssuer(ctx.app); |
| 123 | 136 | if (issuer === undefined) return failure(ctx, "This forge has no parent to sign in through.", 404); |
| 124 | 137 | // 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> { | ||
| 139 | 152 | const state = rand(24); |
| 140 | 153 | const verifier = rand(48); |
| 141 | 154 | 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 }) }); | |
| 143 | 156 | const query = new URLSearchParams({ |
| 144 | 157 | response_type: "code", |
| 145 | 158 | client_id: client, |
| @@ -207,6 +220,22 @@ export async function parentCallback(ctx: Ctx): Promise<Response> { | ||
| 207 | 220 | const sub = verified.value["sub"]; |
| 208 | 221 | if (typeof sub !== "string" || !SUB.test(sub)) |
| 209 | 222 | 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 | } | |
| 210 | 239 | const name = clean(verified.value["preferred_username"], 64); |
| 211 | 240 | // A returning person is a sign-in; first sight of a sub makes an account, |
| 212 | 241 | // 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"; | ||
| 11 | 11 | import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts"; |
| 12 | 12 | import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts"; |
| 13 | 13 | import { unsealReleased } from "../actions/sealed.ts"; |
| 14 | import { parentCallback, parentStart } from "./rp.ts"; | |
| 14 | import { parentCallback, parentLinkStart, parentStart } from "./rp.ts"; | |
| 15 | 15 | import { accessPage } from "../web/pages/access.ts"; |
| 16 | 16 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 17 | 17 | import { systemPage, systemStream } from "../web/pages/system.ts"; |
| @@ -391,6 +391,7 @@ import { | ||
| 391 | 391 | notifyUpdate, |
| 392 | 392 | edgeLink, |
| 393 | 393 | edgeUnlink, |
| 394 | parentUnlink, | |
| 394 | 395 | accountUpdate, |
| 395 | 396 | keyCreate, |
| 396 | 397 | keyDelete, |
| @@ -760,6 +761,8 @@ export function buildRouter(): Router<Handler> { | ||
| 760 | 761 | router.add("POST", "/settings/notify", notifyUpdate); |
| 761 | 762 | router.add("POST", "/settings/edge/link", edgeLink); |
| 762 | 763 | router.add("POST", "/settings/edge/unlink", edgeUnlink); |
| 764 | router.add("POST", "/settings/parent/link", parentLinkStart); | |
| 765 | router.add("POST", "/settings/parent/unlink", parentUnlink); | |
| 763 | 766 | router.add("POST", "/settings/email/verify", resendVerifyLink); |
| 764 | 767 | router.add("GET", "/settings/password", passwordPage); |
| 765 | 768 | router.add("POST", "/settings/password", passwordUpdate); |
src/tui/screens/can.ts +20 −3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts"; |
| 10 | 10 | import { linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts"; |
| 11 | import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts"; | |
| 11 | 12 | import { activeEdgeToken } from "../../http/edge.ts"; |
| 12 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 14 | import { binding } from "../app/keys.ts"; |
| @@ -21,21 +22,37 @@ export function canScreen(f: Session, ctx: Ctx): Screen { | ||
| 21 | 22 | const linked = linkedLogin(f.app.db, f.user.id); |
| 22 | 23 | return linked === undefined ? "open-platform.sh: not linked" : `open-platform.sh: linked as ${linked}`; |
| 23 | 24 | }; |
| 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 | }; | |
| 24 | 34 | return { |
| 25 | 35 | 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")], | |
| 27 | 37 | render(area, s) { |
| 28 | 38 | const c = ctx.theme.c; |
| 29 | 39 | if (f.user === undefined) return; |
| 30 | 40 | const role = roleOf(f.app.db, f.user); |
| 31 | 41 | const row = rowOf(role); |
| 32 | 42 | 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"}`)] | |
| 34 | 44 | .slice(0, Math.max(0, area.h)) |
| 35 | 45 | .forEach((l, i) => s.text(area.x, area.y + i, truncate(l, area.w), style(c.mutedFg, c.bg), area.x + area.w)); |
| 36 | 46 | }, |
| 37 | 47 | 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); | |
| 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 | } | |
| 39 | 56 | if (activeEdgeToken(f.app) !== undefined && unlinkEdgeLogin(f.app.db, f.user.id)) |
| 40 | 57 | f.app.bus.append({ kind: "account.edge_unlinked", actorId: f.user.id }); |
| 41 | 58 | return true; |
src/web/pages/account.ts +35 −1
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | import { forgeHost, noreplyAddress } from "../../core/commit_identity.ts"; |
| 30 | 30 | import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts"; |
| 31 | 31 | import { linkEdgeLogin, linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts"; |
| 32 | import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts"; | |
| 32 | 33 | import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts"; |
| 33 | 34 | import { listKeys } from "../../core/keys.ts"; |
| 34 | 35 | import { endAccountSession } from "../../actions/signins.ts"; |
| @@ -149,6 +150,39 @@ function edgeLinkView(ctx: Ctx): Html { | ||
| 149 | 150 | ); |
| 150 | 151 | } |
| 151 | 152 | |
| 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 |
||
| 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 | ||
| 152 | 186 | // POST /settings/edge/link |
| 153 | 187 | export async function edgeLink(ctx: Ctx): Promise<Response> { |
| 154 | 188 | if (!ctx.auth.user) return signInFirst(ctx); |
| @@ -303,7 +337,7 @@ export function accountPage(ctx: Ctx): Response { | ||
| 303 | 337 | ctx, |
| 304 | 338 | "profile", |
| 305 | 339 | "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)}`, | |
| 307 | 341 | 200, |
| 308 | 342 | // The everyday form makes its account with a passkey (#1890). |
| 309 | 343 | pair ? passkeyJs : undefined, |
test/parent-link.test.ts added +196
| @@ -0,0 +1,196 @@ | ||
| 1 | /** | |
| 2 | * Linking the parent forge from account settings (#2906): a signed-in | |
| 3 | * person runs the round trip and the callback writes the federated_identity | |
| 4 | * row for their own account, never a new one. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { linkedParent } from "../src/core/federation.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | describe("link the parent forge from settings", () => { | |
| 20 | const dirs: string[] = []; | |
| 21 | let parent: App; | |
| 22 | let child: App; | |
| 23 | let parentServers: Servers; | |
| 24 | let childServers: Servers; | |
| 25 | let issuer = ""; | |
| 26 | let aliceCookie = ""; | |
| 27 | let carolId = ""; | |
| 28 | let carolCookie = ""; | |
| 29 | let dougId = ""; | |
| 30 | let dougCookie = ""; | |
| 31 | ||
| 32 | const cookieOf = (res: Response, name: string) => | |
| 33 | res.headers.getSetCookie().map((c) => c.split(";")[0]!).find((c) => c.startsWith(`${name}=`)); | |
| 34 | ||
| 35 | /** The person with `parentCookie` at the parent consents to the round trip `who` began at the child. */ | |
| 36 | const link = async (who: string, parentCookie: string, extra: Record<string, string> = {}) => { | |
| 37 | const start = await fetch(`${childServers.url}/settings/parent/link`, { | |
| 38 | method: "POST", | |
| 39 | redirect: "manual", | |
| 40 | headers: { cookie: who, origin: childServers.url, ...extra }, | |
| 41 | body: "", | |
| 42 | }); | |
| 43 | if (start.status !== 303) return { start } as const; | |
| 44 | const to = new URL(start.headers.get("location")!); | |
| 45 | const state = cookieOf(start, "forge_rp")!; | |
| 46 | const consent = await fetch(`${parentServers.url}/oauth/authorize`, { | |
| 47 | method: "POST", | |
| 48 | redirect: "manual", | |
| 49 | headers: { cookie: parentCookie, origin: parentServers.url }, | |
| 50 | body: new URLSearchParams({ ...Object.fromEntries(to.searchParams), decision: "allow" }), | |
| 51 | }); | |
| 52 | const callback = new URL(consent.headers.get("location")!); | |
| 53 | return { start, callback, state } as const; | |
| 54 | }; | |
| 55 | const finish = (callback: URL, cookie: string) => | |
| 56 | fetch(`${childServers.url}${callback.pathname}${callback.search}`, { redirect: "manual", headers: { cookie } }); | |
| 57 | const users = (app: App) => (app.db.query("SELECT COUNT(*) AS n FROM user").get() as { n: number }).n; | |
| 58 | ||
| 59 | beforeAll(async () => { | |
| 60 | const make = async () => { | |
| 61 | const dir = await mkdtemp(join(tmpdir(), "forge-plink-")); | |
| 62 | dirs.push(dir); | |
| 63 | return dir; | |
| 64 | }; | |
| 65 | parent = createApp({ dataDir: await make(), port: 0 }); | |
| 66 | parentServers = await serve(parent); | |
| 67 | child = createApp({ dataDir: await make(), port: 0, parentUrl: parentServers.url, parentLoopback: true }); | |
| 68 | childServers = await serve(child); | |
| 69 | issuer = new URL(parentServers.url).origin; | |
| 70 | const mk = async (app: App, username: string, adminIfFirst = false) => { | |
| 71 | const made = await createUser(app.db, { origin: "seed", username, email: `${username}@x.local`, password: "a-long-family-passphrase", adminIfFirst }); | |
| 72 | if (!made.ok) throw new Error(made.error); | |
| 73 | return { id: made.value.id, cookie: `forge_session=${createSession(app.db, made.value.id).token}` }; | |
| 74 | }; | |
| 75 | aliceCookie = (await mk(parent, "alice", true)).cookie; | |
| 76 | await mk(child, "olive", true); | |
| 77 | ({ id: carolId, cookie: carolCookie } = await mk(child, "carol")); | |
| 78 | ({ id: dougId, cookie: dougCookie } = await mk(child, "doug")); | |
| 79 | }); | |
| 80 | afterAll(async () => { | |
| 81 | parentServers.stop(); | |
| 82 | childServers.stop(); | |
| 83 | for (const dir of dirs) await rm(dir, { recursive: true, force: true }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("settings offer Link, and the round trip links the signed-in account without making one", async () => { | |
| 87 | const before = await (await fetch(`${childServers.url}/settings`, { headers: { cookie: carolCookie } })).text(); | |
| 88 | expect(before).toContain("not linked"); | |
| 89 | expect(before).toContain("/settings/parent/link"); | |
| 90 | const users0 = users(child); | |
| 91 | const trip = await link(carolCookie, aliceCookie); | |
| 92 | const done = await finish(trip.callback!, `${carolCookie}; ${trip.state}`); | |
| 93 | expect(done.status).toBe(303); | |
| 94 | expect(done.headers.get("location")).toBe("/settings"); | |
| 95 | expect(linkedParent(child.db, carolId, issuer)).toBeDefined(); | |
| 96 | expect(users(child)).toBe(users0); | |
| 97 | const after = await (await fetch(`${childServers.url}/settings`, { headers: { cookie: carolCookie } })).text(); | |
| 98 | expect(after).not.toContain("not linked"); | |
outdated
"not linked" contains "linked", so this passes before the link too. Assert |
||
| 99 | expect(after).toContain("/settings/parent/unlink"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a linked person then signs in through the parent as the same account", async () => { | |
| 103 | const start = await fetch(`${childServers.url}/signin/parent`, { redirect: "manual" }); | |
| 104 | const to = new URL(start.headers.get("location")!); | |
| 105 | const consent = await fetch(`${parentServers.url}/oauth/authorize`, { | |
| 106 | method: "POST", | |
| 107 | redirect: "manual", | |
| 108 | headers: { cookie: aliceCookie, origin: parentServers.url }, | |
| 109 | body: new URLSearchParams({ ...Object.fromEntries(to.searchParams), decision: "allow" }), | |
| 110 | }); | |
| 111 | const callback = new URL(consent.headers.get("location")!); | |
| 112 | const users0 = users(child); | |
| 113 | const done = await finish(callback, cookieOf(start, "forge_rp")!); | |
| 114 | expect(done.status).toBe(303); | |
| 115 | expect(users(child)).toBe(users0); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a parent identity another account holds cannot be linked", async () => { | |
| 119 | const users0 = users(child); | |
| 120 | const trip = await link(dougCookie, aliceCookie); | |
| 121 | const done = await finish(trip.callback!, `${dougCookie}; ${trip.state}`); | |
| 122 | expect(done.status).toBe(303); | |
| 123 | expect(linkedParent(child.db, dougId, issuer)).toBeUndefined(); | |
| 124 | expect(users(child)).toBe(users0); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a link finished signed out or by another account links nothing", async () => { | |
| 128 | const unlinked = child.db.query("SELECT COUNT(*) AS n FROM federated_identity").get(); | |
| 129 | const trip = await link(dougCookie, aliceCookie); | |
| 130 | // Signed out: the state cookie alone does not link. | |
| 131 | const out = await finish(trip.callback!, trip.state!); | |
| 132 | expect(out.status).toBe(403); | |
| 133 | const again = await link(dougCookie, aliceCookie); | |
| 134 | const other = await finish(again.callback!, `${carolCookie}; ${again.state}`); | |
| 135 | expect(other.status).toBe(403); | |
| 136 | expect(linkedParent(child.db, dougId, issuer)).toBeUndefined(); | |
| 137 | expect(child.db.query("SELECT COUNT(*) AS n FROM federated_identity").get()).toEqual(unlinked); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a cross-origin or signed-out link start is refused", async () => { | |
| 141 | const cross = await fetch(`${childServers.url}/settings/parent/link`, { | |
| 142 | method: "POST", | |
| 143 | redirect: "manual", | |
| 144 | headers: { cookie: carolCookie, origin: "https://evil.example" }, | |
| 145 | body: "", | |
| 146 | }); | |
| 147 | expect(cross.status).toBeGreaterThanOrEqual(400); | |
| 148 | const out = await fetch(`${childServers.url}/settings/parent/link`, { | |
| 149 | method: "POST", | |
| 150 | redirect: "manual", | |
| 151 | headers: { origin: childServers.url }, | |
| 152 | body: "", | |
| 153 | }); | |
| 154 | expect(out.headers.get("location") ?? "").not.toContain("/oauth/authorize"); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a second identity for an account that already has one is refused", async () => { | |
| 158 | child.db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, 'someone-else', ?2)").run(issuer, dougId); | |
| 159 | const trip = await link(dougCookie, aliceCookie); | |
| 160 | const done = await finish(trip.callback!, `${dougCookie}; ${trip.state}`); | |
| 161 | expect(done.status).toBe(303); | |
| 162 | expect(linkedParent(child.db, dougId, issuer)).toBe("someone-else"); | |
| 163 | child.db.query("DELETE FROM federated_identity WHERE user_id = ?1").run(dougId); | |
| 164 | }); | |
| 165 | ||
| 166 | test("Unlink is refused when the link is the account's only way in", async () => { | |
| 167 | const mkId = child.db.query("SELECT id FROM user WHERE username = 'carol'").get() as { id: string }; | |
| 168 | child.db.query("UPDATE user SET password_hash = '' WHERE id = ?1").run(mkId.id); | |
| 169 | child.db.query("DELETE FROM federated_identity WHERE user_id = ?1").run(mkId.id); | |
| 170 | child.db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, 'carol-sub', ?2)").run(issuer, mkId.id); | |
| 171 | const res = await fetch(`${childServers.url}/settings/parent/unlink`, { | |
| 172 | method: "POST", | |
| 173 | redirect: "manual", | |
| 174 | headers: { cookie: carolCookie, origin: childServers.url }, | |
| 175 | body: "", | |
| 176 | }); | |
| 177 | expect(res.status).toBe(303); | |
| 178 | expect(linkedParent(child.db, mkId.id, issuer)).toBe("carol-sub"); | |
| 179 | const page = await (await fetch(`${childServers.url}/settings`, { headers: { cookie: carolCookie } })).text(); | |
| 180 | expect(page).not.toContain("not linked"); | |
| 181 | child.db.query("UPDATE user SET password_hash = 'x' WHERE id = ?1").run(mkId.id); | |
| 182 | }); | |
| 183 | ||
| 184 | test("Unlink removes the link; a forge with no parent shows no line", async () => { | |
| 185 | const res = await fetch(`${childServers.url}/settings/parent/unlink`, { | |
| 186 | method: "POST", | |
| 187 | redirect: "manual", | |
| 188 | headers: { cookie: carolCookie, origin: childServers.url }, | |
| 189 | body: "", | |
| 190 | }); | |
| 191 | expect(res.status).toBe(303); | |
| 192 | expect(linkedParent(child.db, carolId, issuer)).toBeUndefined(); | |
| 193 | const none = await (await fetch(`${parentServers.url}/settings`, { headers: { cookie: aliceCookie } })).text(); | |
| 194 | expect(none).not.toContain("/settings/parent/link"); | |
| 195 | }); | |
| 196 | }); |
test/tui-can.test.ts +23
| @@ -7,6 +7,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { linkEdgeLogin, linkedLogin } from "../src/core/edge_links.ts"; |
| 10 | import { linkedParent } from "../src/core/federation.ts"; | |
| 10 | 11 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 11 | 12 | import { setMember } from "../src/core/roles.ts"; |
| 12 | 13 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| @@ -87,4 +88,26 @@ describe("what I can do in the terminal", () => { | ||
| 87 | 88 | expect(linkedLogin(forge.db, bob.id)).toBeUndefined(); |
| 88 | 89 | expect(seen).toContain("account.edge_unlinked"); |
| 89 | 90 | }); |
| 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 | }); | |
| 90 | 113 | }); |
new above
offline
Same lock-out as the page:
pmust not remove the last credential. Put the guard inunlinkParentso both doors share it.