Public: anyone can read this; the crew can change it.
The forge signs people in itself; the edge's Forgejo sign-in becomes optional #9533
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9432-the-forge-signs-people-in-itself-the-edg → main 3 commits fork 2 threads · 2 unresolved
12 files
changed +391
−29
docs/OPERATING.md +9
| @@ -48,6 +48,15 @@ token (`POST /api/v1/tokens` with the password still mints one while it is | ||
| 48 | 48 | off). A stored value outranks `FORGE_SIGNIN_EDGE`, so changing the environment |
| 49 | 49 | alone does not roll back. Switching production off is the owner's to do, with |
| 50 | 50 | the owner present. `test/signin-edge-off.test.ts` walks this round trip. |
| 51 | **Dry run first.** Settings → Capacity has a "Dry run" button on that row (and | |
| 52 | `PUT /api/v1/admin/capacity/signin.edge` with `{"dry_run":true}`): it changes | |
| 53 | nothing, says how many accounts sign in without the edge, names each one that | |
| 54 | would be locked out, and says so when that includes you, in which case the switch | |
| 55 | is refused until you add a passkey. Settings → Accounts shows the same count. | |
| 56 | With the edge off, `/_plat/login` is not linked from the sign-in page and | |
| 57 | `X-Plat-User` is ignored; `--first-admin` is untouched, so the operator's link | |
| 58 | still works as a break-glass. Locked out: see `docs/SECURITY-RUNBOOK.md`, | |
| 59 | "Locked out after the edge went off". | |
| 51 | 60 | Account settings show "open-platform.sh: linked as <login>" or "not linked": Link |
| 52 | 61 | records the login the request carries against the signed-in account, Unlink removes |
| 53 | 62 | it (same-origin POSTs). An edge sign-in finds the account by its link first, then by |
docs/SECURITY-RUNBOOK.md +22
| @@ -144,6 +144,28 @@ Use this when the forge is too busy to answer, or the Shield isn't enough. Cloud | ||
| 144 | 144 | |
| 145 | 145 | Under Attack mode also breaks anything that isn't a browser: `git` over HTTPS, API tokens, curl, and webhooks all stop working through Cloudflare. **Undo:** turn it off in the same place, as soon as the traffic has passed. |
| 146 | 146 | |
| 147 | ## Switching the forge's own sign-in on, and the edge off | |
| 148 | ||
| 149 | The forge signs people in itself (password, passkey, recovery link); the platform | |
| 150 | edge's sign-in (`signin.edge`) is optional. | |
| 151 | ||
| 152 | - **Switch:** each person adds a passkey at Settings → Passkeys (a browser the | |
| 153 | edge signed in can; the edge login is linked to the account, no new account). | |
| 154 | Settings → Accounts shows how many accounts sign in without the edge. Then | |
| 155 | Settings → Capacity → "Sign in through the platform edge" → **Dry run**: it | |
| 156 | lists who would be locked out and refuses while you would be. Set it to `off` | |
| 157 | once the list is empty or accepted. | |
| 158 | - **Undo:** set the same row to `on` (Put back), or | |
| 159 | `curl -X PUT -H "authorization: Bearer $TOKEN" -d '{"value":"on"}' $FORGE_URL/api/v1/admin/capacity/signin.edge`. | |
| 160 | A stored value outranks `FORGE_SIGNIN_EDGE`. | |
| 161 | - **Locked out (everyone, edge off):** sign in with a password or passkey; if | |
| 162 | you have neither, use the recovery link (`/recover`) if mail is set up, else | |
| 163 | run the forge with `--first-admin` for a one-time link; it works whatever | |
| 164 | `signin.edge` says. Then turn the edge back on from Settings → Capacity. | |
| 165 | - **Locked out (one person):** an admin tells them to use `/recover`, or | |
| 166 | puts the edge back on (Settings → Capacity) while the person adds a passkey, | |
| 167 | then runs the dry run again. | |
| 168 | ||
| 147 | 169 | ## After any of these |
| 148 | 170 | |
| 149 | 171 | Write down what happened, when and what you changed, in an issue on the forge once it's calm. Then check Settings → Security again. Each section shows when it was last checked. |
docs/features/edge-only-accounts.md +4
| @@ -11,3 +11,7 @@ An account the platform edge made (or one linked to an edge login) with no passw | ||
| 11 | 11 | 1. Wait until the card and the terminal screen are empty. |
| 12 | 12 | 2. Turn `signin.edge` off on a preview first, with the owner present. |
| 13 | 13 | 3. Roll back by setting it on again in Settings → Capacity. |
| 14 | ||
| 15 | ## Dry run | |
| 16 | ||
| 17 | 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`). |
src/actions/capacity.ts +23 −9
| @@ -25,7 +25,7 @@ import { | ||
| 25 | 25 | import { egressTotals, npmBytesLastHour, npmTopRepoLastHour, type EgressTotal } from "../apps/egress.ts"; |
| 26 | 26 | import { getRepoById } from "../core/repos.ts"; |
| 27 | 27 | import { selfSwapState, type SelfSwap } from "../release/swap.ts"; |
| 28 | import { passkeyIds } from "../core/passkeys.ts"; | |
| 28 | import { signinReach, signsInWithoutEdge } from "../core/accounts.ts"; | |
| 29 | 29 | import type { User } from "../core/users.ts"; |
| 30 | 30 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 31 | 31 | |
| @@ -125,14 +125,28 @@ export function listCapacity( | ||
| 125 | 125 | } |
| 126 | 126 | |
| 127 | 127 | /** |
| 128 | * Whether the account can sign in without the platform edge: a password or | |
| 129 | * a passkey. Turning the edge off for an admin who has neither locks them out. | |
| 128 | * A dry run of switching `signin.edge` off: who could no longer sign in, and | |
| 129 | * whether that includes the admin asking (which refuses the switch). Changes | |
| 130 | * nothing. | |
| 130 | 131 | */ |
| 131 | function hasOwnCredential(app: App, userId: string): boolean { | |
| 132 | const row = app.db | |
| 133 | .query<{ password_hash: string }, [string]>("SELECT password_hash FROM user WHERE id = ?1") | |
| 134 | .get(userId); | |
| 135 | return (row?.password_hash ?? "") !== "" || passkeyIds(app.db, userId).length > 0; | |
| 132 | export function edgeOffDryRun( | |
| 133 | db: Database, | |
| 134 | actor: User | undefined, | |
| 135 | ): Result< | |
| 136 | { total: number; own: number; lockedOut: { username: string; isAdmin: boolean }[]; youLockedOut: boolean; allowed: boolean }, | |
| 137 | "not_found" | |
| 138 | > { | |
| 139 | const who = admin(actor); | |
| 140 | if (!who.ok) return who; | |
| 141 | const reach = signinReach(db); | |
| 142 | const youLockedOut = !signsInWithoutEdge(db, who.value.id); | |
| 143 | return ok({ | |
| 144 | total: reach.total, | |
| 145 | own: reach.own, | |
| 146 | lockedOut: reach.stranded.map((a) => ({ username: a.username, isAdmin: a.isAdmin })), | |
| 147 | youLockedOut, | |
| 148 | allowed: !youLockedOut, | |
| 149 | }); | |
| 136 | 150 | } |
| 137 | 151 | |
| 138 | 152 | /** Set `key` to `value`; `null` or `""` goes back to the boot default. */ |
| @@ -151,7 +165,7 @@ export function changeCapacity( | ||
| 151 | 165 | const next = value === null ? null : value.trim(); |
| 152 | 166 | if (next !== null && next !== "" && !validCapacity(key, next)) |
| 153 | 167 | return err("invalid_value"); |
| 154 | if (key === "signin.edge" && next === "off" && !hasOwnCredential(app, who.value.id)) | |
| 168 | if (key === "signin.edge" && next === "off" && !signsInWithoutEdge(app.db, who.value.id)) | |
| 155 | 169 | return err("no_credential"); |
| 156 | 170 | const changed = setCapacity( |
| 157 | 171 | app.db, |
src/core/accounts.ts +43 −3
| @@ -191,21 +191,61 @@ export function scrubAccount(db: Database, userId: string, at: string): void { | ||
| 191 | 191 | */ |
| 192 | 192 | export function edgeOnlyAccounts( |
| 193 | 193 | db: Database, |
| 194 | ): { username: string; isAdmin: boolean; createdAt: string }[] { | |
| 195 | return withoutOwnCredential(db, true); | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Active people with no password, no passkey and no parent link. With | |
| 200 | * `edgeMade` only those the edge made or linked; without, everyone the | |
| 201 | * edge alone could sign in, whatever made the account. | |
| 202 | */ | |
| 203 | function withoutOwnCredential( | |
| 204 | db: Database, | |
| 205 | edgeMade: boolean, | |
| 206 | userId = "", | |
| 194 | 207 | ): { username: string; isAdmin: boolean; createdAt: string }[] { |
| 195 | 208 | return db |
| 196 | .query<{ username: string; is_admin: number; created_at: string }, [string]>( | |
| 209 | .query<{ username: string; is_admin: number; created_at: string }, [string, number, string]>( | |
| 197 | 210 | `SELECT u.username, u.is_admin, u.created_at FROM user u |
| 198 | 211 | WHERE u.kind = 'user' AND u.is_agent = 0 AND u.removed_at IS NULL |
| 212 | AND (?3 = '' OR u.id = ?3) | |
| 199 | 213 | AND u.password_hash = '' |
| 200 | 214 | AND NOT EXISTS (SELECT 1 FROM passkey p WHERE p.user_id = u.id) |
| 201 | 215 | AND NOT EXISTS (SELECT 1 FROM federated_identity f WHERE f.user_id = u.id) |
| 202 | AND (EXISTS (SELECT 1 FROM edge_link l WHERE l.user_id = u.id) | |
| 216 | AND (?2 = 0 OR EXISTS (SELECT 1 FROM edge_link l WHERE l.user_id = u.id) | |
| 203 | 217 | OR EXISTS (SELECT 1 FROM event e WHERE e.kind = 'user.created' |
| 204 | 218 | AND e.actor_id = u.id |
| 205 | 219 | AND CASE WHEN json_valid(e.payload) |
| 206 | 220 | THEN json_extract(e.payload, '$.via') END = ?1)) |
| 207 | 221 | ORDER BY u.is_admin DESC, u.created_at, u.id`, |
| 208 | 222 | ) |
| 209 | .all(EDGE_VIA) | |
| 223 | .all(EDGE_VIA, edgeMade ? 1 : 0, userId) | |
| 210 | 224 | .map((r) => ({ username: r.username, isAdmin: r.is_admin === 1, createdAt: r.created_at })); |
| 211 | 225 | } |
| 226 | ||
| 227 | /** | |
| 228 | * How far the forge's own sign-in reaches: the people who can sign in | |
| 229 | * without the platform edge and the ones who cannot (everyone `withoutOwnCredential` names). | |
| 230 | */ | |
| 231 | export function signinReach(db: Database): { | |
| 232 | total: number; | |
| 233 | own: number; | |
| 234 | stranded: { username: string; isAdmin: boolean; createdAt: string }[]; | |
| 235 | } { | |
| 236 | const total = db | |
| 237 | .query<{ n: number }, []>( | |
| 238 | "SELECT COUNT(*) AS n FROM user WHERE kind = 'user' AND is_agent = 0 AND removed_at IS NULL", | |
| 239 | ) | |
| 240 | .get()?.n ?? 0; | |
| 241 | const stranded = withoutOwnCredential(db, false); | |
| 242 | return { total, own: Math.max(0, total - stranded.length), stranded }; | |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * Whether this person can sign in without the platform edge. The one | |
| 247 | * predicate behind the reach count, the lists and the admin's own refusal. | |
| 248 | */ | |
| 249 | export function signsInWithoutEdge(db: Database, userId: string): boolean { | |
| 250 | return withoutOwnCredential(db, false, userId).length === 0; | |
| 251 | } |
src/http/api/admin.ts +7 −1
| @@ -11,7 +11,7 @@ import { kpiProbe } from "../../web/studio/kpi_probe.ts"; | ||
| 11 | 11 | import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts"; |
| 12 | 12 | import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts"; |
| 13 | 13 | import { ADMIN_WORDS } from "../../actions/admin_words.ts"; |
| 14 | import { changeCapacity, listCapacity } from "../../actions/capacity.ts"; | |
| 14 | import { changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts"; | |
| 15 | 15 | import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts"; |
| 16 | 16 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts"; |
| 17 | 17 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| @@ -161,6 +161,12 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 161 | 161 | const denied = accountAdmin(ctx, "admin"); |
| 162 | 162 | if (denied) return denied; |
| 163 | 163 | const input = await body(ctx.request); |
| 164 | // A dry run changes nothing, whatever the key: only signin.edge has one. | |
| 165 | if (input.dry_run !== undefined && input.dry_run !== false) { | |
| 166 | if (input.dry_run !== true || ctx.params.key !== "signin.edge") return notFound(); | |
| 167 | const dry = edgeOffDryRun(ctx.app.db, ctx.auth.user); | |
| 168 | return dry.ok ? json({ dry_run: true, ...dry.value }) : notFound(); | |
| 169 | } | |
| 164 | 170 | const value = input.value === null ? null : str(input.value); |
| 165 | 171 | const changed = changeCapacity(ctx.app, ctx.auth.user, ctx.params.key!, value); |
| 166 | 172 | if (changed.ok) return json(changed.value); |
src/tui/screens/edgeonly.ts +14 −9
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | * Edge-only accounts in the terminal, a forge admin's: the people the card at | |
| 3 | * the top of Settings → Accounts names, from the same `edgeOnlyAccounts`. | |
| 4 | * Each can sign in only through the platform edge, so switching | |
| 5 | * `signin.edge` off would lock them out until they add a passkey. | |
| 2 | * Edge-only accounts in the terminal, a forge admin's: the same dry run the | |
| 3 | * Capacity page and the API run (`edgeOffDryRun`): how many sign in without | |
| 4 | * the platform edge, who would be locked out if `signin.edge` went off, and | |
| 5 | * whether that includes the admin looking. Names the people the Accounts card | |
| 6 | * names, from the same `signinReach`. Changes nothing. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | import { edgeOnlyAccounts } from "../../core/accounts.ts"; | |
| 9 | import { edgeOffDryRun } from "../../actions/capacity.ts"; | |
| 9 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 10 | 11 | import { binding } from "../app/keys.ts"; |
| 11 | 12 | import { fill, len, split } from "../ui/layout.ts"; |
| @@ -20,7 +21,11 @@ const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind | ||
| 20 | 21 | |
| 21 | 22 | export function edgeOnlyScreen(f: Session, ctx: Ctx): Screen { |
| 22 | 23 | const state = new ListState(); |
| 23 | const accounts = isAdmin(f) ? edgeOnlyAccounts(f.db) : []; | |
| 24 | const dry = edgeOffDryRun(f.db, f.user); | |
| 25 | const accounts = dry.ok ? dry.value.lockedOut : []; | |
| 26 | const summary = dry.ok | |
| 27 | ? `${dry.value.own} of ${dry.value.total} can sign in without the edge${dry.value.youLockedOut ? "; you would be locked out too, so the switch is refused" : ""}` | |
| 28 | : ""; | |
| 24 | 29 | return { |
| 25 | 30 | title: "Edge-only accounts", |
| 26 | 31 | keys: [binding(["j", "k"], "move cursor")], |
| @@ -28,14 +33,14 @@ export function edgeOnlyScreen(f: Session, ctx: Ctx): Screen { | ||
| 28 | 33 | const c = ctx.theme.c; |
| 29 | 34 | const [head, body] = split(area, "v", [len(1), fill(1)]); |
| 30 | 35 | if (accounts.length === 0) { |
| 31 | emptyState(s, body!, c, { title: "No one is edge-only.", body: "Everyone has a password, a passkey or a parent link." }); | |
| 36 | emptyState(s, body!, c, { title: "Nobody would be locked out.", body: summary }); | |
| 32 | 37 | return; |
| 33 | 38 | } |
| 34 | const line = "sign in only through the edge; ask each to add a passkey"; | |
| 39 | const line = `${summary}; ask each to add a passkey`; | |
| 35 | 40 | s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 36 | 41 | const rows: ListRow[] = accounts.map((a) => ({ |
| 37 | 42 | title: a.username, |
| 38 | secondary: a.createdAt.slice(0, 10), | |
| 43 | secondary: "", | |
| 39 | 44 | meta: a.isAdmin ? "admin" : "", |
| 40 | 45 | })); |
| 41 | 46 | drawList(s, body!, c, state, rows); |
src/web/pages/accounts.ts +8 −6
| @@ -20,7 +20,7 @@ import { | ||
| 20 | 20 | revokeAccountToken, |
| 21 | 21 | } from "../../actions/signins.ts"; |
| 22 | 22 | import { mailConfigured } from "../../actions/verify.ts"; |
| 23 | import { edgeOnlyAccounts, originOf, type AccountRow } from "../../core/accounts.ts"; | |
| 23 | import { originOf, signinReach, type AccountRow } from "../../core/accounts.ts"; | |
| 24 | 24 | import { |
| 25 | 25 | OWNERS_TEAM, |
| 26 | 26 | ownerlessOrgs, |
| @@ -181,14 +181,16 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { | ||
| 181 | 181 | </p>`, |
| 182 | 182 | { tone: "attn" }, |
| 183 | 183 | ); |
| 184 | const stranded = timePhase(ctx.request, "edge-only", () => | |
| 185 | edgeOnlyAccounts(ctx.app.db), | |
| 186 | ); | |
| 184 | const reach = timePhase(ctx.request, "edge-only", () => signinReach(ctx.app.db)); | |
| 185 | const stranded = reach.stranded; | |
| 187 | 186 | const edgeOnly = |
| 188 | 187 | stranded.length === 0 |
| 189 | ? null | |
| 188 | ? card( | |
| 189 | html`<p id="signin-reach">All ${reach.total} accounts can sign in without the platform edge.</p>`, | |
| 190 | ) | |
| 190 | 191 | : card( |
| 191 | html`<p> | |
| 192 | html`<p id="signin-reach">${reach.own} of ${reach.total} accounts can sign in without the platform edge.</p> | |
| 193 | <p> | |
| 192 | 194 | ${stranded.length === 1 ? "This account signs" : "These accounts sign"} |
| 193 | 195 | in only through the platform edge, so switching |
| 194 | 196 | <code>signin.edge</code> off would lock |
src/web/pages/capacity.ts +17 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * and each one is listed below the settings: who, when, from what to what. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; | |
| 9 | import { changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; | |
| 10 | 10 | import { CAPACITY, belowCeiling, type CapacityKey } from "../../core/capacity.ts"; |
| 11 | 11 | import { bytes } from "../../system/format.ts"; |
| 12 | 12 | import { egressSnapshot } from "../../apps/egress.ts"; |
| @@ -111,6 +111,10 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 111 | 111 | variant: "primary", |
| 112 | 112 | label: `Save ${s.label}`, |
| 113 | 113 | })} |
| 114 | ${when( | |
| 115 | s.key === "signin.edge", | |
| 116 | () => html`${btn("Dry run", { size: "sm", name: "dry", value: "1", label: "List who switching the edge off would lock out" })}`, | |
| 117 | )} | |
| 114 | 118 | ${when( |
| 115 | 119 | s.source === "setting", |
| 116 | 120 | () => |
| @@ -278,6 +282,18 @@ export async function capacitySet(ctx: Ctx): Promise<Response> { | ||
| 278 | 282 | const form = await readForm(ctx, "admin"); |
| 279 | 283 | if (form instanceof Response) return form; |
| 280 | 284 | const key = field(form, "key"); |
| 285 | if (field(form, "dry") === "1") { | |
No test covers this dry=1 path (flash text and tone). Also dry run reads |
||
| 286 | const dry = edgeOffDryRun(ctx.app.db, ctx.auth.user); | |
| 287 | if (!dry.ok) return notFoundPage(ctx); | |
| 288 | const d = dry.value; | |
| 289 | const names = d.lockedOut.map((a) => a.username + (a.isAdmin ? " (admin)" : "")).join(", "); | |
| 290 | return flashTo(ctx, "/settings/capacity", { | |
| 291 | tone: d.lockedOut.length === 0 && d.allowed ? "success" : "danger", | |
| 292 | message: `${d.own} of ${d.total} accounts sign in without the edge. ${ | |
| 293 | d.lockedOut.length === 0 ? "Nobody would be locked out." : `Would be locked out: ${names}.` | |
| 294 | }${d.youLockedOut ? " You would be too, so the switch is refused until you add a passkey." : ""}`, | |
| 295 | }); | |
| 296 | } | |
| 281 | 297 | const reset = field(form, "reset") === "1"; |
| 282 | 298 | const changed = changeCapacity( |
| 283 | 299 | ctx.app, |
src/web/pages/passkeys.ts +15
| @@ -17,6 +17,8 @@ import { | ||
| 17 | 17 | passkeyRoom, |
| 18 | 18 | PASSKEY_NAME_MAX, |
| 19 | 19 | } from "../../core/passkeys.ts"; |
| 20 | import { linkEdgeLogin } from "../../core/edge_links.ts"; | |
| 21 | import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts"; | |
| 20 | 22 | import { RateLimiter } from "../../core/ratelimit.ts"; |
| 21 | 23 | import { createSession } from "../../core/sessions.ts"; |
| 22 | 24 | import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts"; |
| @@ -76,6 +78,11 @@ export function passkeysPage(ctx: Ctx): Response { | ||
| 76 | 78 | A passkey signs you in with a fingerprint, face or device PIN instead of |
| 77 | 79 | a password. Add one per device; each can be removed on its own. |
| 78 | 80 | </p> |
| 81 | <p class="muted small mb" id="passkey-why"> | |
| 82 | Why: the forge signs you in itself, so your account does not depend on | |
| 83 | another service being up or trusted. With a passkey, you can sign in here | |
| 84 | even if the platform sign-in is switched off. | |
| 85 | </p> | |
| 79 | 86 | ${keys.length === 0 |
| 80 | 87 | ? empty({ icon: "key", title: "No passkeys yet", note: "Your password still works; add a passkey now, as every new account starts with one." }) |
| 81 | 88 | : card( |
| @@ -152,6 +159,14 @@ export async function passkeyAdd(ctx: Ctx): Promise<Response> { | ||
| 152 | 159 | 409, |
| 153 | 160 | added.error === "too_many" ? "That is as many passkeys as an account keeps." : PASSKEY_TAKEN, |
| 154 | 161 | ); |
| 162 | // A browser the edge signed in carries the login it vouched for: link it to | |
| 163 | // this account, so the edge still reaches it by that login and no second | |
| 164 | // account is made. A refused link (taken, malformed) leaves the passkey added. | |
| 165 | if (ctx.auth.via === "edge") { | |
| 166 | const login = edgeIdentity(ctx.request, activeEdgeToken(ctx.app)); | |
| 167 | if (login !== undefined) | |
| 168 | linkEdgeLogin(ctx.app.db, ctx.auth.user!, login); | |
| 169 | } | |
| 155 | 170 | return json({ ok: true }); |
| 156 | 171 | } |
| 157 | 172 |
test/signin-edge-reach.test.ts added +198
| @@ -0,0 +1,198 @@ | ||
| 1 | /** | |
| 2 | * The forge's own sign-in reach (#9432): the admin sees who signs in without | |
| 3 | * the edge, a dry run names who switching it off would lock out (and refuses | |
| 4 | * the admin themselves), and a passkey added in a browser the edge signed in | |
| 5 | * links the edge login to that account without making another. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { edgeOffDryRun } from "../src/actions/capacity.ts"; | |
| 13 | import { signinReach, signsInWithoutEdge } from "../src/core/accounts.ts"; | |
| 14 | import { capacityValue } from "../src/core/capacity.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { entry, edgeOnlyScreen } from "../src/tui/screens/edgeonly.ts"; | |
| 17 | import { linkedLogin } from "../src/core/edge_links.ts"; | |
| 18 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 21 | import { device } from "./helpers/passkey.ts"; | |
| 22 | ||
| 23 | const TOKEN = "edge-secret-0123456789"; | |
| 24 | ||
| 25 | describe("signin reach", () => { | |
| 26 | let dataDir: string; | |
| 27 | let app: App; | |
| 28 | let servers: Servers; | |
| 29 | let base: string; | |
| 30 | const asAda = { "x-plat-user": "ada", "x-plat-edge-token": TOKEN }; | |
| 31 | const post = (path: string, body: unknown, headers: Record<string, string>) => | |
| 32 | fetch(`${base}${path}`, { | |
| 33 | method: "POST", | |
| 34 | headers: { "content-type": "application/json", origin: base, ...headers }, | |
| 35 | body: JSON.stringify(body), | |
| 36 | }); | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-reach-")); | |
| 40 | app = createApp({ dataDir, port: 0, edgeToken: TOKEN }); | |
| 41 | servers = await serve(app); | |
| 42 | base = servers.url; | |
| 43 | for (const [username, password] of [["olive", "correct horse battery"], ["ada", undefined]] as const) { | |
| 44 | const made = await seedAdmin(app, { username, email: `${username}@forge.example`, ...(password ? { password } : {}) }); | |
| 45 | if (!made.ok) throw new Error(made.error); | |
| 46 | } | |
| 47 | }); | |
| 48 | afterAll(async () => { | |
| 49 | servers.stop(); | |
| 50 | app.close(); | |
| 51 | await rm(dataDir, { recursive: true, force: true }); | |
| 52 | }); | |
| 53 | ||
| 54 | test("counts who signs in without the edge, and names who cannot", () => { | |
| 55 | const reach = signinReach(app.db); | |
| 56 | expect(reach.total).toBe(2); | |
| 57 | expect(reach.own).toBe(1); | |
| 58 | expect(reach.stranded.map((a) => a.username)).toEqual(["ada"]); | |
| 59 | }); | |
| 60 | ||
| 61 | test("the dry run lists the lockout, refuses an admin with no way in, and changes nothing", async () => { | |
| 62 | const ada = getUserByName(app.db, "ada")!; | |
| 63 | const dry = edgeOffDryRun(app.db, ada); | |
| 64 | expect(dry.ok && dry.value.youLockedOut).toBe(true); | |
| 65 | expect(dry.ok && dry.value.lockedOut.map((a) => a.username)).toEqual(["ada"]); | |
| 66 | const over = await fetch(`${base}/api/v1/admin/capacity/signin.edge`, { | |
| 67 | method: "PUT", | |
| 68 | headers: { "content-type": "application/json", origin: base, ...asAda }, | |
| 69 | body: JSON.stringify({ value: "off", dry_run: true }), | |
| 70 | }); | |
| 71 | expect(over.status).toBe(200); | |
| 72 | expect(await over.json()).toMatchObject({ dry_run: true, allowed: false, own: 1, total: 2 }); | |
| 73 | expect(edgeOffDryRun(app.db, getUserByName(app.db, "olive")!)).toMatchObject({ ok: true, value: { allowed: true } }); | |
| 74 | const stranger = edgeOffDryRun(app.db, undefined); | |
| 75 | expect(stranger.ok).toBe(false); | |
| 76 | }); | |
| 77 | ||
| 78 | test("the accounts page says how many sign in without the edge", async () => { | |
| 79 | const page = await (await fetch(`${base}/settings/accounts`, { headers: asAda })).text(); | |
| 80 | expect(page).toContain("1 of 2 accounts can sign in without the platform edge"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a passkey added through the edge links the login, makes no account, and covers the account", async () => { | |
| 84 | const page = await (await fetch(`${base}/settings/passkeys`, { headers: asAda })).text(); | |
| 85 | expect(page).toContain('id="passkey-why"'); | |
| 86 | const key = await device(new URL(base).origin); | |
| 87 | const opts = (await (await post("/settings/passkeys/options", {}, asAda)).json()) as { challenge: string }; | |
| 88 | const added = await post("/settings/passkeys", { name: "laptop", challenge: opts.challenge, ...(await key.create(opts.challenge)) }, asAda); | |
| 89 | expect(added.status).toBe(200); | |
| 90 | const ada = getUserByName(app.db, "ada")!; | |
| 91 | expect(linkedLogin(app.db, ada.id)).toBe("ada"); | |
| 92 | expect(signinReach(app.db)).toMatchObject({ total: 2, own: 2, stranded: [] }); | |
| 93 | }); | |
| 94 | }); | |
| 95 | ||
| 96 | describe("the dry run changes nothing, on every door", () => { | |
| 97 | let dataDir: string; | |
| 98 | let app: App; | |
| 99 | let servers: Servers; | |
| 100 | let base: string; | |
| 101 | let adaCookie: string; | |
| 102 | let bobCookie: string; | |
| 103 | ||
| 104 | beforeAll(async () => { | |
| 105 | dataDir = await mkdtemp(join(tmpdir(), "forge-dry-")); | |
| 106 | app = createApp({ dataDir, port: 0 }); | |
| 107 | servers = await serve(app); | |
| 108 | base = servers.url; | |
| 109 | const ada = await seedAdmin(app, { username: "ada", email: "[email protected]" }); | |
| 110 | if (!ada.ok) throw new Error(ada.error); | |
| 111 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bobby", email: "[email protected]", password: "correct horse battery" }); | |
| 112 | if (!bob.ok) throw new Error(bob.error); | |
| 113 | adaCookie = `forge_session=${createSession(app.db, getUserByName(app.db, "ada")!.id).token}`; | |
| 114 | bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`; | |
| 115 | }); | |
| 116 | afterAll(async () => { | |
| 117 | servers.stop(); | |
| 118 | app.close(); | |
| 119 | await rm(dataDir, { recursive: true, force: true }); | |
| 120 | }); | |
| 121 | ||
| 122 | const put = (key: string, body: unknown, cookie: string) => | |
| 123 | fetch(`${base}/api/v1/admin/capacity/${key}`, { | |
| 124 | method: "PUT", | |
| 125 | headers: { "content-type": "application/json", origin: base, cookie }, | |
| 126 | body: JSON.stringify(body), | |
| 127 | }); | |
| 128 | ||
| 129 | test("dry_run on a key with no dry run is refused and writes nothing", async () => { | |
| 130 | const key = "signin.edge"; | |
| 131 | const other = (await (await fetch(`${base}/api/v1/admin/capacity`, { headers: { cookie: adaCookie } })).json() as { items: { key: string }[] }).items.find((i) => i.key !== key)!.key; | |
| 132 | const before = capacityValue(app.db, other as never); | |
| 133 | const res = await put(other, { value: "1", dry_run: true }, adaCookie); | |
| 134 | expect(res.status).toBe(404); | |
| 135 | expect(capacityValue(app.db, other as never)).toBe(before); | |
| 136 | const after = await fetch(`${base}/api/v1/admin/capacity`, { headers: { cookie: adaCookie } }); | |
| 137 | expect(JSON.stringify(await after.json())).not.toContain('"changed"'); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a non-admin is refused the dry run", async () => { | |
| 141 | const res = await put("signin.edge", { dry_run: true }, bobCookie); | |
| 142 | expect(res.status).toBeGreaterThanOrEqual(400); | |
| 143 | expect(res.status).toBeLessThan(500); | |
| 144 | expect(await res.text()).not.toContain("locked"); | |
| 145 | }); | |
| 146 | ||
| 147 | test("the Capacity page's Dry run button flashes the verdict, and a locked-out admin cannot then switch the edge off", async () => { | |
| 148 | const form = (extra: Record<string, string>) => | |
| 149 | fetch(`${base}/settings/capacity`, { | |
| 150 | method: "POST", | |
| 151 | headers: { origin: base, cookie: adaCookie }, | |
| 152 | body: new URLSearchParams({ key: "signin.edge", ...extra }), | |
| 153 | redirect: "manual", | |
| 154 | }); | |
| 155 | const dry = await form({ dry: "1", value: "off" }); | |
| 156 | expect(dry.status).toBe(303); | |
| 157 | const seen = await fetch(new URL(dry.headers.get("location")!, base), { | |
| 158 | headers: { cookie: `${adaCookie}; ${(dry.headers.get("set-cookie") ?? "").split(";")[0]}` }, | |
| 159 | }); | |
| 160 | const page = await seen.text(); | |
| 161 | expect(page).toContain("accounts sign in without the edge"); | |
| 162 | expect(page).toContain("ada (admin)"); | |
| 163 | expect(page).toContain("You would be too"); | |
| 164 | expect(page).toMatch(/danger/); | |
| 165 | expect(capacityValue(app.db, "signin.edge")).not.toBe("off"); | |
| 166 | const refused = await form({ value: "off" }); | |
| 167 | expect(refused.status).not.toBe(200); | |
| 168 | expect(capacityValue(app.db, "signin.edge")).not.toBe("off"); | |
| 169 | }); | |
| 170 | ||
| 171 | test("the refusal and the lists agree: a federated identity or a password counts as a way in", () => { | |
| 172 | const ada = getUserByName(app.db, "ada")!; | |
| 173 | expect(signsInWithoutEdge(app.db, ada.id)).toBe(false); | |
| 174 | app.db.run("UPDATE user SET password_hash = 'x' WHERE id = ?1", [ada.id]); | |
| 175 | expect(signsInWithoutEdge(app.db, ada.id)).toBe(true); | |
| 176 | expect(signinReach(app.db).stranded.map((a) => a.username)).not.toContain("ada"); | |
| 177 | const dry = edgeOffDryRun(app.db, getUserByName(app.db, "ada")!); | |
| 178 | expect(dry.ok && dry.value.youLockedOut).toBe(false); | |
| 179 | }); | |
| 180 | ||
| 181 | test("the terminal screen gates admin and guest like the page, and names who the page names", () => { | |
| 182 | app.db.run("UPDATE user SET password_hash = '' WHERE username = 'ada'"); | |
| 183 | const ada = getUserByName(app.db, "ada")!; | |
| 184 | const bob = getUserByName(app.db, "bobby")!; | |
| 185 | const session = (user: unknown) => ({ user, db: app.db }) as never; | |
| 186 | expect(entry.offered(session(ada))).toBe(true); | |
| 187 | expect(entry.offered(session(bob))).toBe(false); | |
| 188 | expect(entry.offered(session(undefined))).toBe(false); | |
| 189 | const ctx = { theme: { c: {} } } as never; | |
| 190 | const screen = edgeOnlyScreen(session(ada), ctx); | |
| 191 | expect(screen.title).toBe("Edge-only accounts"); | |
| 192 | const names = signinReach(app.db).stranded.map((a) => a.username); | |
| 193 | const dry = edgeOffDryRun(app.db, ada); | |
| 194 | expect(dry.ok && dry.value.lockedOut.map((a) => a.username)).toEqual(names); | |
| 195 | expect(edgeOffDryRun(app.db, bob).ok).toBe(false); | |
| 196 | expect(edgeOffDryRun(app.db, undefined).ok).toBe(false); | |
| 197 | }); | |
| 198 | }); |
test/signin-window-limits.test.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** The sign-in windows hold at their defaults: twenty per account, sixty per source (#9432). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { admitSignin } from "../src/core/signin-window.ts"; | |
| 8 | ||
| 9 | let dataDir = ""; | |
| 10 | let app: App; | |
| 11 | beforeAll(async () => { | |
| 12 | dataDir = await mkdtemp(join(tmpdir(), "forge-window-")); | |
| 13 | app = createApp({ dataDir, port: 0 }); | |
| 14 | }); | |
| 15 | afterAll(async () => { | |
| 16 | app.close(); | |
| 17 | await rm(dataDir, { recursive: true, force: true }); | |
| 18 | }); | |
| 19 | ||
| 20 | test("one account is refused after twenty attempts from one address; another address has its own window", () => { | |
| 21 | for (let i = 0; i < 20; i++) expect(admitSignin(app.db, "carol", "203.0.113.1").admitted).toBe(true); | |
| 22 | expect(admitSignin(app.db, "carol", "203.0.113.1").admitted).toBe(false); | |
| 23 | expect(admitSignin(app.db, "carol", "203.0.113.9").admitted).toBe(true); | |
| 24 | }); | |
| 25 | ||
| 26 | test("one address is refused after sixty attempts across accounts", () => { | |
| 27 | for (let i = 0; i < 60; i++) expect(admitSignin(app.db, `user${i}`, "198.51.100.7").admitted).toBe(true); | |
| 28 | const shut = admitSignin(app.db, "user-new", "198.51.100.7"); | |
| 29 | expect(shut.admitted).toBe(false); | |
| 30 | expect(!shut.admitted && shut.retryAfter).toBeGreaterThan(0); | |
| 31 | }); |
new above
offline
A dry run for any key other than signin.edge falls through to changeCapacity and writes. Handle dry_run first and never fall through to a write.