Public: anyone can read this; the crew can change it.
Sign in with your forge: a forge in the fork tree signs people in through its parent's OpenID provider #2099
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/748-sign-in-with-your-forge-a-forge-in-the-f → main 9 commits fork 10 threads · 10 unresolved
README.md +17
| @@ -173,6 +173,23 @@ The relying party is the address the browser used (or | ||
| 173 | 173 | `FORGE_URL`), so a passkey does not follow a forge to another hostname. |
| 174 | 174 | Sign-up is a username, an email address and a passkey (`/signup`, `src/web/pages/signup-passkey.ts`); the running server refuses a password-only sign-up on the form, `POST /api/v1/users` and the terminal (`passkeyOnlySignup`, set in `main.ts`). Accounts that already have a password still sign in. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`) make their account with a passkey too (`web/passkey-ceremony.ts`, challenges from `POST /claim/options` and `POST /settings/everyday/options`); neither reads a password, and without JavaScript the page says a passkey needs it. A claim post with no passkey is refused and does not spend the link. |
| 175 | 175 | |
| 176 | A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned | |
| 177 | forge's `parent.json`) offers "Continue with <parent>" on `/signin`. The forge | |
| 178 | registers itself once at the parent's `/oauth/register` (the client id is kept | |
| 179 | in `rp_client`), sends the person through the parent's authorize page with | |
| 180 | PKCE, and on return checks state (also bound to the browser by an HttpOnly | |
| 181 | cookie), nonce, `iss`, `aud`, `exp` and the ES256 signature against the | |
| 182 | parent's JWKS (`kernel/jwt.ts`; keys are cached ten minutes and an unknown | |
| 183 | `kid` refetches them once a minute at most). The parent's `sub` — never the | |
| 184 | username — maps to a local account made on first sight with no password | |
| 185 | (`core/federation.ts`; `http/rp.ts` has the two routes). No dependency added. | |
| 186 | The parent's answers are read by bytes and refused past 16 KiB (64 KiB for the | |
| 187 | key set), and starting a sign-in is throttled like a password attempt. | |
| 188 | An account made this way has no password, so the terminal's username-and-password | |
| 189 | door (`s`) cannot let that person in. They add an SSH key under Settings → Keys | |
| 190 | once signed in on the web; after that the terminal signs them straight in as | |
| 191 | that account, as for anyone with a key. | |
| 192 | ||
| 176 | 193 | Your SSH key is your identity. A key the forge knows signs you straight in; any |
| 177 | 194 | other key browses public repos as a guest and can press `s` to sign in with a |
| 178 | 195 | username and password — which links that key to your account, so the next |
src/core/federation.ts added +153
| @@ -0,0 +1,153 @@ | ||
| 1 | /** | |
| 2 | * Signing in through another forge (#748): what this forge, as a relying | |
| 3 | * party, keeps. The client id it was given at the issuer (one per redirect | |
| 4 | * address), a sign-in in flight (the state only as a hash, so a leaked | |
| 5 | * database starts nothing), and which local account an issuer's `sub` is. | |
| 6 | * The username is never the key: it can be renamed, and a name the parent | |
| 7 | * frees may be taken by someone else. | |
| 8 | */ | |
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { readFileSync } from "node:fs"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { sha256 } from "../kernel/hash.ts"; | |
| 13 | import { parentUrlProblem } from "./parent_releases.ts"; | |
| 14 | import { createUser, getUserById, getUserByName, isActive, type User } from "./users.ts"; | |
| 15 | ||
| 16 | /** Where the parent sends a person back; the address registered at the parent. */ | |
| 17 | export const CALLBACK_PATH = "/signin/parent/callback"; | |
| 18 | ||
| 19 | /** A sign-in not finished in this long is gone. */ | |
| 20 | export const LOGIN_TTL_MS = 10 * 60_000; | |
| 21 | /** The most sign-ins in flight at once; the oldest make room. */ | |
| 22 | export const LOGIN_MAX = 1000; | |
| 23 | /** A `sub` we accept: an id, not free text. */ | |
| 24 | export const SUB = /^[A-Za-z0-9_-]{1,64}$/; | |
| 25 | ||
| 26 | export const clientFor = (db: Database, issuer: string, redirectUri: string): string | undefined => | |
| 27 | db | |
| 28 | .query<{ client_id: string }, [string, string]>( | |
| 29 | "SELECT client_id FROM rp_client WHERE issuer = ?1 AND redirect_uri = ?2", | |
| 30 | ) | |
| 31 | .get(issuer, redirectUri)?.client_id; | |
| 32 | ||
| 33 | /** Keep the first client id for an address; a racing second registration loses. */ | |
| 34 | export function keepClient(db: Database, issuer: string, redirectUri: string, clientId: string): string { | |
| 35 | db.query( | |
| 36 | "INSERT INTO rp_client (issuer, redirect_uri, client_id) VALUES (?1, ?2, ?3) ON CONFLICT DO NOTHING", | |
| 37 | ).run(issuer, redirectUri, clientId); | |
| 38 | return clientFor(db, issuer, redirectUri) ?? clientId; | |
| 39 | } | |
| 40 | ||
| 41 | export interface Login { | |
| 42 | readonly verifier: string; | |
| 43 | readonly nonce: string; | |
| 44 | readonly issuer: string; | |
| 45 | readonly redirectUri: string; | |
| 46 | } | |
| 47 | ||
| 48 | export function startLogin(db: Database, state: string, login: Login, now = Date.now()): void { | |
| 49 | db.query("DELETE FROM rp_login WHERE expires_at <= ?1").run(now); | |
| 50 | db.query( | |
| 51 | `DELETE FROM rp_login WHERE state_hash IN | |
| 52 | (SELECT state_hash FROM rp_login ORDER BY expires_at LIMIT max(0, (SELECT count(*) FROM rp_login) - ?1))`, | |
| 53 | ).run(LOGIN_MAX - 1); | |
| 54 | db.query( | |
| 55 | "INSERT INTO rp_login (state_hash, verifier, nonce, issuer, redirect_uri, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | |
| 56 | ).run(sha256(state), login.verifier, login.nonce, login.issuer, login.redirectUri, now + LOGIN_TTL_MS); | |
| 57 | } | |
| 58 | ||
| 59 | /** The sign-in a state began, used up by asking: a state works once. */ | |
| 60 | export function takeLogin(db: Database, state: string, now = Date.now()): Login | undefined { | |
| 61 | const row = db | |
| 62 | .query< | |
| 63 | { verifier: string; nonce: string; issuer: string; redirect_uri: string; expires_at: number }, | |
| 64 | [string] | |
| 65 | >("DELETE FROM rp_login WHERE state_hash = ?1 RETURNING verifier, nonce, issuer, redirect_uri, expires_at") | |
| 66 | .get(sha256(state)); | |
| 67 | if (row === null || row.expires_at <= now) return undefined; | |
| 68 | return { verifier: row.verifier, nonce: row.nonce, issuer: row.issuer, redirectUri: row.redirect_uri }; | |
| 69 | } | |
| 70 | ||
| 71 | /** A local username for a parent's person: their name if free and fit, else made unique by their `sub`. */ | |
| 72 | async function makeAccount(db: Database, sub: string, preferred: string): Promise<User | undefined> { | |
| 73 | const base = preferred.toLowerCase().replace(/[^a-z0-9.-]/g, "").replace(/^[.-]+/, "").slice(0, 30); | |
| 74 | const tail = sub.toLowerCase().replace(/[^a-z0-9]/g, "").slice(-8); | |
| 75 | for (const username of [base, `${base}-${tail}`, `user-${tail}`]) { | |
| 76 | if (username === "" || username.startsWith("-")) continue; | |
| 77 | if (getUserByName(db, username) !== undefined) continue; | |
| 78 | const made = await createUser(db, { | |
| 79 | username, | |
| 80 | // Unique, never mailed: `.invalid` is reserved and resolves nowhere. | |
| 81 | email: `${sub.toLowerCase()}@sso.invalid`, | |
| 82 | displayName: preferred, | |
| 83 | }); | |
| 84 | if (made.ok) return made.value; | |
| 85 | } | |
| 86 | return undefined; | |
| 87 | } | |
| 88 | ||
| 89 | const making = new Map<string, Promise<User | undefined>>(); | |
| 90 | ||
| 91 | /** | |
| 92 | * The local account for an issuer's `sub`, made on first sight with no | |
| 93 | * password. Two returns of the same person at once make one account. | |
| 94 | */ | |
| 95 | export function accountFor( | |
| 96 | db: Database, | |
| 97 | issuer: string, | |
| 98 | sub: string, | |
| 99 | preferred: string, | |
| 100 | ): Promise<User | undefined> { | |
| 101 | const key = `${issuer}\0${sub}`; | |
| 102 | const pending = making.get(key); | |
| 103 | if (pending !== undefined) return pending; | |
| 104 | const attempt = (async (): Promise<User | undefined> => { | |
| 105 | const known = db | |
| 106 | .query<{ user_id: string }, [string, string]>( | |
| 107 | "SELECT user_id FROM federated_identity WHERE issuer = ?1 AND sub = ?2", | |
| 108 | ) | |
| 109 | .get(issuer, sub); | |
| 110 | if (known !== null) { | |
| 111 | const user = getUserById(db, known.user_id); | |
| 112 | return user !== undefined && isActive(user) ? user : undefined; | |
| 113 | } | |
| 114 | const made = await makeAccount(db, sub, preferred); | |
| 115 | if (made === undefined) return undefined; | |
| 116 | db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)").run( | |
| 117 | issuer, | |
| 118 | sub, | |
| 119 | made.id, | |
| 120 | ); | |
| 121 | return made; | |
| 122 | })(); | |
| 123 | making.set(key, attempt); | |
| 124 | void attempt.finally(() => making.delete(key)); | |
| 125 | return attempt; | |
| 126 | } | |
| 127 | ||
| 128 | /** The spawn record's upstream, read once per forge: it does not change while the process runs. */ | |
| 129 | const recorded = new WeakMap<object, string | undefined>(); | |
| 130 | ||
| 131 | /** The parent forge's origin: the address this forge was started with, or the spawn record's. Undefined for a forge with none. */ | |
| 132 | export function parentIssuerOf(config: { | |
| 133 | parentUrl?: string; | |
| 134 | parentLoopback?: boolean; | |
| 135 | dataDir: string; | |
| 136 | }): string | undefined { | |
| 137 | let raw = config.parentUrl; | |
| 138 | if (raw === undefined) { | |
| 139 | if (!recorded.has(config)) { | |
| 140 | let upstream: string | undefined; | |
| 141 | try { | |
| 142 | const doc = JSON.parse(readFileSync(join(config.dataDir, "parent.json"), "utf8")) as { upstream?: unknown }; | |
| 143 | if (typeof doc.upstream === "string") upstream = doc.upstream; | |
| 144 | } catch { | |
| 145 | // No record: no parent. | |
| 146 | } | |
| 147 | recorded.set(config, upstream); | |
| 148 | } | |
| 149 | raw = recorded.get(config); | |
| 150 | } | |
| 151 | if (raw === undefined || parentUrlProblem(raw, config.parentLoopback === true) !== null) return undefined; | |
| 152 | return new URL(raw).origin; | |
| 153 | } |
src/core/migrations/20261016130000-sign-in-with-the-parent.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | // Sign in with the parent forge (#748, core/federation.ts): the client id this | |
| 2 | // forge was given at its parent, a sign-in in flight (state kept only as a | |
| 3 | // hash), and which local account a parent's `sub` is. | |
| 4 | export default ` | |
| 5 | CREATE TABLE rp_client ( | |
| 6 | issuer TEXT NOT NULL, | |
| 7 | redirect_uri TEXT NOT NULL, | |
| 8 | client_id TEXT NOT NULL, | |
| 9 | PRIMARY KEY (issuer, redirect_uri) | |
| 10 | ) WITHOUT ROWID; | |
| 11 | CREATE TABLE rp_login ( | |
| 12 | state_hash TEXT PRIMARY KEY, | |
| 13 | verifier TEXT NOT NULL, | |
| 14 | nonce TEXT NOT NULL, | |
| 15 | issuer TEXT NOT NULL, | |
| 16 | redirect_uri TEXT NOT NULL, | |
| 17 | expires_at INTEGER NOT NULL | |
| 18 | ) WITHOUT ROWID; | |
| 19 | CREATE TABLE federated_identity ( | |
| 20 | issuer TEXT NOT NULL, | |
| 21 | sub TEXT NOT NULL, | |
| 22 | user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, | |
| 23 | PRIMARY KEY (issuer, sub) | |
| 24 | ) WITHOUT ROWID; | |
| 25 | `; |
src/core/migrations/20261016180000-signin-only-code.ts added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | // A consent that only signs someone in (OpenID scopes, no ro/rw) mints no | |
| 2 | // token at the exchange; the code remembers it was asked that way. | |
| 3 | export default ` | |
| 4 | ALTER TABLE oauth_code ADD COLUMN signin_only INTEGER NOT NULL DEFAULT 0; | |
| 5 | `; |
src/core/oauth.ts +20 −3
| @@ -163,6 +163,8 @@ export interface AuthRequest { | ||
| 163 | 163 | /** `openid` and the OIDC scopes asked with it; empty for a plain OAuth grant. */ |
| 164 | 164 | readonly oidcScopes: readonly string[]; |
| 165 | 165 | readonly nonce: string; |
| 166 | /** OpenID scopes and no `ro`/`rw`: the person is signed in, no API token is granted. */ | |
| 167 | readonly signInOnly: boolean; | |
| 166 | 168 | } |
| 167 | 169 | |
| 168 | 170 | const CHALLENGE = /^[A-Za-z0-9_-]{43}$/; |
| @@ -206,6 +208,7 @@ export function parseAuthRequest( | ||
| 206 | 208 | scope: asked ?? "ro", |
| 207 | 209 | oidcScopes: openid ? oidc : [], |
| 208 | 210 | nonce: openid ? nonce : "", |
| 211 | signInOnly: openid && asked === undefined, | |
| 209 | 212 | }); |
| 210 | 213 | } |
| 211 | 214 | |
| @@ -234,10 +237,11 @@ export function issueCode( | ||
| 234 | 237 | request: AuthRequest, |
| 235 | 238 | grant: { scope: OAuthScope; name: string; lifetimeS: number | null }, |
| 236 | 239 | ): string { |
| 240 | const signInOnly = request.signInOnly ? 1 : 0; | |
| 237 | 241 | const code = secret(); |
| 238 | 242 | db.query( |
| 239 | `INSERT INTO oauth_code (code_hash, client_id, user_id, redirect_uri, challenge, scope, name, lifetime_s, expires_at, oidc_scopes, nonce) | |
| 240 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`, | |
| 243 | `INSERT INTO oauth_code (code_hash, client_id, user_id, redirect_uri, challenge, scope, name, lifetime_s, expires_at, oidc_scopes, nonce, signin_only) | |
| 244 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`, | |
| 241 | 245 | ).run( |
| 242 | 246 | sha256(code), |
| 243 | 247 | request.client.id, |
| @@ -250,6 +254,7 @@ export function issueCode( | ||
| 250 | 254 | new Date(Date.now() + CODE_TTL_MS).toISOString(), |
| 251 | 255 | request.oidcScopes.join(" "), |
| 252 | 256 | request.nonce, |
| 257 | signInOnly, | |
| 253 | 258 | ); |
| 254 | 259 | db.query("DELETE FROM oauth_code WHERE expires_at <= ?1").run( |
| 255 | 260 | new Date().toISOString(), |
| @@ -258,7 +263,8 @@ export function issueCode( | ||
| 258 | 263 | } |
| 259 | 264 | |
| 260 | 265 | export interface Minted { |
| 261 | readonly token: string; | |
| 266 | /** Absent for a sign-in-only grant: no credential is left behind. */ | |
| 267 | readonly token?: string; | |
| 262 | 268 | readonly scope: OAuthScope; |
| 263 | 269 | /** Seconds the token lives, or null for one that does not expire. */ |
| 264 | 270 | readonly expiresIn: number | null; |
| @@ -295,6 +301,7 @@ export async function redeemCode( | ||
| 295 | 301 | expires_at: string; |
| 296 | 302 | oidc_scopes: string; |
| 297 | 303 | nonce: string; |
| 304 | signin_only: number; | |
| 298 | 305 | }, |
| 299 | 306 | [string] |
| 300 | 307 | >("DELETE FROM oauth_code WHERE code_hash = ?1 RETURNING *") |
| @@ -310,6 +317,16 @@ export async function redeemCode( | ||
| 310 | 317 | if (digest !== row.challenge) return err("invalid_grant"); |
| 311 | 318 | const user = getUserById(db, row.user_id); |
| 312 | 319 | if (user === null || user === undefined || !isActive(user)) return err("invalid_grant"); |
| 320 | if (row.signin_only === 1) { | |
| 321 | const idToken = await signIdToken(db, dataDir, { | |
| 322 | issuer: input.issuer, | |
| 323 | clientId: row.client_id, | |
| 324 | user, | |
| 325 | scopes: row.oidc_scopes.split(" "), | |
| 326 | nonce: row.nonce, | |
| 327 | }); | |
| 328 | return ok({ scope: row.scope, expiresIn: null, idToken }); | |
| 329 | } | |
| 313 | 330 | const minted = createToken(db, row.user_id, row.name, row.scope, { |
| 314 | 331 | ...(row.lifetime_s === null |
| 315 | 332 | ? {} |
src/http/jwks.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** | |
| 2 | * Another forge's signing keys, fetched once and kept. A key id that is not | |
| 3 | * in what was kept may mean the issuer rotated, so the set is fetched again | |
| 4 | * — once, and not again for a minute however many tokens name an unknown | |
| 5 | * kid, so a forged token cannot make this forge hammer its parent. | |
| 6 | */ | |
| 7 | import { readCapped } from "../origins/providers/http.ts"; | |
| 8 | import type { Jwk } from "../kernel/jwt.ts"; | |
| 9 | ||
| 10 | export const JWKS_FRESH_MS = 10 * 60_000; | |
| 11 | export const JWKS_REFETCH_MS = 60_000; | |
| 12 | const BODY_MAX = 64 * 1024; | |
| 13 | ||
| 14 | export type Fetch = (url: string, init?: RequestInit) => Promise<Response>; | |
| 15 | ||
| 16 | export class KeySet { | |
| 17 | private keys: Jwk[] = []; | |
| 18 | private at = -Infinity; | |
| 19 | private pending: Promise<void> | undefined; | |
| 20 | ||
| 21 | constructor( | |
| 22 | private readonly url: string, | |
| 23 | private readonly fetcher: Fetch = fetch, | |
| 24 | ) {} | |
| 25 | ||
| 26 | async find(kid: string, now = Date.now()): Promise<Jwk | undefined> { | |
| 27 | const kept = (): Jwk | undefined => this.keys.find((k) => k["kid"] === kid); | |
| 28 | if (now - this.at < JWKS_FRESH_MS && kept() !== undefined) return kept(); | |
| 29 | if (now - this.at >= JWKS_REFETCH_MS) await this.load(now); | |
| 30 | return kept(); | |
| 31 | } | |
| 32 | ||
| 33 | private load(now: number): Promise<void> { | |
| 34 | this.pending ??= (async () => { | |
| 35 | try { | |
| 36 | const res = await this.fetcher(this.url, { redirect: "error", signal: AbortSignal.timeout(5000) }); | |
| 37 | if (!res.ok) return; | |
outdated
High: |
||
| 38 | const text = await readCapped(res, BODY_MAX, AbortSignal.timeout(5000)); | |
| 39 | if (text === undefined) return; | |
| 40 | const list = (JSON.parse(text) as { keys?: unknown }).keys; | |
| 41 | if (!Array.isArray(list)) return; | |
| 42 | this.keys = list.filter((k): k is Jwk => typeof k === "object" && k !== null); | |
| 43 | this.at = now; | |
| 44 | } catch { | |
| 45 | // Unreachable or malformed: keep what we had; the caller refuses the token. | |
| 46 | } finally { | |
| 47 | this.pending = undefined; | |
| 48 | } | |
| 49 | })(); | |
| 50 | return this.pending; | |
| 51 | } | |
| 52 | } |
src/http/oauth.ts +2 −2
| @@ -82,9 +82,9 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 82 | 82 | if (!minted.ok) return grantError(minted.error); |
| 83 | 83 | return json( |
| 84 | 84 | { |
| 85 | access_token: minted.value.token, | |
| 85 | ...(minted.value.token === undefined ? {} : { access_token: minted.value.token }), | |
| 86 | 86 | token_type: "Bearer", |
| 87 | scope: minted.value.scope, | |
| 87 | ...(minted.value.token === undefined ? {} : { scope: minted.value.scope }), | |
| 88 | 88 | ...(minted.value.idToken === undefined ? {} : { id_token: minted.value.idToken }), |
| 89 | 89 | ...(minted.value.expiresIn === null |
| 90 | 90 | ? {} |
src/http/rp.ts added +220
| @@ -0,0 +1,220 @@ | ||
| 1 | /** | |
| 2 | * Continue with the parent forge (#748): this forge as a relying party of | |
| 3 | * the OpenID provider its parent already is. `/signin/parent` registers | |
| 4 | * this forge at the parent once (the client id is kept), then sends the | |
| 5 | * person there with a state, a nonce and a PKCE challenge; the state also | |
| 6 | * rides in an HttpOnly cookie so only the browser that began can finish. | |
| 7 | * `/signin/parent/callback` trades the code, checks the id_token (state, | |
| 8 | * nonce, iss, aud, exp, signature against the parent's JWKS) and signs in | |
| 9 | * the local account the parent's `sub` maps to, made on first sight. | |
| 10 | * Both are GETs that change nothing a stranger could use: a callback with | |
| 11 | * no matching cookie and state is refused before anything is fetched. | |
| 12 | */ | |
| 13 | import { createHash, randomBytes } from "node:crypto"; | |
| 14 | import type { App } from "../app.ts"; | |
| 15 | import { | |
| 16 | accountFor, | |
| 17 | clientFor, | |
| 18 | keepClient, | |
| 19 | parentIssuerOf, | |
| 20 | SUB, | |
| 21 | startLogin, | |
| 22 | takeLogin, | |
| 23 | CALLBACK_PATH, | |
| 24 | } from "../core/federation.ts"; | |
| 25 | import { readCapped } from "../origins/providers/http.ts"; | |
| 26 | import { clientAddress } from "./peer.ts"; | |
| 27 | import { signinAllowed } from "../web/pages/site.ts"; | |
| 28 | import { createSession } from "../core/sessions.ts"; | |
| 29 | import { verifyJwt } from "../kernel/jwt.ts"; | |
| 30 | import { html } from "../web/html.ts"; | |
| 31 | import { page, redirect } from "../web/layout.ts"; | |
| 32 | import { sessionCookie } from "../web/pages/site.ts"; | |
| 33 | import { cookieValue, type Ctx } from "./context.ts"; | |
| 34 | import { KeySet } from "./jwks.ts"; | |
| 35 | ||
| 36 | export const STATE_COOKIE = "forge_rp"; | |
| 37 | const SCOPE = "openid profile"; | |
With no |
||
| 38 | const rand = (bytes: number): string => randomBytes(bytes).toString("base64url"); | |
| 39 | ||
| 40 | const parentIssuer = (app: App): string | undefined => parentIssuerOf(app.config); | |
| 41 | ||
| 42 | const keySets = new WeakMap<App, Map<string, KeySet>>(); | |
| 43 | const keysOf = (app: App, issuer: string): KeySet => { | |
| 44 | let sets = keySets.get(app); | |
| 45 | if (sets === undefined) keySets.set(app, (sets = new Map())); | |
| 46 | let set = sets.get(issuer); | |
| 47 | if (set === undefined) sets.set(issuer, (set = new KeySet(`${issuer}/oauth/jwks`))); | |
| 48 | return set; | |
| 49 | }; | |
| 50 | ||
| 51 | /** What the parent may say in one answer: a client id or a token is small. */ | |
| 52 | const ANSWER_MAX = 16 * 1024; | |
| 53 | ||
| 54 | /** The parent's JSON answer, read by bytes and refused past the cap. */ | |
| 55 | async function answerOf(res: Response): Promise<Record<string, unknown>> { | |
| 56 | const text = await readCapped(res, ANSWER_MAX, AbortSignal.timeout(5000)); | |
| 57 | if (text === undefined) throw new Error("the parent's answer was too large"); | |
| 58 | const body: unknown = JSON.parse(text); | |
| 59 | if (typeof body !== "object" || body === null) throw new Error("the parent's answer was not an object"); | |
| 60 | return body as Record<string, unknown>; | |
| 61 | } | |
| 62 | ||
| 63 | const redirectUriOf = (ctx: Ctx): string => `${ctx.url.origin}${CALLBACK_PATH}`; | |
| 64 | ||
| 65 | /** The client id at the issuer for this address, registering once if there is none. */ | |
| 66 | const registering = new Map<string, Promise<string>>(); | |
| 67 | async function clientId(ctx: Ctx, issuer: string, redirectUri: string): Promise<string> { | |
| 68 | const known = clientFor(ctx.app.db, issuer, redirectUri); | |
| 69 | if (known !== undefined) return known; | |
outdated
High: |
||
| 70 | const key = `${issuer}\0${redirectUri}`; | |
| 71 | let pending = registering.get(key); | |
| 72 | if (pending === undefined) { | |
| 73 | pending = (async () => { | |
| 74 | const res = await fetch(`${issuer}/oauth/register`, { | |
| 75 | method: "POST", | |
| 76 | headers: { "content-type": "application/json" }, | |
| 77 | body: JSON.stringify({ | |
| 78 | client_name: ctx.url.host.slice(0, 100), | |
| 79 | redirect_uris: [redirectUri], | |
| 80 | }), | |
| 81 | redirect: "error", | |
| 82 | signal: AbortSignal.timeout(5000), | |
| 83 | }); | |
| 84 | const body = await answerOf(res); | |
| 85 | if (res.status !== 201 || typeof body.client_id !== "string" || body.client_id === "") | |
| 86 | throw new Error(`registration refused (${res.status})`); | |
| 87 | return keepClient(ctx.app.db, issuer, redirectUri, body.client_id); | |
| 88 | })(); | |
| 89 | registering.set(key, pending); | |
| 90 | void pending.then( | |
| 91 | () => registering.delete(key), | |
| 92 | () => registering.delete(key), | |
| 93 | ); | |
| 94 | } | |
| 95 | return pending; | |
| 96 | } | |
| 97 | ||
| 98 | function failure(ctx: Ctx, message: string, status: number): Response { | |
| 99 | const response = page( | |
| 100 | { | |
| 101 | ctx, | |
| 102 | title: "Sign in", | |
| 103 | body: html`<div class="narrow"> | |
| 104 | <div class="page-head"><h1>Could not sign you in</h1></div> | |
| 105 | <p role="alert">${message}</p> | |
| 106 | <p><a href="/signin">Back to sign in</a></p> | |
| 107 | </div>`, | |
| 108 | }, | |
| 109 | status, | |
| 110 | ); | |
| 111 | response.headers.set("cache-control", "no-store"); | |
| 112 | return response; | |
| 113 | } | |
| 114 | ||
| 115 | const cookieHeader = (value: string, maxAge: number, url: URL, forgeUrl?: string): string => { | |
| 116 | const secure = url.protocol === "https:" || forgeUrl?.startsWith("https://") ? "; Secure" : ""; | |
| 117 | return `${STATE_COOKIE}=${value}; Path=/signin/parent; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`; | |
| 118 | }; | |
outdated
Medium: an anonymous GET writes a row here, and |
||
| 119 | ||
| 120 | export async function parentStart(ctx: Ctx): Promise<Response> { | |
| 121 | const issuer = parentIssuer(ctx.app); | |
| 122 | if (issuer === undefined) return failure(ctx, "This forge has no parent to sign in through.", 404); | |
| 123 | // Each start writes a row and the table is bounded: a flood must not push | |
| 124 | // out the sign-ins of people in flight. By socket address, never a header. | |
| 125 | const wait = signinAllowed(ctx, `parent:${clientAddress(ctx.request)}`); | |
Low: |
||
| 126 | if (wait !== undefined) { | |
| 127 | const slow = failure(ctx, `Too many attempts. Try again in ${wait}s.`, 429); | |
| 128 | slow.headers.set("retry-after", String(wait)); | |
| 129 | return slow; | |
| 130 | } | |
| 131 | const redirectUri = redirectUriOf(ctx); | |
| 132 | let client: string; | |
| 133 | try { | |
| 134 | client = await clientId(ctx, issuer, redirectUri); | |
| 135 | } catch { | |
| 136 | return failure(ctx, "The parent forge could not be reached.", 502); | |
| 137 | } | |
| 138 | const state = rand(24); | |
| 139 | const verifier = rand(48); | |
| 140 | const nonce = rand(24); | |
| 141 | startLogin(ctx.app.db, state, { verifier, nonce, issuer, redirectUri }); | |
| 142 | const query = new URLSearchParams({ | |
| 143 | response_type: "code", | |
| 144 | client_id: client, | |
| 145 | redirect_uri: redirectUri, | |
| 146 | scope: SCOPE, | |
| 147 | state, | |
| 148 | nonce, | |
| 149 | code_challenge: createHash("sha256").update(verifier).digest("base64url"), | |
| 150 | code_challenge_method: "S256", | |
| 151 | }); | |
| 152 | const headers = new Headers({ | |
| 153 | location: `${issuer}/oauth/authorize?${query}`, | |
| 154 | "cache-control": "no-store", | |
| 155 | }); | |
| 156 | headers.append("set-cookie", cookieHeader(state, 600, ctx.url, ctx.app.config.url)); | |
| 157 | return new Response(null, { status: 303, headers }); | |
| 158 | } | |
| 159 | ||
| 160 | const clean = (s: unknown, max: number): string => | |
| 161 | // eslint-disable-next-line no-control-regex | |
| 162 | typeof s === "string" ? s.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, max) : ""; | |
| 163 | ||
| 164 | export async function parentCallback(ctx: Ctx): Promise<Response> { | |
| 165 | const q = ctx.url.searchParams; | |
| 166 | const state = q.get("state") ?? ""; | |
| 167 | const code = q.get("code") ?? ""; | |
| 168 | // The cookie is the browser that began; without it nothing is fetched or spent. | |
| 169 | if (state === "" || state !== cookieValue(ctx.request, STATE_COOKIE)) | |
| 170 | return failure(ctx, "This sign-in did not start in this browser.", 400); | |
| 171 | const login = takeLogin(ctx.app.db, state); | |
| 172 | const clear = cookieHeader("", 0, ctx.url, ctx.app.config.url); | |
| 173 | const refuse = (message: string, status = 400): Response => { | |
outdated
High: the same problem on the token response. Cap it by bytes read before parsing. |
||
| 174 | const response = failure(ctx, message, status); | |
| 175 | response.headers.append("set-cookie", clear); | |
| 176 | return response; | |
| 177 | }; | |
| 178 | if (login === undefined || login.issuer !== parentIssuer(ctx.app) || login.redirectUri !== redirectUriOf(ctx)) | |
| 179 | return refuse("This sign-in took too long or was already used."); | |
| 180 | if (q.has("error") || code === "" || code.length > 512) | |
| 181 | return refuse("The parent forge did not sign you in."); | |
| 182 | try { | |
| 183 | const client = await clientId(ctx, login.issuer, login.redirectUri); | |
| 184 | const res = await fetch(`${login.issuer}/oauth/token`, { | |
| 185 | method: "POST", | |
| 186 | body: new URLSearchParams({ | |
| 187 | grant_type: "authorization_code", | |
| 188 | code, | |
| 189 | client_id: client, | |
| 190 | redirect_uri: login.redirectUri, | |
| 191 | code_verifier: login.verifier, | |
| 192 | }), | |
| 193 | redirect: "error", | |
| 194 | signal: AbortSignal.timeout(5000), | |
| 195 | }); | |
| 196 | const body = await answerOf(res); | |
| 197 | if (!res.ok || typeof body.id_token !== "string") | |
High: the token answer also carries an |
||
| 198 | return refuse("The parent forge did not accept the sign-in.", 502); | |
| 199 | const keys = keysOf(ctx.app, login.issuer); | |
| 200 | const verified = await verifyJwt( | |
| 201 | body.id_token, | |
| 202 | (kid) => keys.find(kid), | |
| 203 | { issuer: login.issuer, audience: client, nonce: login.nonce }, | |
| 204 | ); | |
| 205 | if (!verified.ok) return refuse("The parent forge's answer did not verify.", 502); | |
| 206 | const sub = verified.value["sub"]; | |
| 207 | if (typeof sub !== "string" || !SUB.test(sub)) | |
| 208 | return refuse("The parent forge's answer did not verify.", 502); | |
| 209 | const name = clean(verified.value["preferred_username"], 64); | |
| 210 | const user = await accountFor(ctx.app.db, login.issuer, sub, name); | |
| 211 | if (user === undefined) return refuse("An account for you could not be made or is not allowed in.", 403); | |
| 212 | const session = createSession(ctx.app.db, user.id); | |
| 213 | const done = redirect("/", sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url)); | |
| 214 | done.headers.append("set-cookie", clear); | |
| 215 | done.headers.set("cache-control", "no-store"); | |
| 216 | return done; | |
| 217 | } catch { | |
| 218 | return refuse("The parent forge could not be reached.", 502); | |
| 219 | } | |
| 220 | } |
src/http/server.ts +3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| 9 | 9 | import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts"; |
| 10 | 10 | import { unsealReleased } from "../actions/sealed.ts"; |
| 11 | import { parentCallback, parentStart } from "./rp.ts"; | |
| 11 | 12 | import { accessPage } from "../web/pages/access.ts"; |
| 12 | 13 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 13 | 14 | import { flowPage } from "../web/pages/flow.ts"; |
| @@ -633,6 +634,8 @@ export function buildRouter(): Router<Handler> { | ||
| 633 | 634 | router.add("POST", "/front", frontUpdate); |
| 634 | 635 | router.add("GET", "/signin", signinPage); |
| 635 | 636 | router.add("POST", "/signin", signinSubmit); |
| 637 | router.add("GET", "/signin/parent", parentStart); | |
| 638 | router.add("GET", "/signin/parent/callback", parentCallback); | |
| 636 | 639 | router.add("GET", "/signup", signupPage); |
| 637 | 640 | router.add("GET", "/verify", verifyPage); |
| 638 | 641 | router.add("POST", "/signup", signupSubmit); |
src/kernel/jwt.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * Verifying a JWT someone else signed: ES256 only, the key looked up by | |
| 3 | * `kid` through a function the caller gives (so where keys come from — a | |
| 4 | * cache, a fetch, a test — is not this file's business), then the claims a | |
| 5 | * relying party must check: issuer, audience, expiry and, when asked, nonce. | |
| 6 | * Anything off is `invalid_token`; the reason is not told to the caller. | |
| 7 | */ | |
| 8 | import { err, ok, type Result } from "./result.ts"; | |
| 9 | ||
| 10 | export type Jwk = Record<string, unknown>; | |
| 11 | ||
| 12 | export interface JwtExpect { | |
| 13 | readonly issuer: string; | |
| 14 | readonly audience: string; | |
| 15 | readonly nonce?: string; | |
| 16 | } | |
| 17 | ||
| 18 | const KEY = { name: "ECDSA", namedCurve: "P-256" } as const; | |
| 19 | const SIGN = { name: "ECDSA", hash: "SHA-256" } as const; | |
| 20 | const SEGMENT = /^[A-Za-z0-9_-]+$/; | |
| 21 | ||
| 22 | const decode = (part: string): Record<string, unknown> => { | |
| 23 | const parsed: unknown = JSON.parse(Buffer.from(part, "base64url").toString()); | |
| 24 | if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) | |
| 25 | throw new Error("not an object"); | |
| 26 | return parsed as Record<string, unknown>; | |
| 27 | }; | |
| 28 | ||
| 29 | export async function verifyJwt( | |
| 30 | jwt: string, | |
| 31 | keyFor: (kid: string) => Promise<Jwk | undefined>, | |
| 32 | expect: JwtExpect, | |
| 33 | now = Date.now(), | |
| 34 | ): Promise<Result<Record<string, unknown>, "invalid_token">> { | |
| 35 | const parts = jwt.split("."); | |
| 36 | if (parts.length !== 3 || !parts.every((p) => SEGMENT.test(p))) | |
| 37 | return err("invalid_token"); | |
| 38 | try { | |
| 39 | const head = decode(parts[0]!); | |
| 40 | if (head["alg"] !== "ES256" || typeof head["kid"] !== "string") | |
| 41 | return err("invalid_token"); | |
| 42 | const jwk = await keyFor(head["kid"]); | |
| 43 | if (jwk === undefined) return err("invalid_token"); | |
| 44 | const pub = await crypto.subtle.importKey("jwk", jwk as never, KEY, false, ["verify"]); | |
| 45 | const good = await crypto.subtle.verify( | |
| 46 | SIGN, | |
| 47 | pub, | |
| 48 | Buffer.from(parts[2]!, "base64url"), | |
| 49 | new TextEncoder().encode(`${parts[0]}.${parts[1]}`), | |
| 50 | ); | |
| 51 | if (!good) return err("invalid_token"); | |
| 52 | const claims = decode(parts[1]!); | |
| 53 | if (claims["iss"] !== expect.issuer || claims["aud"] !== expect.audience) | |
| 54 | return err("invalid_token"); | |
| 55 | if (typeof claims["exp"] !== "number" || claims["exp"] * 1000 <= now) | |
| 56 | return err("invalid_token"); | |
| 57 | if (expect.nonce !== undefined && claims["nonce"] !== expect.nonce) | |
| 58 | return err("invalid_token"); | |
| 59 | return ok(claims); | |
| 60 | } catch { | |
| 61 | return err("invalid_token"); | |
| 62 | } | |
| 63 | } |
src/web/pages/oauth.ts +30
| @@ -93,7 +93,29 @@ const identity = (request: AuthRequest): Html | "" => | ||
| 93 | 93 | </ul> |
| 94 | 94 | </fieldset>`; |
| 95 | 95 | |
| 96 | function signInConsent(request: AuthRequest, values: Params): Html { | |
| 97 | const host = new URL(request.redirectUri).host; | |
| 98 | return html`<div class="narrow"> | |
| 99 | ${pageHead(`Sign in to ${host}`)} | |
| 100 | ${card( | |
| 101 | html`<p class="mb"> | |
| 102 | Sign in to <strong>${host}</strong> as you. It learns only what is | |
| 103 | listed below, and gets no access to this forge. | |
| 104 | </p> | |
| 105 | <form method="post" action="/oauth/authorize"> | |
| 106 | ${PARAMS.map((name) => hidden(name, values[name]))} | |
| 107 | ${identity(request)} | |
| 108 | <div class="form-actions"> | |
| 109 | ${btn("Sign in", { variant: "primary", name: "decision", value: "allow" })} | |
| 110 | ${btn("Deny", { name: "decision", value: "deny" })} | |
| 111 | </div> | |
| 112 | </form>`, | |
| 113 | )} | |
| 114 | </div>`; | |
| 115 | } | |
| 116 | ||
| 96 | 117 | function consent(ctx: Ctx, request: AuthRequest, values: Params): Html { |
| 118 | if (request.signInOnly) return signInConsent(request, values); | |
| 97 | 119 | const host = new URL(request.redirectUri).host; |
| 98 | 120 | return html`<div class="narrow"> |
| 99 | 121 | ${pageHead(`Connect ${request.client.name}`)} |
| @@ -179,6 +201,14 @@ export async function authorizeSubmit(ctx: Ctx): Promise<Response> { | ||
| 179 | 201 | return guard( |
| 180 | 202 | redirect(redirectWith(redirectUri, { error: "access_denied", state })), |
| 181 | 203 | ); |
| 204 | if (request.value.signInOnly) { | |
| 205 | const code = issueCode(ctx.app.db, ctx.auth.user.id, request.value, { | |
| 206 | scope: "ro", | |
| 207 | name: request.value.client.name, | |
| 208 | lifetimeS: null, | |
| 209 | }); | |
| 210 | return guard(redirect(redirectWith(redirectUri, { code, state }))); | |
| 211 | } | |
| 182 | 212 | const grant = field(form, "grant"); |
| 183 | 213 | const lifetime = LIFETIMES[field(form, "lifetime")]; |
| 184 | 214 | if (!isOAuthScope(grant) || lifetime === undefined) |
src/web/pages/site.ts +14 −3
| @@ -46,6 +46,7 @@ import { | ||
| 46 | 46 | import { existsSync } from "node:fs"; |
| 47 | 47 | import { join } from "node:path"; |
| 48 | 48 | import { hasAdmin } from "../../spawn/claim.ts"; |
| 49 | import { parentIssuerOf } from "../../core/federation.ts"; | |
| 49 | 50 | |
| 50 | 51 | export function sessionCookie( |
| 51 | 52 | value: string, |
| @@ -369,6 +370,15 @@ function authForm( | ||
| 369 | 370 | <a class="small" href="/recover">Lost your device?</a> |
| 370 | 371 | </p>`, |
| 371 | 372 | )} |
| 373 | ${when( | |
outdated
Low: this calls |
||
| 374 | kind === "signin" ? parentIssuerOf(ctx.app.config) : undefined, | |
| 375 | (parent) => html`<p class="mt"> | |
| 376 | ${btn(`Continue with ${new URL(parent).host}`, { | |
| 377 | href: "/signin/parent", | |
| 378 | variant: "outline", | |
| 379 | })} | |
| 380 | </p>`, | |
| 381 | )} | |
| 372 | 382 | ${claimable |
| 373 | 383 | ? html`<p class="muted small mt" id="claim-door"> |
| 374 | 384 | Was this forge made for you? <a href="/claim">Claim it with your code</a>. |
| @@ -426,10 +436,11 @@ export function signinAllowed(ctx: Ctx, username: string): number | undefined { | ||
| 426 | 436 | adminPerMinute: limitValue(ctx.app.db, "signin.per_forge"), |
| 427 | 437 | enabled: true, |
| 428 | 438 | }); |
| 429 | const whole = limiter.take("forge", "admin"); | |
| 430 | if (!whole.allowed) return whole.retryAfter; | |
| 439 | // The one key first: a key already refused must not spend the forge's budget. | |
| 431 | 440 | const one = limiter.take(`account:${username.toLowerCase()}`, "ro"); |
| 432 | return one.allowed ? undefined : one.retryAfter; | |
| 441 | if (!one.allowed) return one.retryAfter; | |
| 442 | const whole = limiter.take("forge", "admin"); | |
| 443 | return whole.allowed ? undefined : whole.retryAfter; | |
| 433 | 444 | } |
| 434 | 445 | |
| 435 | 446 | export async function signinSubmit(ctx: Ctx): Promise<Response> { |
test/forge-signin.test.ts added +389
| @@ -0,0 +1,389 @@ | ||
| 1 | /** | |
| 2 | * Sign in with your forge (#748): two forges booted, a person signed into the | |
| 3 | * child through the parent's OpenID provider. The id_token's checks are tried | |
| 4 | * one spoiled claim at a time, and the parent's keys are fetched once and | |
| 5 | * again only for a key id they do not hold. | |
| 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 { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser, getUserByName, verifyPassword } from "../src/core/users.ts"; | |
| 14 | import { addKey, findKeyByFingerprint, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts"; | |
| 15 | import { KeySet } from "../src/http/jwks.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { verifyJwt } from "../src/kernel/jwt.ts"; | |
| 18 | import { signinAllowed } from "../src/web/pages/site.ts"; | |
| 19 | import type { Ctx } from "../src/http/context.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const PASSWORD = "a-long-family-passphrase"; | |
| 25 | ||
| 26 | describe("sign in through the parent forge", () => { | |
| 27 | let dirs: string[] = []; | |
| 28 | let parent: App; | |
| 29 | let child: App; | |
| 30 | let parentServers: Servers; | |
| 31 | let childServers: Servers; | |
| 32 | let alice = ""; | |
| 33 | let aliceCookie = ""; | |
| 34 | ||
| 35 | const cookieOf = (res: Response, name: string): string | undefined => | |
| 36 | res.headers | |
| 37 | .getSetCookie() | |
| 38 | .map((c) => c.split(";")[0]!) | |
| 39 | .find((c) => c.startsWith(`${name}=`)); | |
| 40 | ||
| 41 | /** Begin at the child, consent at the parent as the person with `cookie`; the child's callback address and its state cookie. */ | |
| 42 | const begin = async (cookie: string) => { | |
| 43 | const start = await fetch(`${childServers.url}/signin/parent`, { redirect: "manual" }); | |
| 44 | expect(start.status).toBe(303); | |
| 45 | const to = new URL(start.headers.get("location")!); | |
| 46 | expect(to.origin).toBe(parentServers.url); | |
| 47 | expect(to.pathname).toBe("/oauth/authorize"); | |
| 48 | const state = cookieOf(start, "forge_rp")!; | |
| 49 | expect(state).toBeDefined(); | |
| 50 | const consent = await fetch(`${parentServers.url}/oauth/authorize`, { | |
| 51 | method: "POST", | |
| 52 | redirect: "manual", | |
| 53 | headers: { cookie, origin: parentServers.url }, | |
| 54 | body: new URLSearchParams({ | |
| 55 | ...Object.fromEntries(to.searchParams), | |
| 56 | decision: "allow", | |
| 57 | }), | |
| 58 | }); | |
| 59 | expect(consent.status).toBe(303); | |
| 60 | return { callback: new URL(consent.headers.get("location")!), state }; | |
| 61 | }; | |
| 62 | const finish = (callback: URL, state: string | undefined) => | |
| 63 | fetch(`${childServers.url}${callback.pathname}${callback.search}`, { | |
| 64 | redirect: "manual", | |
| 65 | headers: state === undefined ? {} : { cookie: state }, | |
| 66 | }); | |
| 67 | ||
| 68 | beforeAll(async () => { | |
| 69 | const make = async () => { | |
| 70 | const dir = await mkdtemp(join(tmpdir(), "forge-signin-")); | |
| 71 | dirs.push(dir); | |
| 72 | return dir; | |
| 73 | }; | |
| 74 | parent = createApp({ dataDir: await make(), port: 0 }); | |
| 75 | parentServers = await serve(parent); | |
| 76 | child = createApp({ | |
| 77 | dataDir: await make(), | |
| 78 | port: 0, | |
| 79 | parentUrl: parentServers.url, | |
| 80 | parentLoopback: true, | |
| 81 | }); | |
| 82 | childServers = await serve(child); | |
| 83 | const made = await createUser(parent.db, { | |
| 84 | username: "alice", | |
| 85 | email: "[email protected]", | |
| 86 | password: PASSWORD, | |
| 87 | adminIfFirst: true, | |
| 88 | }); | |
| 89 | if (!made.ok) throw new Error(made.error); | |
| 90 | alice = made.value.id; | |
| 91 | aliceCookie = `forge_session=${createSession(parent.db, alice).token}`; | |
| 92 | }); | |
| 93 | afterAll(async () => { | |
| 94 | parentServers.stop(); | |
| 95 | childServers.stop(); | |
| 96 | for (const dir of dirs) await rm(dir, { recursive: true, force: true }); | |
| 97 | }); | |
| 98 | ||
| 99 | test("only a forge with a parent offers the button", async () => { | |
| 100 | const onChild = await (await fetch(`${childServers.url}/signin`)).text(); | |
| 101 | expect(onChild).toContain(`Continue with ${new URL(parentServers.url).host}`); | |
| 102 | expect(onChild).toContain('href="/signin/parent"'); | |
| 103 | expect(await (await fetch(`${parentServers.url}/signin`)).text()).not.toContain("Continue with"); | |
| 104 | const none = await fetch(`${parentServers.url}/signin/parent`, { redirect: "manual" }); | |
| 105 | expect(none.status).toBe(404); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a person signs into the child through the parent, and the account keys on sub", async () => { | |
| 109 | const { callback, state } = await begin(aliceCookie); | |
| 110 | expect(callback.origin).toBe(childServers.url); | |
| 111 | const done = await finish(callback, state); | |
| 112 | expect(done.status).toBe(303); | |
| 113 | expect(done.headers.get("location")).toBe("/"); | |
| 114 | const session = cookieOf(done, "forge_session")!; | |
| 115 | expect(session).toBeDefined(); | |
| 116 | expect(done.headers.getSetCookie().find((c) => c.startsWith("forge_session="))).toContain("HttpOnly"); | |
| 117 | ||
| 118 | const local = getUserByName(child.db, "alice")!; | |
| 119 | expect(local.id).not.toBe(""); | |
| 120 | // No password was ever set: only the parent vouches for this account. | |
| 121 | expect((await verifyPassword(child.db, "alice", "")).ok).toBe(false); | |
| 122 | expect((await verifyPassword(child.db, "alice", PASSWORD)).ok).toBe(false); | |
| 123 | const settings = await fetch(`${childServers.url}/settings`, { headers: { cookie: session }, redirect: "manual" }); | |
| 124 | expect(settings.status).toBe(200); | |
| 125 | ||
| 126 | // Renamed at the parent, the same person is the same local account. | |
| 127 | parent.db.query("UPDATE user SET username = 'alice-renamed' WHERE id = ?1").run(alice); | |
| 128 | const again = await begin(aliceCookie); | |
| 129 | const second = await finish(again.callback, again.state); | |
| 130 | expect(second.status).toBe(303); | |
| 131 | expect(getUserByName(child.db, "alice-renamed")).toBeUndefined(); | |
| 132 | expect(child.db.query("SELECT COUNT(*) AS n FROM user WHERE username LIKE 'alice%'").get()).toEqual({ n: 1 }); | |
| 133 | // Registered at the parent once, and the id kept. | |
| 134 | expect(parent.db.query("SELECT COUNT(*) AS n FROM oauth_client").get()).toEqual({ n: 1 }); | |
| 135 | expect(child.db.query("SELECT COUNT(*) AS n FROM rp_client").get()).toEqual({ n: 1 }); | |
| 136 | parent.db.query("UPDATE user SET username = 'alice' WHERE id = ?1").run(alice); | |
| 137 | }); | |
| 138 | ||
| 139 | test("signing in leaves no API credential at the parent, and the token answer carries none", async () => { | |
| 140 | const tokens = () => parent.db.query("SELECT COUNT(*) AS n FROM token WHERE user_id = ?1").get(alice); | |
| 141 | const before = tokens(); | |
| 142 | const page = await fetch( | |
| 143 | `${parentServers.url}/oauth/authorize?` + | |
| 144 | new URL((await fetch(`${childServers.url}/signin/parent`, { redirect: "manual" })).headers.get("location")!).searchParams, | |
| 145 | { headers: { cookie: aliceCookie } }, | |
| 146 | ); | |
| 147 | const text = await page.text(); | |
| 148 | expect(text).toContain("Sign in"); | |
| 149 | expect(text).not.toContain('name="lifetime"'); | |
| 150 | expect(text).not.toContain('name="grant"'); | |
| 151 | const { callback, state } = await begin(aliceCookie); | |
| 152 | expect((await finish(callback, state)).status).toBe(303); | |
| 153 | expect(tokens()).toEqual(before); | |
| 154 | ||
| 155 | // The exchange itself, done by hand: an id_token and nothing to call the API with. | |
| 156 | const client = parent.db | |
| 157 | .query<{ id: string; redirect_uris: string }, []>("SELECT id, redirect_uris FROM oauth_client") | |
| 158 | .get()!; | |
| 159 | const redirect = (JSON.parse(client.redirect_uris) as string[])[0]!; | |
| 160 | const verifier = "v".repeat(64); | |
| 161 | const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url"); | |
| 162 | const asked = await fetch(`${parentServers.url}/oauth/authorize`, { | |
| 163 | method: "POST", | |
| 164 | redirect: "manual", | |
| 165 | headers: { cookie: aliceCookie, origin: parentServers.url }, | |
| 166 | body: new URLSearchParams({ | |
| 167 | response_type: "code", | |
| 168 | client_id: client.id, | |
| 169 | redirect_uri: redirect, | |
| 170 | scope: "openid profile", | |
| 171 | state: "s", | |
| 172 | nonce: "n", | |
| 173 | code_challenge: challenge, | |
| 174 | code_challenge_method: "S256", | |
| 175 | decision: "allow", | |
| 176 | }), | |
| 177 | }); | |
| 178 | const code = new URL(asked.headers.get("location")!).searchParams.get("code")!; | |
| 179 | const answer = await fetch(`${parentServers.url}/oauth/token`, { | |
| 180 | method: "POST", | |
| 181 | body: new URLSearchParams({ | |
| 182 | grant_type: "authorization_code", | |
| 183 | code, | |
| 184 | client_id: client.id, | |
| 185 | redirect_uri: redirect, | |
| 186 | code_verifier: verifier, | |
| 187 | }), | |
| 188 | }); | |
| 189 | const body = (await answer.json()) as Record<string, unknown>; | |
| 190 | expect(answer.status).toBe(200); | |
| 191 | expect(typeof body.id_token).toBe("string"); | |
| 192 | expect(body.access_token).toBeUndefined(); | |
| 193 | expect(tokens()).toEqual(before); | |
| 194 | }); | |
| 195 | ||
| 196 | test("a parent's name that is taken here does not take over the local account", async () => { | |
| 197 | const bob = await createUser(parent.db, { username: "bob", email: "[email protected]", password: PASSWORD }); | |
| 198 | if (!bob.ok) throw new Error(bob.error); | |
| 199 | const local = await createUser(child.db, { username: "bob", email: "[email protected]", password: PASSWORD }); | |
| 200 | if (!local.ok) throw new Error(local.error); | |
| 201 | const { callback, state } = await begin(`forge_session=${createSession(parent.db, bob.value.id).token}`); | |
| 202 | const done = await finish(callback, state); | |
| 203 | expect(done.status).toBe(303); | |
| 204 | const row = child.db | |
| 205 | .query<{ user_id: string }, [string]>("SELECT user_id FROM federated_identity WHERE sub = ?1") | |
| 206 | .get(bob.value.id)!; | |
| 207 | expect(row.user_id).not.toBe(local.value.id); | |
| 208 | }); | |
| 209 | ||
| 210 | test("a callback is refused without its browser's cookie, with another state, or a second time", async () => { | |
| 211 | const { callback, state } = await begin(aliceCookie); | |
| 212 | expect((await finish(callback, undefined)).status).toBe(400); | |
| 213 | expect((await finish(callback, "forge_rp=other")).status).toBe(400); | |
| 214 | const sessions = () => child.db.query("SELECT COUNT(*) AS n FROM session").get(); | |
| 215 | const before = sessions(); | |
| 216 | expect((await finish(callback, state)).status).toBe(303); | |
| 217 | const replay = await finish(callback, state); | |
| 218 | expect(replay.status).toBe(400); | |
| 219 | expect(cookieOf(replay, "forge_session")).toBeUndefined(); | |
| 220 | expect(sessions()).toEqual({ n: (before as { n: number }).n + 1 }); | |
| 221 | }); | |
| 222 | ||
| 223 | test("a nonce that is not the one this browser sent is refused", async () => { | |
| 224 | const { callback, state } = await begin(aliceCookie); | |
| 225 | child.db.query("UPDATE rp_login SET nonce = 'not-the-nonce'").run(); | |
| 226 | const done = await finish(callback, state); | |
| 227 | expect(done.status).toBe(502); | |
| 228 | expect(cookieOf(done, "forge_session")).toBeUndefined(); | |
| 229 | }); | |
| 230 | ||
| 231 | test("an error from the parent, or no code, signs no one in", async () => { | |
| 232 | const { state } = await begin(aliceCookie); | |
| 233 | const value = state.slice("forge_rp=".length); | |
| 234 | const done = await finish(new URL(`${childServers.url}/signin/parent/callback?state=${value}&error=access_denied`), state); | |
| 235 | expect(done.status).toBe(400); | |
| 236 | expect(cookieOf(done, "forge_session")).toBeUndefined(); | |
| 237 | }); | |
| 238 | test("a person made here adds an SSH key, and the terminal's key door resolves it to that account", async () => { | |
| 239 | const local = getUserByName(child.db, "alice")!; | |
| 240 | const line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo alice"; | |
| 241 | const parsed = parseAuthorizedKey(line); | |
| 242 | if (!parsed.ok) throw new Error("key"); | |
| 243 | expect(addKey(child.db, local.id, "laptop", parsed.value).ok).toBe(true); | |
| 244 | expect(findKeyByFingerprint(child.db, fingerprint(parsed.value.blob))?.userId).toBe(local.id); | |
| 245 | }); | |
| 246 | ||
| 247 | test("starting a sign-in is throttled, so a flood cannot push out those in flight", async () => { | |
| 248 | const statuses: number[] = []; | |
| 249 | for (let i = 0; i < 30; i++) | |
| 250 | statuses.push((await fetch(`${childServers.url}/signin/parent`, { redirect: "manual" })).status); | |
| 251 | expect(statuses).toContain(429); | |
| 252 | const slow = await fetch(`${childServers.url}/signin/parent`, { redirect: "manual" }); | |
| 253 | expect(slow.status).toBe(429); | |
| 254 | expect(Number(slow.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 255 | }); | |
| 256 | }); | |
| 257 | ||
| 258 | describe("the sign-in throttle", () => { | |
| 259 | test("a key that is refused does not spend the forge-wide budget", async () => { | |
| 260 | const dir = await mkdtemp(join(tmpdir(), "forge-signin-")); | |
| 261 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 262 | const ctx = { app } as unknown as Ctx; | |
| 263 | for (let i = 0; i < 60; i++) signinAllowed(ctx, "parent:one"); | |
| 264 | // The forge's whole budget (200) is still there for 180 other keys. | |
| 265 | for (let i = 0; i < 180; i++) expect(signinAllowed(ctx, `k${i}`)).toBeUndefined(); | |
| 266 | await rm(dir, { recursive: true, force: true }); | |
| 267 | }); | |
| 268 | }); | |
| 269 | ||
| 270 | describe("verifyJwt", () => { | |
| 271 | const b64 = (v: unknown) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v)).toString("base64url"); | |
| 272 | let pair: CryptoKeyPair; | |
| 273 | let jwk: Record<string, unknown>; | |
| 274 | const now = 1_700_000_000_000; | |
| 275 | const expectation = { issuer: "https://p", audience: "c1", nonce: "n1" }; | |
| 276 | ||
| 277 | beforeAll(async () => { | |
| 278 | pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; | |
| 279 | jwk = { ...(await crypto.subtle.exportKey("jwk", pair.publicKey)), kid: "k1" }; | |
| 280 | }); | |
| 281 | const mint = async (claims: Record<string, unknown> = {}, head: Record<string, unknown> = {}) => { | |
| 282 | const h = b64({ alg: "ES256", kid: "k1", ...head }); | |
| 283 | const p = b64({ iss: "https://p", aud: "c1", nonce: "n1", sub: "s", exp: now / 1000 + 60, ...claims }); | |
| 284 | const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, new TextEncoder().encode(`${h}.${p}`)); | |
| 285 | return `${h}.${p}.${Buffer.from(sig).toString("base64url")}`; | |
| 286 | }; | |
| 287 | const keys = async (kid: string) => (kid === "k1" ? jwk : undefined); | |
| 288 | ||
| 289 | test("a good token passes", async () => { | |
| 290 | const out = await verifyJwt(await mint(), keys, expectation, now); | |
| 291 | expect(out.ok && out.value["sub"]).toBe("s"); | |
| 292 | }); | |
| 293 | ||
| 294 | test("each spoiled claim, key and signature is refused", async () => { | |
| 295 | const good = await mint(); | |
| 296 | const [h, p, s] = good.split("."); | |
| 297 | const forged = `${h}.${b64({ iss: "https://p", aud: "c1", nonce: "n1", sub: "root", exp: now / 1000 + 60 })}.${s}`; | |
| 298 | const cases: [string, string][] = [ | |
| 299 | ["wrong iss", await mint({ iss: "https://evil" })], | |
| 300 | ["wrong aud", await mint({ aud: "c2" })], | |
| 301 | ["wrong nonce", await mint({ nonce: "n2" })], | |
| 302 | ["no nonce", await mint({ nonce: undefined })], | |
| 303 | ["expired", await mint({ exp: now / 1000 - 1 })], | |
| 304 | ["no exp", await mint({ exp: undefined })], | |
| 305 | ["unknown kid", await mint({}, { kid: "k2" })], | |
| 306 | ["alg none", await mint({}, { alg: "none" })], | |
| 307 | ["alg HS256", await mint({}, { alg: "HS256" })], | |
| 308 | ["tampered payload", forged], | |
| 309 | ["two parts", `${h}.${p}`], | |
| 310 | ["not base64", "a.b.c!"], | |
| 311 | ]; | |
| 312 | for (const [name, jwt] of cases) { | |
| 313 | const out = await verifyJwt(jwt, keys, expectation, now); | |
| 314 | expect([name, out.ok]).toEqual([name, false]); | |
| 315 | } | |
| 316 | // A key that is not the signer's refuses a good token. | |
| 317 | const other = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; | |
| 318 | const otherJwk = { ...(await crypto.subtle.exportKey("jwk", other.publicKey)), kid: "k1" }; | |
| 319 | expect((await verifyJwt(good, async () => otherJwk, expectation, now)).ok).toBe(false); | |
| 320 | }); | |
| 321 | }); | |
| 322 | ||
| 323 | describe("a parent's keys, cached", () => { | |
| 324 | const set = (keys: string[]) => { | |
| 325 | const calls: string[] = []; | |
| 326 | const fetcher = async (url: string) => { | |
| 327 | calls.push(url); | |
| 328 | return Response.json({ keys: keys.map((kid) => ({ kid, kty: "EC" })) }); | |
| 329 | }; | |
| 330 | return { calls, fetcher }; | |
| 331 | }; | |
| 332 | ||
| 333 | test("fetched once, then served from memory", async () => { | |
| 334 | const { calls, fetcher } = set(["a", "b"]); | |
| 335 | const keys = new KeySet("https://p/oauth/jwks", fetcher); | |
| 336 | expect((await keys.find("a", 1000))?.["kid"]).toBe("a"); | |
| 337 | expect((await keys.find("b", 2000))?.["kid"]).toBe("b"); | |
| 338 | expect((await keys.find("a", 3000))?.["kid"]).toBe("a"); | |
| 339 | expect(calls).toHaveLength(1); | |
| 340 | }); | |
| 341 | ||
| 342 | test("an unknown kid looks again once, and not again within the minute", async () => { | |
| 343 | const live = set(["a"]); | |
| 344 | const keys = new KeySet("https://p/oauth/jwks", live.fetcher); | |
| 345 | await keys.find("a", 0); | |
| 346 | // The parent rotated: the next fetch has the new key. | |
| 347 | const rotated = set(["a", "b"]); | |
| 348 | (keys as unknown as { fetcher: unknown }).fetcher = rotated.fetcher; | |
| 349 | expect(await keys.find("b", 70_000)).toBeDefined(); | |
| 350 | expect(rotated.calls).toHaveLength(1); | |
| 351 | // A kid nobody has: one more look at most, then none for a minute. | |
| 352 | expect(await keys.find("zzz", 140_000)).toBeUndefined(); | |
| 353 | expect(await keys.find("zzz", 141_000)).toBeUndefined(); | |
| 354 | expect(await keys.find("yyy", 150_000)).toBeUndefined(); | |
| 355 | expect(rotated.calls).toHaveLength(2); | |
| 356 | }); | |
| 357 | ||
| 358 | test("an unreachable parent keeps the keys it had", async () => { | |
| 359 | const good = set(["a"]); | |
| 360 | const keys = new KeySet("https://p/oauth/jwks", good.fetcher); | |
| 361 | await keys.find("a", 0); | |
| 362 | (keys as unknown as { fetcher: unknown }).fetcher = async () => { | |
| 363 | throw new Error("down"); | |
| 364 | }; | |
| 365 | expect(await keys.find("nope", 100_000)).toBeUndefined(); | |
| 366 | expect(await keys.find("a", 100_001)).toBeDefined(); | |
| 367 | }); | |
| 368 | }); | |
| 369 | ||
| 370 | describe("a parent's oversized answer", () => { | |
| 371 | /** A body that never ends, counting how many chunks were pulled from it. */ | |
| 372 | const endless = () => { | |
| 373 | const pulled = { n: 0 }; | |
| 374 | const body = new ReadableStream<Uint8Array>({ | |
| 375 | pull(controller) { | |
| 376 | pulled.n++; | |
| 377 | controller.enqueue(new Uint8Array(1024).fill(32)); | |
| 378 | }, | |
| 379 | }); | |
| 380 | return { pulled, response: new Response(body) }; | |
| 381 | }; | |
| 382 | ||
| 383 | test("the key set is refused past its cap without reading on", async () => { | |
| 384 | const { pulled, response } = endless(); | |
| 385 | const keys = new KeySet("https://p/oauth/jwks", async () => response); | |
| 386 | expect(await keys.find("a", 0)).toBeUndefined(); | |
| 387 | expect(pulled.n).toBeLessThan(200); | |
| 388 | }); | |
| 389 | }); |
test/oauth-flow.test.ts +1 −1
| @@ -279,7 +279,7 @@ describe("oauth authorize and token", () => { | ||
| 279 | 279 | const b64 = (part: string) => JSON.parse(Buffer.from(part, "base64url").toString()); |
| 280 | 280 | |
| 281 | 281 | test("scope=openid with a nonce returns a verifiable id_token beside the access token, and userinfo answers", async () => { |
| 282 | const q = query({ scope: "openid profile email", nonce: "n-0S6_WzA2Mj" }); | |
| 282 | const q = query({ scope: "openid profile email ro", nonce: "n-0S6_WzA2Mj" }); | |
| 283 | 283 | const code = new URL((await consent(q)).headers.get("location")!).searchParams.get("code")!; |
| 284 | 284 | const res = await exchange({ code }); |
| 285 | 285 | expect(res.status).toBe(200); |
Low: this doc comment belongs to
LOGIN_TTL_MSbut sits aboveCALLBACK_PATH, which now carries two doc comments. Move it down.