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

The forge signs people in with no forwardAuth edge in front of it, and people who came through the edge keep their accounts #11809

Merged opened this

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

Merged

torque/forge:torque/11802-the-forge-signs-people-in-with-no-forwar → main 6 commits fork 3 threads · 3 unresolved

  • Merged. torque/11802-the-forge-signs-people-in-with-no-forwar was merged into main as b5bea06.

Review

Sign in to review this change.
17 files changed +727 −9
docs/features/edge-only-accounts.md +16
@@ -15,3 +15,19 @@ An account the platform edge made (or one linked to an edge login) with no passw
1515 ## Dry run
1616
1717 Settings → Capacity → "Dry run" on the `signin.edge` row (or `dry_run: true` on the capacity PUT) lists who would be locked out and refuses while the admin themselves would be. Settings → Accounts shows how many accounts sign in without the edge. A passkey added from a browser the edge signed in links that login to the account (`edge_link`).
18
19 ## When the edge is gone (#11802)
20
21 Every account the edge made is mapped once to the login it forwarded, in `edge_link` (migration `20261112180000-edge-handover`), and an account the edge signs in by name is mapped on that sign-in. So the record of who came through the edge is a row, not an inference.
22
23 An edge-made account has a noreply address, so email recovery cannot reach it. For a mapped account that is not an admin and has no way in of its own, the Accounts card offers **Way-back link for <name>**: an admin presses it and the link is shown once on the page (`Cache-Control: no-store`). Hand it over by a channel you trust. It works once, for 7 days, and opens the `/recover` step: register a passkey, and the person is signed in with a session of the forge's own. Making another link replaces the first. In the terminal, the palette's **Way-back links** (admins only) lists the same accounts; Enter makes the link with the same action and shows it once on a screen of its own, `y` copies it. Only its SHA-256 hash is stored (`edge_handover`). An admin who signs in only through the edge adds a passkey while the edge still answers. Switching `signin.edge` off already refuses to strand them.
24
25 ## Removing the middleware from the Ingress
26
27 The forwardAuth middleware lives in the platform's gitops repository (`apps/forge/prod/manifests.yaml`, walked in #8948), not here. A person applies the change:
28
29 1. Wait until the card and the terminal screen are empty, or until each person left has been handed a way-back link.
30 2. On the forge's Ingress, delete the `traefik.ingress.kubernetes.io/router.middlewares` entry that names plat-auth. Keep any other middleware in that annotation. Then let the standby reconcile apply it, or apply it by hand.
31 3. Check: `curl -sI https://<forge>/healthz` answers 200, and so does `/` signed out. Signing in with a passkey works.
32 4. Unset `FORGE_EDGE_TOKEN` (or turn `signin.edge` off). The edge's headers are then a claim anyone could write, and they are ignored.
33 5. Delete the `plat-auth` row from `FOOTING` in `src/system/footing.ts` and regenerate the table in `docs/OPERATING.md`.
src/actions/edge_handover.ts added +89
@@ -0,0 +1,89 @@
1 /**
2 * Who may mint a way-back link (`core/edge_handover.ts`) and for whom: a
3 * forge admin, a person (not an agent), for an active account that is not
4 * an admin, is mapped to a platform edge login, and has no way in of its
5 * own. Anyone else is told the account is not there, like every admin
6 * surface (`actions/signins.ts`). An admin who signs in only through the
7 * edge adds a passkey while the edge still answers: switching the edge off
8 * already refuses to strand them (`actions/capacity.ts`).
9 *
10 * Opening the link is the recovery step (`actions/verify.ts`
11 * `recoveryAccount`, `spendRecoveryLink`): a new passkey, then a session.
12 */
13
14 import type { App } from "../app.ts";
15 import { signinReach, signsInWithoutEdge } from "../core/accounts.ts";
16 import { linkedLogin } from "../core/edge_links.ts";
17 import { HANDOVER_TTL_MS, issueHandover } from "../core/edge_handover.ts";
18 import { getUserByName, isActive, type User } from "../core/users.ts";
19 import { err, ok, type Result } from "../kernel/result.ts";
20 import { requireUser } from "./access.ts";
21
22 export interface WayBack {
23 readonly username: string;
24 /** The edge login the account is mapped to. */
25 readonly login: string;
26 /** The link to hand over, absolute when the forge knows its address. */
27 readonly url: string;
28 readonly expiresAt: string;
29 }
30
31 export type WayBackRefusal = "not_found" | "has_way_in";
32
33 /** An account a way-back link can be made for, and the edge login it is mapped to. */
34 export interface Stranded {
35 readonly username: string;
36 readonly login: string;
37 }
outdated

Both doors: mintWayBack has only the web caller. Add the terminal screen in src/tui/ calling this same action, with a test.

38
39 const mayMint = (actor: User | undefined): User | undefined => {
40 const asker = requireUser(actor);
41 return asker.ok && asker.value.isAdmin && asker.value.kind === "user" ? asker.value : undefined;
42 };
43
44 /**
45 * The accounts a way-back link can be made for, in the order Accounts names
46 * them: the ones `mintWayBack` would mint for. The page and the terminal
47 * screen (`tui/screens/wayback.ts`) offer the same list behind the same gate.
48 */
49 export function wayBackAccounts(app: App, actor: User | undefined): Result<Stranded[], "not_found"> {
50 if (mayMint(actor) === undefined) return err("not_found");
51 const out: Stranded[] = [];
52 for (const a of signinReach(app.db).stranded) {
53 if (a.isAdmin) continue;
54 const user = getUserByName(app.db, a.username);
55 if (user === undefined || user.kind !== "user" || !isActive(user)) continue;
56 const login = linkedLogin(app.db, user.id);
57 if (login !== undefined) out.push({ username: user.username, login });
58 }
59 return ok(out);
60 }
61
62 /** Mint (or replace) the way-back link for `username`. */
63 export function mintWayBack(
64 app: App,
65 actor: User | undefined,
66 username: string,
67 now: number = Date.now(),
68 ): Result<WayBack, WayBackRefusal> {
69 const asker = mayMint(actor);
70 if (asker === undefined) return err("not_found");
71 const user = getUserByName(app.db, username);
72 if (user === undefined || user.kind !== "user" || user.isAdmin || !isActive(user)) return err("not_found");
73 const login = linkedLogin(app.db, user.id);
74 if (login === undefined) return err("not_found");
75 if (signsInWithoutEdge(app.db, user.id)) return err("has_way_in");
76 const token = issueHandover(app.db, user.id, asker.id, now);
77 app.bus.append({ kind: "account.way_back_minted", actorId: asker.id, payload: { userId: user.id } });
78 return ok({
79 username: user.username,
80 login,
81 url: wayBackUrl(app, token),
82 expiresAt: new Date(now + HANDOVER_TTL_MS).toISOString(),
83 });
84 }
85
86 function wayBackUrl(app: App, token: string): string {
87 const path = `/recover?token=${encodeURIComponent(token)}`;
88 return app.config.url === undefined ? path : new URL(path, app.config.url).toString();
89 }
src/actions/verify.ts +10 −3
@@ -17,6 +17,7 @@ import {
1717 spendRecovery,
1818 resetVerification,
1919 } from "../core/verify.ts";
20 import { handoverOwner, spendHandover } from "../core/edge_handover.ts";
2021 import { listSecretNames, resolveSecrets } from "../core/secret_store.ts";
2122 import { getUserById, isActive, type User } from "../core/users.ts";
2223 import { sendMail, type Mailer } from "../kernel/mail.ts";
@@ -209,16 +210,22 @@ export function requestRecovery(
209210 }, now).catch(() => undefined);
210211 }
211212
212 /** The account a recovery link is for, if it still works; nothing is spent. */
213 /**
214 * The account a recovery link is for, if it still works; nothing is spent.
215 * A way-back link an admin minted for an edge account (`core/edge_handover.ts`)
216 * opens the same step.
217 */
213218 export function recoveryAccount(app: App, token: string): User | undefined {
214 const id = recoveryOwner(app.db, token, Date.now());
219 const now = Date.now();
220 const id = recoveryOwner(app.db, token, now) ?? handoverOwner(app.db, token, now);
215221 const user = id === undefined ? undefined : getUserById(app.db, id);
216222 return user !== undefined && isActive(user) ? user : undefined;
217223 }
218224
219225 /** Spend the link: the account to sign in, or `invalid` for a used or stale one. */
220226 export function spendRecoveryLink(app: App, token: string): Result<User, "invalid"> {
221 const id = spendRecovery(app.db, token, Date.now());
227 const now = Date.now();
228 const id = spendRecovery(app.db, token, now) ?? spendHandover(app.db, token, now);
222229 const user = id === undefined ? undefined : getUserById(app.db, id);
223230 if (user === undefined || !isActive(user)) return err("invalid");
224231 app.bus.append({ kind: "account.recovered", actorId: user.id });
src/core/edge_handover.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * The way back for a person the platform edge signed in, once the edge is
3 * gone (#11802). An account the edge made has a noreply address, so email
4 * recovery cannot reach it; an admin mints a way-back link for it and hands
5 * it over, and opening it registers a passkey and signs the person in, the
6 * same step a recovery link leads to (`/recover`).
7 *
8 * Only an account mapped to an edge login (`edge_link`) that has no way in
9 * of its own — no password, no passkey, no parent link — gets one. One link
10 * per account: minting again replaces it. The secret is stored only as its
11 * hash, works once, and expires after `HANDOVER_TTL_MS`. Pure data: the
12 * action decides who may mint (`actions/edge_handover.ts`).
13 */
14
15 import type { Database } from "bun:sqlite";
16 import { sha256 } from "../kernel/hash.ts";
17
18 /** How long a way-back link works: a week, since it is handed over by a person, not mailed. */
19 export const HANDOVER_TTL_MS = 7 * 24 * 3_600_000;
20
21 const secret = (): string =>
22 Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url");
23
24 /** Mint (or replace) the account's way-back link; the plain secret, once. */
25 export function issueHandover(
26 db: Database,
27 userId: string,
28 byId: string,
29 now: number,
30 ): string {
31 const token = secret();
32 db.query(
33 `INSERT INTO edge_handover (user_id, token_hash, expires_at, by_id) VALUES (?1, ?2, ?3, ?4)
34 ON CONFLICT(user_id) DO UPDATE SET token_hash = excluded.token_hash,
35 expires_at = excluded.expires_at, by_id = excluded.by_id,
36 at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
37 ).run(userId, sha256(token), new Date(now + HANDOVER_TTL_MS).toISOString(), byId);
38 return token;
39 }
40
41 /** Whose way-back link this is, if it still works; nothing is spent. */
42 export function handoverOwner(
43 db: Database,
44 token: string,
45 now: number,
46 ): string | undefined {
47 if (token === "") return undefined;
48 return (
49 db
50 .query<{ user_id: string }, [string, string]>(
51 "SELECT user_id FROM edge_handover WHERE token_hash = ?1 AND expires_at > ?2",
52 )
53 .get(sha256(token), new Date(now).toISOString())?.user_id ?? undefined
54 );
55 }
56
57 /** Spend a way-back link: the account it was for, once; undefined after. */
58 export function spendHandover(
59 db: Database,
60 token: string,
61 now: number,
62 ): string | undefined {
63 if (token === "") return undefined;
64 return (
65 db
66 .query<{ user_id: string }, [string, string]>(
67 "DELETE FROM edge_handover WHERE token_hash = ?1 AND expires_at > ?2 RETURNING user_id",
68 )
69 .get(sha256(token), new Date(now).toISOString())?.user_id ?? undefined
70 );
71 }
src/core/event_audience.ts +2
@@ -236,6 +236,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
236236 // Who is trusted to fold in, and the switch that stops it, are the owner's (`actions/trusted_sources.ts`).
237237 kind.startsWith("trust.") ||
238238 kind.startsWith("foldin.") ||
239 // A way-back link minted for an account the platform edge signed in (#11802).
240 kind === "account.way_back_minted" ||
239241 // The owner's planner switch is the admins' to hear (#11604).
240242 kind === "planner.paused" ||
241243 kind === "planner.resumed" ||
src/core/migration-pins/20261112180000-edge-handover.pin added +1
@@ -0,0 +1,1 @@
1 197cf75c99b41e445854a2ceac8c1360ff3c87907eadf99c06bffba8291237d4
src/core/migrations/20261112180000-edge-handover.ts added +25
@@ -0,0 +1,25 @@
1 // The platform's forwardAuth edge goes (#11802). Every account the edge made
2 // is mapped once to the login it forwarded (its username then), in
3 // `edge_link`, so the mapping is a row and not an inference from an event.
4 // A login already linked to another account, or an account already linked,
5 // is left as it is (INSERT OR IGNORE; the unique user_id is checked first).
6 // `edge_handover` holds one way-back link per account, minted by an admin
7 // for a mapped account with no password, passkey or parent link: its secret
8 // stored only as a hash, single-use, expiring. Additive.
9 export default `
10 INSERT OR IGNORE INTO edge_link (login, user_id)
11 SELECT u.username, u.id FROM user u
12 WHERE u.kind = 'user' AND u.is_agent = 0
13 AND NOT EXISTS (SELECT 1 FROM edge_link l WHERE l.user_id = u.id)
14 AND EXISTS (SELECT 1 FROM event e WHERE e.kind = 'user.created'
15 AND e.actor_id = u.id
16 AND CASE WHEN json_valid(e.payload)
17 THEN json_extract(e.payload, '$.via') END = 'edge');
18 CREATE TABLE edge_handover (
19 user_id TEXT PRIMARY KEY REFERENCES user(id) ON DELETE CASCADE,
20 token_hash TEXT NOT NULL UNIQUE,
21 expires_at TEXT NOT NULL,
22 by_id TEXT REFERENCES user(id) ON DELETE SET NULL,
23 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
24 );
25 `;
src/core/users.ts +12 −4
@@ -523,10 +523,18 @@ export function ensureEdgeUser(db: Database, login: string): User | undefined {
523523 const link = db
524524 .query<{ user_id: string }, [string]>("SELECT user_id FROM edge_link WHERE login = ?")
525525 .get(login);
526 const existing = (link === null ? undefined : getUserById(db, link.user_id)) ?? getUserByName(db, login);
527 return existing && existing.kind === "user" && isActive(existing)
528 ? existing
529 : undefined;
526 const linked = link === null ? undefined : getUserById(db, link.user_id);
527 const existing = linked ?? getUserByName(db, login);
528 if (!(existing && existing.kind === "user" && isActive(existing))) return undefined;
529 // Found by name: map the login to the account once, so the mapping
530 // outlives the edge (#11802). An account linked to another login, or a
531 // login another account holds, keeps what it has.
532 if (linked === undefined)
533 db.query(
534 `INSERT OR IGNORE INTO edge_link (login, user_id)
535 SELECT ?1, ?2 WHERE NOT EXISTS (SELECT 1 FROM edge_link WHERE user_id = ?2)`,
536 ).run(login, existing.id);
537 return existing;
530538 }
531539
532540 /** Why a password was not accepted: wrong, or tried too often to check. */
src/http/settings_routes.ts +2
@@ -37,6 +37,7 @@ import {
3737 retireAct,
3838 retirePage,
3939 accountsPage,
40 wayBackAct,
4041 personSignInsPage,
4142 signInAct,
4243 } from "../web/pages/accounts.ts";
@@ -297,6 +298,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
297298 router.add("GET", "/settings/accounts/:username/retire", retirePage);
298299 router.add("POST", "/settings/accounts/:username/retire", retireAct);
299300 router.add("POST", "/settings/accounts/:username/verify", accountAct("verify"));
301 router.add("POST", "/settings/accounts/:username/way-back", wayBackAct);
300302 router.add("GET", "/settings/crew", crewPage);
301303 router.add("POST", "/settings/crew/knobs", knobSet);
302304 router.add("POST", "/settings/crew", agentCreate);
src/tui/screens/entries.ts +2
@@ -36,6 +36,7 @@ import { entry as displays } from "./displays.ts";
3636 import { entry as disks } from "./disks.ts";
3737 import { entry as domains } from "./domains.ts";
3838 import { entry as edgeOnly } from "./edgeonly.ts";
39 import { entry as wayBack } from "./wayback.ts";
3940 import { entry as traffic } from "./trafficview.ts";
4041 import { entry as funnelEntry } from "./funnel.ts";
4142 import { entry as featureReview } from "./features.ts";
@@ -139,6 +140,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
139140 valves,
140141 access,
141142 edgeOnly,
143 wayBack,
142144 traffic,
143145 funnelEntry,
144146 audit,
src/tui/screens/signins.ts +1 −1
@@ -95,7 +95,7 @@ export function signInsOf(f: Session): SignIn[] {
9595 }
9696
9797 /** Cut into rows of `width` characters: wrapped, never truncated, so a hand-copied line is the whole line. */
98 function hardWrap(text: string, width: number): string[] {
98 export function hardWrap(text: string, width: number): string[] {
9999 const w = Math.max(1, width);
100100 const rows: string[] = [];
101101 for (let i = 0; i < text.length; i += w) rows.push(text.slice(i, i + w));
src/tui/screens/wayback.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * Way-back links in the terminal, a forge admin's (#11802): the accounts the
3 * platform edge made that have no way in of their own, as Settings →
4 * Accounts lists them (`wayBackAccounts`). Enter mints the link with the same
5 * action the page posts to (`mintWayBack`), behind the same gate, and shows
6 * it once on a screen of its own; nothing keeps it once that is popped.
7 */
8
9 import { mintWayBack, wayBackAccounts, type Stranded } from "../../actions/edge_handover.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import { style } from "../term/style.ts";
13 import { truncate } from "../term/width.ts";
14 import type { Session } from "../session.ts";
15 import { fill, len, split } from "../ui/layout.ts";
16 import { ListState, drawList, type ListRow } from "./common.ts";
17 import type { PaletteEntry } from "./entries.ts";
18 import { hardWrap } from "./signins.ts";
19
20 const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind === "user";
21
22 export function wayBackScreen(f: Session, ctx: Ctx): Screen {
23 const state = new ListState();
24 let accounts: Stranded[] = [];
25 let allowed = false;
26 let note = "";
27 const read = (): void => {
28 const listed = wayBackAccounts(f.app, f.user);
29 allowed = listed.ok;
30 accounts = listed.ok ? listed.value : [];
31 state.clamp(accounts.length);
32 };
33 read();
34 const mint = (): void => {
35 const picked = accounts[state.index];
36 if (picked === undefined) return;
37 const minted = mintWayBack(f.app, f.user, picked.username);
38 if (!minted.ok) {
39 note =
40 minted.error === "has_way_in"
41 ? `${picked.username} already signs in without the edge; no link was made.`
42 : `${picked.username} is not there.`;
43 read();
44 ctx.invalidate();
45 return;
46 }
47 note = "";
48 const w = minted.value;
49 ctx.push(linkScreen(ctx, w.username, w.login, w.url, w.expiresAt));
50 };
51 return {
52 title: "Way-back links",
53 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "make the link"), binding(["r"], "refresh")],
54 render(area, s) {
55 const c = ctx.theme.c;
56 const [head, list] = split(area, "v", [len(1), fill(1)]);
57 const text = !allowed
58 ? "Not found."
59 : note !== ""
60 ? note
61 : accounts.length === 0
62 ? "Every account the edge made can sign in without it."
63 : "Accounts the edge made with no way in of their own; enter makes one a way-back link.";
64 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
65 const rows: ListRow[] = accounts.map((a) => ({ title: a.username, secondary: `edge login ${a.login}`, meta: "" }));
66 drawList(s, list!, c, state, rows);
67 },
68 key(e) {
69 if (e.ctrl || e.alt) return state.key(e, accounts.length);
70 if (e.key === "enter") {
71 if (allowed) mint();
72 return true;
73 }
74 if (e.key === "r") {
75 note = "";
76 read();
77 return true;
78 }
79 return state.key(e, accounts.length);
80 },
81 };
82 }
83
84 /** The link, alone on a screen, wrapped whole so a hand-copied line is the whole line. */
85 function linkScreen(ctx: Ctx, username: string, login: string, url: string, expiresAt: string): Screen {
86 let copied = "";
87 const until = `${expiresAt.slice(0, 16).replace("T", " ")} UTC`;
88 return {
89 title: `Way-back link for ${username}`,
90 keys: [binding(["y"], "copy it"), binding(["esc", "q"], "done")],
91 render(area, s) {
92 const c = ctx.theme.c;
93 const end = area.x + area.w;
94 const lead = `Hand this to ${username} (edge login ${login}) by a channel you trust: it works once, until ${until}, and is not shown again.`;
95 const said = hardWrap(lead, area.w);
96 said.forEach((line, i) => s.text(area.x, area.y + i, line, style(c.mutedFg, c.bg), end));
97 const top = area.y + said.length + 1;
98 const lines = hardWrap(url, area.w);
99 lines.forEach((line, i) => s.text(area.x, top + i, line, style(c.fg, c.bg), end));
100 const foot = copied === "" ? "y copies it, esc is done." : copied;
101 s.text(area.x, top + lines.length + 1, truncate(foot, area.w), style(c.mutedFg, c.bg), end);
102 },
103 key(e) {
104 if (e.ctrl || e.alt) return true;
105 if (e.key === "y") {
106 ctx.copy(url);
107 copied = "Copied.";
108 } else if (e.key === "esc" || e.key === "q") {
109 ctx.pop();
110 }
111 return true;
112 },
113 };
114 }
115
116 /** Settings → Accounts' way-back links: one per account the edge made with no way in. */
117 export const entry: PaletteEntry = {
118 id: "way-back-links",
119 title: "Way-back links",
120 hint: "a link that signs in an account the edge made, once the edge is gone",
121 after: ["edge-only-accounts"],
122 offered: isAdmin,
123 run: (session, ctx) => ctx.push(wayBackScreen(session, ctx)),
124 };
src/web/pages/accounts.ts +52
@@ -29,6 +29,7 @@ import {
2929 soleOwnedOrgs,
3030 } from "../../core/orgs.ts";
3131 import { getUserByName, hasPassword, isStandInEmail } from "../../core/users.ts";
32 import { mintWayBack, wayBackAccounts } from "../../actions/edge_handover.ts";
3233 import type { Ctx } from "../../http/context.ts";
3334 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
3435 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
@@ -39,6 +40,7 @@ import {
3940 badge,
4041 btn,
4142 confirmAction,
43 copyField,
4244 field as formField,
4345 row,
4446 time,
@@ -117,6 +119,55 @@ function personRow(a: AccountRow, sole: readonly string[]): Html {
117119 });
118120 }
119121
122 /**
123 * Once the edge is gone, a person it signed in who added no passkey gets in
124 * by a way-back link an admin hands over (#11802): one button per account
125 * `wayBackAccounts` names. An admin is not offered one; they add a passkey.
126 */
127 function wayBackForms(ctx: Ctx): Html | null {
128 const listed = wayBackAccounts(ctx.app, ctx.auth.user);
129 if (!listed.ok || listed.value.length === 0) return null;
130 return html`<div class="form-actions" id="way-back">
131 ${listed.value.map(
outdated

Principle 1: this paragraph explains what the button and the result section already say. One confident sentence, said once.

132 (a) => html`<form method="post" action="/settings/accounts/${encodeURIComponent(a.username)}/way-back">
133 ${btn(`Way-back link for ${a.username}`, { type: "submit", size: "sm" })}
134 </form>`,
135 )}
136 </div>`;
137 }
138
139 // POST /settings/accounts/:username/way-back — the link, shown once on the page.
140 export async function wayBackAct(ctx: Ctx): Promise<Response> {
141 if (!ctx.auth.user) return signInFirst(ctx);
142 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
143 const form = await readForm(ctx, "admin");
144 if (form instanceof Response) return form;
145 const username = ctx.params.username ?? "";
146 const minted = mintWayBack(ctx.app, ctx.auth.user, username);
147 if (!minted.ok) {
148 if (minted.error === "not_found") return notFoundPage(ctx);
149 return flashTo(ctx, "/settings/accounts", {
150 message: `${username} already signs in without the edge; no link was made.`,
151 tone: "danger",
152 });
153 }
154 const w = minted.value;
155 const response = accountsView(
156 ctx,
157 html`<section class="settings-block" id="way-back-link">
158 <h2>Way-back link for ${w.username}</h2>
159 <p>
160 Hand this to ${w.username} (edge login <code>${w.login}</code>) by a
161 channel you trust: it works once, until ${time(w.expiresAt)}, and is
162 not shown again.
163 </p>
164 ${copyField("way-back-url", w.url, `Way-back link for ${w.username}`, "Copy the link")}
165 </section>`,
166 );
167 response.headers.set("cache-control", "no-store");
168 return response;
169 }
170
120171 // GET /settings/accounts
121172 export function accountsPage(ctx: Ctx): Response {
122173 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -192,6 +243,7 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
192243 : ""}`,
193244 )}.
194245 </p>
246 ${wayBackForms(ctx)}
195247 </section>`;
196248 const sole = timePhase(ctx.request, "owned", () =>
197249 people.map((a) => {
test/edge-gone.test.ts added +204
@@ -0,0 +1,204 @@
1 /**
2 * The platform's forwardAuth edge goes (#11802). Every account it signed in
3 * is mapped once to its login (`edge_link`); with the edge gone an account
4 * it made — a noreply address, no password, no passkey — gets in by a
5 * way-back link an admin hands over, which registers a passkey and signs
6 * in. `/healthz` and public pages answer with no edge headers at all.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { b64u } from "../src/auth/webauthn.ts";
14 import { edgeOnlyAccounts } from "../src/core/accounts.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { createUser, getUserByName } from "../src/core/users.ts";
17 import { HANDOVER_TTL_MS } from "../src/core/edge_handover.ts";
18 import { mintWayBack } from "../src/actions/edge_handover.ts";
19 import { sha256 } from "../src/kernel/hash.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import migration from "../src/core/migrations/20261112180000-edge-handover.ts";
22
23 const te = new TextEncoder();
24 const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d));
25 const cat = (...p: Uint8Array[]): Uint8Array<ArrayBuffer> => { const o: Uint8Array<ArrayBuffer> = new Uint8Array(p.reduce((n, a) => n + a.length, 0)); let i = 0; for (const a of p) { o.set(a, i); i += a.length; } return o; };
26 function head(major: number, n: number): Uint8Array {
27 if (n < 24) return Uint8Array.of((major << 5) | n);
28 if (n < 256) return Uint8Array.of((major << 5) | 24, n);
29 return Uint8Array.of((major << 5) | 25, n >> 8, n & 255);
30 }
31 type V = number | string | Uint8Array | [V, V][];
32 function enc(v: V): Uint8Array {
33 if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v);
34 if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); }
35 if (v instanceof Uint8Array) return cat(head(2, v.length), v);
36 return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)]));
37 }
38
39 /** A software authenticator that can only register. */
40 async function register(origin: string, rp: string, challenge: string) {
41 const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair;
42 const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
43 const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url"));
44 const cose: [number, V][] = [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]];
45 const id = crypto.getRandomValues(new Uint8Array(32));
46 const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x45), new Uint8Array(4), new Uint8Array(16), Uint8Array.of(0, id.length), id, enc(cose as V));
47 return {
48 clientDataJSON: b64u(te.encode(JSON.stringify({ type: "webauthn.create", challenge, origin }))),
49 attestationObject: b64u(enc([["fmt", "none"], ["attStmt", []], ["authData", authData]])),
50 };
51 }
52
53 const EDGE = "edge-secret";
54
55 describe("the forge with its forwardAuth edge gone", () => {
56 let dataDir: string;
57 let app: App;
58 let servers: Servers;
59 let base: string;
60 let ownerCookie: string;
61
62 const linkOf = (userId: string) =>
63 app.db.query<{ login: string }, [string]>("SELECT login FROM edge_link WHERE user_id = ?1").get(userId)?.login;
64 const whoAmI = async (headers: Record<string, string>) => {
65 const res = await fetch(`${base}/api/v1/user`, { headers });
66 return res.status === 200 ? ((await res.json()) as { username: string }).username : res.status;
67 };
68 const post = (path: string, body: unknown) =>
69 fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json", origin: base }, body: JSON.stringify(body) });
70
71 beforeAll(async () => {
72 dataDir = await mkdtemp(join(tmpdir(), "forge-edge-gone-"));
73 // While the edge still stands in front of the forge.
74 app = createApp({ dataDir, port: 0, edgeToken: EDGE });
75 servers = await serve(app);
76 base = servers.url;
77 const owner = await createUser(app.db, { origin: "seed", trusted: true, username: "olga", email: "[email protected]", password: "owner-passphrase" });
78 if (!owner.ok) throw new Error(owner.error);
79 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(owner.value.id);
80 });
81
82 afterAll(async () => {
83 servers.stop();
84 app.close();
85 await rm(dataDir, { recursive: true, force: true });
86 });
87
88 test("every account the edge signed in is mapped once to its login", async () => {
89 // ada: an account the edge made, as it did before #2779 (a noreply address).
90 const ada = await createUser(app.db, { origin: "edge", username: "ada", email: "[email protected]" });
91 if (!ada.ok) throw new Error(ada.error);
92 app.bus.append({ kind: "user.created", actorId: ada.value.id, payload: { via: "edge" } });
93 // bea: an account of the forge's own the edge signed in by name.
94 const bea = await createUser(app.db, { origin: "seed", trusted: true, username: "bea", email: "[email protected]", password: "bea-passphrase" });
95 if (!bea.ok) throw new Error(bea.error);
96 expect(linkOf(ada.value.id)).toBeUndefined();
97
98 // The migration's backfill maps what the edge made.
99 app.db.exec("DROP TABLE edge_handover");
100 app.db.exec(migration);
101 expect(linkOf(ada.value.id)).toBe("ada");
102 expect(linkOf(bea.value.id)).toBeUndefined();
103
104 // A sign-in through the edge by name maps that account, once.
105 expect(await whoAmI({ "x-plat-user": "bea", "x-plat-edge-token": EDGE })).toBe("bea");
106 expect(linkOf(bea.value.id)).toBe("bea");
107 expect(await whoAmI({ "x-plat-user": "bea", "x-plat-edge-token": EDGE })).toBe("bea");
108 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM edge_link").get()!.n).toBe(2);
109 expect(await whoAmI({ "x-plat-user": "ada", "x-plat-edge-token": EDGE })).toBe("ada");
110 });
111
112 test("with the edge gone, an edge account walks back in by a way-back link", async () => {
113 // The edge goes: the forge restarts with no edge secret.
114 servers.stop();
115 app.close();
116 app = createApp({ dataDir, port: 0 });
117 servers = await serve(app);
118 base = servers.url;
119 const owner = getUserByName(app.db, "olga")!;
120 ownerCookie = `forge_session=${createSession(app.db, owner.id).token}`;
121
122 // The edge's headers are now a claim anyone could write: nobody.
123 expect(await whoAmI({ "x-plat-user": "ada", "x-plat-edge-token": EDGE })).toBe(401);
124 expect(edgeOnlyAccounts(app.db).map((a) => a.username)).toEqual(["ada"]);
125
126 // The owner sees ada stranded and makes her link.
127 const card = await (await fetch(`${base}/settings/accounts`, { headers: { cookie: ownerCookie } })).text();
128 expect(card).toContain(`action="/settings/accounts/ada/way-back"`);
129 expect(card).not.toContain(`action="/settings/accounts/bea/way-back"`);
130 const made = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: ownerCookie, origin: base } });
131 expect(made.status).toBe(200);
132 expect(made.headers.get("cache-control")).toBe("no-store");
133 const token = /\/recover\?token=([\w-]+)/.exec(await made.text())![1]!;
134
135 // Only its hash is kept, for a week.
136 const row = app.db.query<{ token_hash: string; expires_at: string }, []>("SELECT token_hash, expires_at FROM edge_handover").get()!;
137 expect(row.token_hash).toBe(sha256(token));
138 const life = Date.parse(row.expires_at) - Date.now();
139 expect(life).toBeGreaterThan(HANDOVER_TTL_MS - 60_000);
140 expect(life).toBeLessThanOrEqual(HANDOVER_TTL_MS);
141
142 // ada opens it: a new passkey, then a session of the forge's own.
143 expect((await fetch(`${base}/recover?token=${token}`)).status).toBe(200);
144 const o = await post("/recover/options", { token });
145 expect(o.status).toBe(200);
146 const opts = (await o.json()) as { challenge: string; user: { name: string } };
147 expect(opts.user.name).toBe("ada");
148 const reg = await register(new URL(base).origin, new URL(base).hostname, opts.challenge);
149 const done = await post("/recover/passkey", { token, name: "Laptop", challenge: opts.challenge, ...reg });
150 expect(done.status).toBe(200);
151 const cookie = done.headers.get("set-cookie")!.split(";")[0]!;
152 expect(await whoAmI({ cookie })).toBe("ada");
153 expect(edgeOnlyAccounts(app.db)).toEqual([]);
154
155 // The link works once.
156 expect((await post("/recover/options", { token })).status).toBe(400);
157 });
158
159 test("only an admin mints one, only for a mapped account with no way in", async () => {
160 const owner = getUserByName(app.db, "olga")!;
161 const bea = getUserByName(app.db, "bea")!;
162 // bea has a password: no link.
163 expect(mintWayBack(app, owner, "bea")).toEqual({ ok: false, error: "has_way_in" });
164 // Not for an account nobody mapped, not by a non-admin, not for an admin.
165 const cy = await createUser(app.db, { origin: "seed", trusted: true, username: "cy", email: "[email protected]" });
166 if (!cy.ok) throw new Error(cy.error);
167 expect(mintWayBack(app, owner, "cy")).toEqual({ ok: false, error: "not_found" });
168 expect(mintWayBack(app, bea, "ada")).toEqual({ ok: false, error: "not_found" });
169 expect(mintWayBack(app, owner, "olga")).toEqual({ ok: false, error: "not_found" });
170 const beaCookie = `forge_session=${createSession(app.db, bea.id).token}`;
171 const refused = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: beaCookie, origin: base } });
172 expect(refused.status).toBe(404);
173 // From another site: refused before anything is made.
174 const crossed = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: ownerCookie, origin: "https://elsewhere.example" } });
175 expect(crossed.status).toBe(403);
176 });
177 });
178
179 describe("no other service up", () => {
180 let dataDir: string;
181 let app: App;
182 let servers: Servers;
183
184 beforeAll(async () => {
185 dataDir = await mkdtemp(join(tmpdir(), "forge-no-edge-"));
186 // An edge secret configured and nothing in front: no request carries its headers.
187 app = createApp({ dataDir, port: 0, edgeToken: EDGE });
188 servers = await serve(app);
189 });
190
191 afterAll(async () => {
192 servers.stop();
193 app.close();
194 await rm(dataDir, { recursive: true, force: true });
195 });
196
197 test("/healthz and the public pages answer with no edge headers at all", async () => {
198 for (const path of ["/healthz", "/", "/signin", "/explore", "/recover"]) {
199 const res = await fetch(`${servers.url}${path}`, { redirect: "manual" });
200 expect({ path, status: res.status }).toEqual({ path, status: 200 });
201 await res.arrayBuffer();
202 }
203 });
204 });
test/palette-roster.test.ts +1
@@ -173,6 +173,7 @@ test("a site-admin person is offered the whole roster", () => {
173173 ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"],
174174 ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"],
175175 ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"],
176 ["way-back-links", "Way-back links", "a link that signs in an account the edge made, once the edge is gone"],
176177 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
177178 ["from-visit-to-paid", "From visit to paid", "the last 7 days: home visits, plans seen, plan clicks, forges made, paid"],
178179 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
test/pull-quiet.test.ts +2 −1
@@ -68,7 +68,8 @@ const bytes = (t: string) => new TextEncoder().encode(t).length;
6868
6969 // Share tags with the full name (#11654): 31,983 pull, 25,359 list, budgets that plus ~170.
7070 // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. With the Your-dotfiles palette entry (#10548) the list measures 25,260, budget that plus ~170. The palette now also carries the people, three settings rows and the Tell-the-crew action (#10659): 31,869 measured on the pull page, so the pull budget is that plus ~160.
71 const BUDGET = { pull: 32_160, list: 25_530 };
71 // The admin palette's Way-back links entry (#11802) adds ~110 B: 32,097 measured on the pull page and 25,478 on the list, each budget that plus ~170.
72 const BUDGET = { pull: 32_270, list: 25_650 };
7273
7374 test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => {
7475 const text = await get(`/alice/r/pulls/${number}`);
test/tui-way-back.test.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * Both doors for the way-back link (#11802): the terminal screen lists the
3 * accounts the page offers a button for, Enter mints the link with the same
4 * action and shows it once, and anyone but an admin is told it is not there.
5 */
6 import { afterAll, beforeAll, 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 { linkEdgeLogin } from "../src/core/edge_links.ts";
12 import { createUser, getUserByName } from "../src/core/users.ts";
13 import { sha256 } from "../src/kernel/hash.ts";
14 import type { Ctx } from "../src/tui/app/app.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import { wayBackScreen, entry } from "../src/tui/screens/wayback.ts";
17 import type { Session } from "../src/tui/session.ts";
18 import { headlessTerminal } from "../src/tui/term/terminal.ts";
19 import { seedAdmin } from "./helpers/admin.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22
23 quietLogs();
24
25 let dir = "";
26 let app: App;
27
28 beforeAll(async () => {
29 dir = await mkdtemp(join(tmpdir(), "forge-tui-way-back-"));
30 app = createApp({ dataDir: dir, port: 0 });
31 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
32 if (!a.ok) throw new Error(a.error);
33 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false });
34 if (!b.ok) throw new Error(b.error);
35 const insert = (name: string) =>
36 app.db
37 .query("INSERT INTO user (id, username, kind, email, display_name, password_hash) VALUES (?1, ?1, 'user', ?1 || '@users.noreply.test', ?1, '')")
38 .run(name);
39 insert("stranded");
40 linkEdgeLogin(app.db, getUserByName(app.db, "stranded")!, "stranded-login");
41 insert("unmapped");
42 });
43 afterAll(async () => {
44 app.close();
45 await rm(dir, { recursive: true, force: true });
46 });
47
48 function boot(username: string) {
49 const user = getUserByName(app.db, username)!;
50 const { app: tui } = createForgeApp(
51 app,
52 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
53 { terminal: headlessTerminal(140, 32), exitHooks: false },
54 );
55 tui.start();
56 const key = async (k: string, ctrl = false) => {
57 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
58 await settleApp(tui);
59 };
60 return { tui, key, text: () => tui.screen.toText() };
61 }
62
63 const handover = () =>
64 app.db.query<{ token_hash: string; by_id: string }, [string]>("SELECT token_hash, by_id FROM edge_handover WHERE user_id = ?1").get("stranded");
65
66 test("an admin makes the way-back link in the terminal; it is the one the recover step spends", async () => {
67 const t = boot("alice");
68 await t.key("k", true);
69 for (const ch of "Way-back links") await t.key(ch);
70 await t.key("enter");
71 expect(t.text()).toContain("stranded");
72 expect(t.text()).toContain("edge login stranded-login");
73 expect(t.text()).not.toContain("unmapped");
74 expect(handover()).toBeNull();
75
76 await t.key("enter");
77 const shown = t.text();
78 expect(shown).toContain("Hand this to stranded (edge login stranded-login)");
79 expect(shown).toContain("not shown again");
80 const token = /\/recover\?token=([A-Za-z0-9_-]+)/.exec(shown)?.[1];
81 expect(token).toBeDefined();
82 const row = handover()!;
83 expect(row.token_hash).toBe(sha256(token!));
84 expect(row.by_id).toBe(getUserByName(app.db, "alice")!.id);
85
86 // Gone once the screen is: back on the list, the token is not on screen.
87 await t.key("esc");
88 expect(t.text()).toContain("edge login stranded-login");
89 expect(t.text()).not.toContain(token!);
90 t.tui.stop();
91 });
92
93 test("anyone but an admin is not offered it, and built directly it mints nothing", async () => {
94 const before = handover();
95 const bob = getUserByName(app.db, "bob")!;
96 expect(entry.offered({ user: bob } as unknown as Session)).toBe(false);
97 expect(entry.offered({ user: getUserByName(app.db, "alice")! } as unknown as Session)).toBe(true);
98
99 const t = boot("bob");
100 await t.key("k", true);
101 for (const ch of "Way-back links") await t.key(ch);
102 expect(t.text()).not.toContain("a link that signs in an account the edge made");
103 t.tui.stop();
104
105 const pushed: unknown[] = [];
106 const screen = wayBackScreen(
107 { db: app.db, app, user: bob } as unknown as Session,
108 { push: (s: unknown) => pushed.push(s), invalidate() {} } as unknown as Ctx,
109 );
110 screen.key({ type: "key", key: "enter", ctrl: false, alt: false, shift: false });
111 expect(pushed).toEqual([]);
112 expect(handover()).toEqual(before);
113 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs