Public: anyone can read this; the crew can change it.
The forge is an OpenID Connect provider: discovery, id_token, userinfo and rotating JWKS, landed from pull #1404 #1520
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1459-the-forge-is-an-openid-connect-provider → main 5 commits fork 6 threads · 6 unresolved
docs/APPS.md +11
| @@ -1122,3 +1122,14 @@ refused), `placement` (`"metal"` or `"cloud"`, optional) and `volumes` | ||
| 1122 | 1122 | sync cadence in seconds, 10–86400). `src/apps/service.ts` validates it; the |
| 1123 | 1123 | manifest carries it as `service`, `null` for a web app. Running it, exposing |
| 1124 | 1124 | the ports and syncing volumes are not built yet (issue #391). |
| 1125 | ||
| 1126 | ## The forge as an OpenID Connect provider | |
| 1127 | ||
| 1128 | Discovery is at `/.well-known/openid-configuration`. Register a client at | |
| 1129 | `/oauth/register`, send a person to `/oauth/authorize` with `scope=openid` | |
| 1130 | (optionally `profile email`), a `nonce`, and a PKCE S256 challenge. The | |
| 1131 | `/oauth/token` answer carries an `id_token` (ES256) beside the ordinary | |
| 1132 | ro/rw access token — the same grant, not a second credential. Present that | |
| 1133 | access token to `/oauth/userinfo` for the claims. `/oauth/jwks` publishes the | |
| 1134 | current signing key and the previous one; keys rotate every 30 days and live | |
| 1135 | in the database, never in the repository. |
scripts/gate.ts +1
| @@ -235,6 +235,7 @@ export const GATE: readonly string[] = [ | ||
| 235 | 235 | "test/home-feed.test.ts", |
| 236 | 236 | "test/oauth-discovery.test.ts", |
| 237 | 237 | "test/oauth-flow.test.ts", |
| 238 | "test/oidc.test.ts", | |
| 238 | 239 | "test/data-home.test.ts", |
| 239 | 240 | "test/tree.test.ts", |
| 240 | 241 | "test/reports.test.ts", |
src/core/oauth.ts +44 −6
| @@ -7,7 +7,9 @@ import type { Database } from "bun:sqlite"; | ||
| 7 | 7 | import { sha256 } from "../kernel/hash.ts"; |
| 8 | 8 | import { id, secret } from "../kernel/id.ts"; |
| 9 | 9 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 10 | import { NONCE_MAX, oidcScopes, recordGrant, signIdToken } from "./oidc.ts"; | |
| 10 | 11 | import { createToken } from "./tokens.ts"; |
| 12 | import { getUserById, isActive } from "./users.ts"; | |
| 11 | 13 | |
| 12 | 14 | export const NAME_MAX = 100; |
| 13 | 15 | export const REDIRECTS_MAX = 10; |
| @@ -158,6 +160,9 @@ export interface AuthRequest { | ||
| 158 | 160 | readonly challenge: string; |
| 159 | 161 | readonly state: string; |
| 160 | 162 | readonly scope: OAuthScope; |
| 163 | /** `openid` and the OIDC scopes asked with it; empty for a plain OAuth grant. */ | |
| 164 | readonly oidcScopes: readonly string[]; | |
| 165 | readonly nonce: string; | |
| 161 | 166 | } |
| 162 | 167 | |
| 163 | 168 | const CHALLENGE = /^[A-Za-z0-9_-]{43}$/; |
| @@ -189,7 +194,19 @@ export function parseAuthRequest( | ||
| 189 | 194 | const state = get("state"); |
| 190 | 195 | if (state.length > STATE_MAX) return err("The state is too long."); |
| 191 | 196 | const asked = get("scope").split(/\s+/).find(isOAuthScope); |
| 192 | return ok({ client, redirectUri, challenge, state, scope: asked ?? "ro" }); | |
| 197 | const oidc = oidcScopes(get("scope")); | |
| 198 | const openid = oidc.includes("openid"); | |
| 199 | const nonce = get("nonce"); | |
| 200 | if (nonce.length > NONCE_MAX) return err("The nonce is too long."); | |
| 201 | return ok({ | |
| 202 | client, | |
| 203 | redirectUri, | |
| 204 | challenge, | |
| 205 | state, | |
| 206 | scope: asked ?? "ro", | |
| 207 | oidcScopes: openid ? oidc : [], | |
| 208 | nonce: openid ? nonce : "", | |
| 209 | }); | |
| 193 | 210 | } |
| 194 | 211 | |
| 195 | 212 | /** The name a person gave a token: plain text, bounded, never empty. */ |
| @@ -219,8 +236,8 @@ export function issueCode( | ||
| 219 | 236 | ): string { |
| 220 | 237 | const code = secret(); |
| 221 | 238 | db.query( |
| 222 | `INSERT INTO oauth_code (code_hash, client_id, user_id, redirect_uri, challenge, scope, name, lifetime_s, expires_at) | |
| 223 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`, | |
| 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)`, | |
| 224 | 241 | ).run( |
| 225 | 242 | sha256(code), |
| 226 | 243 | request.client.id, |
| @@ -231,6 +248,8 @@ export function issueCode( | ||
| 231 | 248 | grant.name, |
| 232 | 249 | grant.lifetimeS, |
| 233 | 250 | new Date(Date.now() + CODE_TTL_MS).toISOString(), |
| 251 | request.oidcScopes.join(" "), | |
| 252 | request.nonce, | |
| 234 | 253 | ); |
| 235 | 254 | db.query("DELETE FROM oauth_code WHERE expires_at <= ?1").run( |
| 236 | 255 | new Date().toISOString(), |
| @@ -243,6 +262,8 @@ export interface Minted { | ||
| 243 | 262 | readonly scope: OAuthScope; |
| 244 | 263 | /** Seconds the token lives, or null for one that does not expire. */ |
| 245 | 264 | readonly expiresIn: number | null; |
| 265 | /** The signed id_token, when the grant was an OpenID one. */ | |
| 266 | readonly idToken?: string; | |
| 246 | 267 | } |
| 247 | 268 | |
| 248 | 269 | /** |
| @@ -250,15 +271,17 @@ export interface Minted { | ||
| 250 | 271 | * decided, so a replay, a wrong verifier or a wrong redirect all find it |
| 251 | 272 | * gone: one try per code. Every refusal is `invalid_grant`. |
| 252 | 273 | */ |
| 253 | export function redeemCode( | |
| 274 | export async function redeemCode( | |
| 254 | 275 | db: Database, |
| 276 | dataDir: string, | |
| 255 | 277 | input: { |
| 256 | 278 | code: string; |
| 257 | 279 | clientId: string; |
| 258 | 280 | redirectUri: string; |
| 259 | 281 | verifier: string; |
| 282 | issuer: string; | |
| 260 | 283 | }, |
| 261 | ): Result<Minted, "invalid_grant"> { | |
| 284 | ): Promise<Result<Minted, "invalid_grant">> { | |
| 262 | 285 | const row = db |
| 263 | 286 | .query< |
| 264 | 287 | { |
| @@ -270,6 +293,8 @@ export function redeemCode( | ||
| 270 | 293 | name: string; |
| 271 | 294 | lifetime_s: number | null; |
| 272 | 295 | expires_at: string; |
| 296 | oidc_scopes: string; | |
| 297 | nonce: string; | |
| 273 | 298 | }, |
| 274 | 299 | [string] |
| 275 | 300 | >("DELETE FROM oauth_code WHERE code_hash = ?1 RETURNING *") |
| @@ -283,10 +308,23 @@ export function redeemCode( | ||
| 283 | 308 | .update(input.verifier) |
| 284 | 309 | .digest("base64url"); |
| 285 | 310 | if (digest !== row.challenge) return err("invalid_grant"); |
| 311 | const user = getUserById(db, row.user_id); | |
| 312 | if (user === null || user === undefined || !isActive(user)) return err("invalid_grant"); | |
| 286 | 313 | const minted = createToken(db, row.user_id, row.name, row.scope, { |
| 287 | 314 | ...(row.lifetime_s === null |
| 288 | 315 | ? {} |
| 289 | 316 | : { expiresAt: new Date(Date.now() + row.lifetime_s * 1000).toISOString() }), |
| 290 | 317 | }); |
| 291 | return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s }); | |
| 318 | const scopes = row.oidc_scopes === "" ? [] : row.oidc_scopes.split(" "); | |
| 319 | if (scopes.length === 0) | |
| 320 | return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s }); | |
| 321 | recordGrant(db, minted.id, scopes); | |
| 322 | const idToken = await signIdToken(db, dataDir, { | |
| 323 | issuer: input.issuer, | |
| 324 | clientId: row.client_id, | |
| 325 | user, | |
| 326 | scopes, | |
| 327 | nonce: row.nonce, | |
| 328 | }); | |
| 329 | return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s, idToken }); | |
| 292 | 330 | } |
src/core/oidc.ts added +219
| @@ -0,0 +1,219 @@ | ||
| 1 | /** | |
| 2 | * OpenID Connect on top of the OAuth grant: the signing keys (ES256, kept in | |
| 3 | * the database, rotated), the id_token a code exchange returns beside the | |
| 4 | * access token, and the claims `/oauth/userinfo` answers with. There is no | |
| 5 | * second credential: the id_token is a statement about the same grant that | |
| 6 | * minted the ro/rw token. | |
| 7 | */ | |
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { sha256 } from "../kernel/hash.ts"; | |
| 10 | import { id } from "../kernel/id.ts"; | |
| 11 | import { open, seal } from "../kernel/secret.ts"; | |
| 12 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 13 | import { verifyToken } from "./tokens.ts"; | |
| 14 | import { getUserById, type User } from "./users.ts"; | |
| 15 | ||
| 16 | /** A key signs for this long, then a new one takes over. */ | |
| 17 | export const ROTATE_MS = 30 * 86_400_000; | |
| 18 | /** An id_token is good for an hour. */ | |
| 19 | export const ID_TOKEN_TTL_S = 3600; | |
| 20 | /** The scopes that mean OIDC; `openid` is the one that turns it on. */ | |
| 21 | export const OIDC_SCOPES = ["openid", "profile", "email"] as const; | |
| 22 | export type OidcScope = (typeof OIDC_SCOPES)[number]; | |
| 23 | export const NONCE_MAX = 256; | |
| 24 | ||
| 25 | const ALG = { name: "ECDSA", namedCurve: "P-256" } as const; | |
| 26 | const SIGN = { name: "ECDSA", hash: "SHA-256" } as const; | |
| 27 | ||
| 28 | const b64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); | |
| 29 | const b64json = (v: unknown): string => b64(new TextEncoder().encode(JSON.stringify(v))); | |
| 30 | ||
| 31 | interface KeyRow { | |
| 32 | kid: string; | |
| 33 | private_jwk: string; | |
| 34 | public_jwk: string; | |
| 35 | created_at: string; | |
| 36 | } | |
| 37 | ||
| 38 | /** The scopes of a request that are OIDC's, in a fixed order, deduplicated. */ | |
| 39 | export function oidcScopes(scope: string): OidcScope[] { | |
| 40 | const asked = new Set(scope.split(/\s+/)); | |
| 41 | return OIDC_SCOPES.filter((s) => asked.has(s)); | |
| 42 | } | |
| 43 | ||
| 44 | /** The private key is sealed with the data dir's key, bound to its kid. */ | |
| 45 | async function generate(db: Database, dataDir: string, now: number, onlyIfStale = false): Promise<KeyRow> { | |
| 46 | const pair = (await crypto.subtle.generateKey(ALG, true, ["sign", "verify"])) as CryptoKeyPair; | |
| 47 | const kid = id(); | |
outdated
High: the private signing key is written to the database in plaintext. Seal it with |
||
| 48 | const row: KeyRow = { | |
| 49 | kid, | |
| 50 | private_jwk: seal( | |
| 51 | dataDir, | |
| 52 | JSON.stringify(await crypto.subtle.exportKey("jwk", pair.privateKey)), | |
| 53 | kid, | |
| 54 | ), | |
| 55 | public_jwk: JSON.stringify(await crypto.subtle.exportKey("jwk", pair.publicKey)), | |
| 56 | created_at: new Date(now).toISOString(), | |
| 57 | }; | |
| 58 | // Check and insert in one transaction: of two requests racing past the | |
Low: there's no test for this guard. Two concurrent |
||
| 59 | // rotation mark, the second finds the first one's key and uses it. | |
| 60 | return db.transaction((): KeyRow => { | |
| 61 | if (onlyIfStale) { | |
| 62 | const current = newest(db); | |
| 63 | if (current !== null && now - Date.parse(current.created_at) < ROTATE_MS) | |
| 64 | return current; | |
| 65 | } | |
| 66 | db.query( | |
| 67 | "INSERT INTO oidc_key (kid, private_jwk, public_jwk, created_at) VALUES (?1, ?2, ?3, ?4)", | |
| 68 | ).run(row.kid, row.private_jwk, row.public_jwk, row.created_at); | |
| 69 | // Only the current key and the one before it are kept. | |
| 70 | db.query( | |
| 71 | "DELETE FROM oidc_key WHERE kid NOT IN (SELECT kid FROM oidc_key ORDER BY created_at DESC, rowid DESC LIMIT 2)", | |
| 72 | ).run(); | |
| 73 | return row; | |
| 74 | })(); | |
| 75 | } | |
| 76 | ||
| 77 | const newest = (db: Database): KeyRow | null => | |
| 78 | db | |
| 79 | .query<KeyRow, []>("SELECT * FROM oidc_key ORDER BY created_at DESC, rowid DESC LIMIT 1") | |
| 80 | .get(); | |
| 81 | ||
| 82 | /** The key that signs now; a new one is made when none exists or it is old. */ | |
| 83 | export async function signingKey(db: Database, dataDir: string, now = Date.now()): Promise<KeyRow> { | |
| 84 | const current = newest(db); | |
| 85 | if (current !== null && now - Date.parse(current.created_at) < ROTATE_MS) | |
| 86 | return current; | |
| 87 | return generate(db, dataDir, now, true); | |
| 88 | } | |
| 89 | ||
| 90 | /** Make a new current key now; the one it replaces stays published. */ | |
| 91 | export const rotateKeys = (db: Database, dataDir: string, now = Date.now()): Promise<KeyRow> => | |
| 92 | generate(db, dataDir, now); | |
| 93 | ||
| 94 | /** The JWK Set: the current public key and the previous one. */ | |
| 95 | export async function jwks(db: Database, dataDir: string): Promise<{ keys: Record<string, unknown>[] }> { | |
| 96 | await signingKey(db, dataDir); | |
| 97 | const rows = db | |
| 98 | .query<KeyRow, []>("SELECT * FROM oidc_key ORDER BY created_at DESC, rowid DESC LIMIT 2") | |
| 99 | .all(); | |
| 100 | return { | |
| 101 | keys: rows.map((r) => ({ | |
| 102 | ...(JSON.parse(r.public_jwk) as Record<string, unknown>), | |
| 103 | kid: r.kid, | |
| 104 | use: "sig", | |
| 105 | alg: "ES256", | |
| 106 | })), | |
| 107 | }; | |
| 108 | } | |
| 109 | ||
| 110 | /** Claims about a person for the scopes they consented to. */ | |
| 111 | export function claimsFor(user: User, scopes: readonly string[]): Record<string, unknown> { | |
| 112 | return { | |
| 113 | sub: user.id, | |
| 114 | ...(scopes.includes("profile") | |
| 115 | ? { | |
| 116 | preferred_username: user.username, | |
| 117 | name: user.displayName === "" ? user.username : user.displayName, | |
| 118 | } | |
| 119 | : {}), | |
| 120 | ...(scopes.includes("email") ? { email: user.email, email_verified: false } : {}), | |
| 121 | }; | |
| 122 | } | |
| 123 | ||
| 124 | export async function signIdToken( | |
| 125 | db: Database, | |
| 126 | dataDir: string, | |
| 127 | input: { | |
| 128 | issuer: string; | |
| 129 | clientId: string; | |
| 130 | user: User; | |
| 131 | scopes: readonly string[]; | |
| 132 | nonce: string; | |
| 133 | }, | |
| 134 | now = Date.now(), | |
| 135 | ): Promise<string> { | |
| 136 | const key = await signingKey(db, dataDir, now); | |
| 137 | const iat = Math.floor(now / 1000); | |
| 138 | const head = b64json({ alg: "ES256", typ: "JWT", kid: key.kid }); | |
| 139 | const body = b64json({ | |
| 140 | iss: input.issuer, | |
| 141 | aud: input.clientId, | |
| 142 | iat, | |
| 143 | exp: iat + ID_TOKEN_TTL_S, | |
| 144 | ...(input.nonce === "" ? {} : { nonce: input.nonce }), | |
| 145 | ...claimsFor(input.user, input.scopes), | |
| 146 | }); | |
| 147 | const opened = open(dataDir, key.private_jwk, key.kid); | |
| 148 | if (!opened.ok) throw new Error("oidc signing key cannot be opened"); | |
| 149 | const priv = await crypto.subtle.importKey( | |
| 150 | "jwk", | |
| 151 | JSON.parse(opened.value) as never, | |
| 152 | ALG, | |
| 153 | false, | |
| 154 | ["sign"], | |
| 155 | ); | |
| 156 | const sig = await crypto.subtle.sign(SIGN, priv, new TextEncoder().encode(`${head}.${body}`)); | |
| 157 | return `${head}.${body}.${b64(new Uint8Array(sig))}`; | |
| 158 | } | |
| 159 | ||
| 160 | /** Check a JWT against the published keys, its expiry and its issuer. */ | |
| 161 | export async function verifyIdToken( | |
| 162 | db: Database, | |
| 163 | dataDir: string, | |
| 164 | jwt: string, | |
| 165 | expect: { issuer: string; clientId: string }, | |
| 166 | now = Date.now(), | |
| 167 | ): Promise<Result<Record<string, unknown>, "invalid_token">> { | |
| 168 | const parts = jwt.split("."); | |
| 169 | if (parts.length !== 3) return err("invalid_token"); | |
| 170 | try { | |
| 171 | const head = JSON.parse(Buffer.from(parts[0]!, "base64url").toString()) as Record<string, unknown>; | |
| 172 | if (head["alg"] !== "ES256") return err("invalid_token"); | |
| 173 | const jwk = (await jwks(db, dataDir)).keys.find((k) => k["kid"] === head["kid"]); | |
| 174 | if (jwk === undefined) return err("invalid_token"); | |
| 175 | const pub = await crypto.subtle.importKey("jwk", jwk as never, ALG, false, ["verify"]); | |
| 176 | const good = await crypto.subtle.verify( | |
| 177 | SIGN, | |
| 178 | pub, | |
| 179 | Buffer.from(parts[2]!, "base64url"), | |
| 180 | new TextEncoder().encode(`${parts[0]}.${parts[1]}`), | |
| 181 | ); | |
| 182 | if (!good) return err("invalid_token"); | |
| 183 | const claims = JSON.parse(Buffer.from(parts[1]!, "base64url").toString()) as Record<string, unknown>; | |
| 184 | if (claims["iss"] !== expect.issuer || claims["aud"] !== expect.clientId) | |
| 185 | return err("invalid_token"); | |
| 186 | if (typeof claims["exp"] !== "number" || claims["exp"] * 1000 <= now) | |
| 187 | return err("invalid_token"); | |
| 188 | return ok(claims); | |
| 189 | } catch { | |
| 190 | return err("invalid_token"); | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | /** Remember that an access token came from an OpenID grant, and for which scopes. */ | |
| 195 | export function recordGrant(db: Database, tokenId: string, scopes: readonly string[]): void { | |
| 196 | db.query("INSERT INTO oidc_token (token_id, scopes) VALUES (?1, ?2)").run( | |
| 197 | tokenId, | |
| 198 | scopes.join(" "), | |
| 199 | ); | |
| 200 | } | |
| 201 | ||
| 202 | /** The claims for a bearer token, if it came from an OpenID grant. */ | |
| 203 | export function userinfo( | |
| 204 | db: Database, | |
| 205 | presented: string, | |
| 206 | ): Result<Record<string, unknown>, "invalid_token"> { | |
| 207 | const verified = verifyToken(db, presented); | |
| 208 | if (!verified.ok) return err("invalid_token"); | |
| 209 | const row = db | |
| 210 | .query<{ scopes: string; user_id: string }, [string]>( | |
| 211 | `SELECT o.scopes, t.user_id FROM oidc_token o JOIN token t ON t.id = o.token_id | |
| 212 | WHERE t.token_hash = ?1`, | |
| 213 | ) | |
| 214 | .get(sha256(presented)); | |
| 215 | if (row === null) return err("invalid_token"); | |
| 216 | const user = getUserById(db, row.user_id); | |
| 217 | if (user === null || user === undefined) return err("invalid_token"); | |
Nit: |
||
| 218 | return ok(claimsFor(user, row.scopes.split(" "))); | |
| 219 | } |
src/core/schema.ts +17
| @@ -1614,6 +1614,23 @@ export const migrations: readonly string[] = [ | ||
| 1614 | 1614 | PRIMARY KEY (key, issue_id) |
| 1615 | 1615 | ); |
| 1616 | 1616 | `, |
| 1617 | // 77 — OpenID Connect over the same grant (#1459): the nonce and OIDC | |
| 1618 | // scopes a consent carried, the signing keys (current and previous), and | |
| 1619 | // which access tokens came from an OpenID grant, for /oauth/userinfo. | |
| 1620 | ` | |
| 1621 | ALTER TABLE oauth_code ADD COLUMN oidc_scopes TEXT NOT NULL DEFAULT ''; | |
| 1622 | ALTER TABLE oauth_code ADD COLUMN nonce TEXT NOT NULL DEFAULT ''; | |
| 1623 | CREATE TABLE oidc_key ( | |
| 1624 | kid TEXT PRIMARY KEY, | |
| 1625 | private_jwk TEXT NOT NULL, | |
| 1626 | public_jwk TEXT NOT NULL, | |
| 1627 | created_at TEXT NOT NULL | |
| 1628 | ); | |
| 1629 | CREATE TABLE oidc_token ( | |
| 1630 | token_id TEXT PRIMARY KEY REFERENCES token(id) ON DELETE CASCADE, | |
| 1631 | scopes TEXT NOT NULL | |
| 1632 | ); | |
| 1633 | `, | |
| 1617 | 1634 | ]; |
| 1618 | 1635 | |
| 1619 | 1636 | /** |
src/http/oauth.ts +42 −1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | 12 | import { createClient, parseRegistration, redeemCode } from "../core/oauth.ts"; |
| 13 | import { jwks, userinfo } from "../core/oidc.ts"; | |
| 13 | 14 | import { RateLimiter } from "../core/ratelimit.ts"; |
| 14 | 15 | import type { Handler } from "./context.ts"; |
| 15 | 16 | import { clientBucket } from "./peer.ts"; |
| @@ -71,11 +72,12 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 71 | 72 | if (get === null) return grantError("invalid_request", 413); |
| 72 | 73 | if (get("grant_type") !== "authorization_code") |
| 73 | 74 | return grantError("unsupported_grant_type"); |
| 74 | const minted = redeemCode(ctx.app.db, { | |
| 75 | const minted = await redeemCode(ctx.app.db, ctx.app.config.dataDir, { | |
| 75 | 76 | code: get("code"), |
| 76 | 77 | clientId: get("client_id"), |
| 77 | 78 | redirectUri: get("redirect_uri"), |
| 78 | 79 | verifier: get("code_verifier"), |
| 80 | issuer: ctx.url.origin, | |
| 79 | 81 | }); |
| 80 | 82 | if (!minted.ok) return grantError(minted.error); |
| 81 | 83 | return json( |
| @@ -83,6 +85,7 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 83 | 85 | access_token: minted.value.token, |
| 84 | 86 | token_type: "Bearer", |
| 85 | 87 | scope: minted.value.scope, |
| 88 | ...(minted.value.idToken === undefined ? {} : { id_token: minted.value.idToken }), | |
| 86 | 89 | ...(minted.value.expiresIn === null |
| 87 | 90 | ? {} |
| 88 | 91 | : { expires_in: minted.value.expiresIn }), |
| @@ -91,6 +94,44 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 91 | 94 | noStore, |
| 92 | 95 | ); |
| 93 | 96 | }); |
| 97 | router.add("GET", "/oauth/jwks", async (ctx) => | |
| 98 | json(await jwks(ctx.app.db, ctx.app.config.dataDir), 200, docHeaders), | |
| 99 | ); | |
| 100 | const userinfoRoute: Handler = (ctx) => { | |
| 101 | const header = ctx.request.headers.get("authorization") ?? ""; | |
| 102 | const presented = /^Bearer (\S+)$/i.exec(header)?.[1]; | |
| 103 | const claims = presented === undefined ? null : userinfo(ctx.app.db, presented); | |
| 104 | if (claims === null || !claims.ok) | |
| 105 | return json({ error: "invalid_token" }, 401, { | |
| 106 | ...noStore, | |
| 107 | "www-authenticate": 'Bearer error="invalid_token"', | |
| 108 | }); | |
| 109 | return json(claims.value, 200, noStore); | |
| 110 | }; | |
| 111 | router.add("GET", "/oauth/userinfo", userinfoRoute); | |
| 112 | router.add("POST", "/oauth/userinfo", userinfoRoute); | |
| 113 | router.add("GET", "/.well-known/openid-configuration", (ctx) => | |
| 114 | json( | |
| 115 | { | |
| 116 | issuer: ctx.url.origin, | |
| 117 | authorization_endpoint: `${ctx.url.origin}/oauth/authorize`, | |
| 118 | token_endpoint: `${ctx.url.origin}/oauth/token`, | |
| 119 | userinfo_endpoint: `${ctx.url.origin}/oauth/userinfo`, | |
| 120 | jwks_uri: `${ctx.url.origin}/oauth/jwks`, | |
| 121 | registration_endpoint: `${ctx.url.origin}/oauth/register`, | |
| 122 | scopes_supported: ["openid", "profile", "email", "ro", "rw", "admin"], | |
| 123 | response_types_supported: ["code"], | |
| 124 | grant_types_supported: ["authorization_code"], | |
| 125 | subject_types_supported: ["public"], | |
| 126 | id_token_signing_alg_values_supported: ["ES256"], | |
| 127 | code_challenge_methods_supported: ["S256"], | |
| 128 | token_endpoint_auth_methods_supported: ["none"], | |
| 129 | claims_supported: ["sub", "iss", "aud", "exp", "iat", "nonce", "name", "preferred_username", "email", "email_verified"], | |
| 130 | }, | |
| 131 | 200, | |
| 132 | docHeaders, | |
| 133 | ), | |
| 134 | ); | |
| 94 | 135 | router.add("GET", "/.well-known/oauth-protected-resource", (ctx) => |
| 95 | 136 | json( |
| 96 | 137 | { |
src/web/pages/oauth.ts +19
| @@ -29,6 +29,7 @@ const PARAMS = [ | ||
| 29 | 29 | "code_challenge_method", |
| 30 | 30 | "state", |
| 31 | 31 | "scope", |
| 32 | "nonce", | |
The nonce is carried through, but the consent form never tells the person that |
||
| 32 | 33 | ] as const; |
| 33 | 34 | |
| 34 | 35 | /** Nothing here is cacheable, and nothing here may be sent on as a referrer. */ |
| @@ -75,6 +76,23 @@ const refused = (ctx: Ctx, message: string): Response => | ||
| 75 | 76 | const hidden = (name: string, value: string): Html => |
| 76 | 77 | html`<input type="hidden" name="${name}" value="${value}" />`; |
| 77 | 78 | |
| 79 | /** What an OpenID grant tells the app about the person, in plain words. */ | |
| 80 | const IDENTITY: Record<string, string> = { | |
| 81 | openid: "who you are on this forge (an identifier that stays the same)", | |
| 82 | profile: "your username and name", | |
| 83 | email: "your email address", | |
| 84 | }; | |
| 85 | ||
| 86 | const identity = (request: AuthRequest): Html | "" => | |
| 87 | request.oidcScopes.length === 0 | |
| 88 | ? "" | |
| 89 | : html`<fieldset class="field"> | |
| 90 | <legend>What it will learn about you</legend> | |
| 91 | <ul> | |
| 92 | ${request.oidcScopes.map((s) => html`<li>${IDENTITY[s] ?? s}</li>`)} | |
| 93 | </ul> | |
| 94 | </fieldset>`; | |
| 95 | ||
| 78 | 96 | function consent(ctx: Ctx, request: AuthRequest, values: Params): Html { |
| 79 | 97 | const host = new URL(request.redirectUri).host; |
| 80 | 98 | return html`<div class="narrow"> |
| @@ -87,6 +105,7 @@ function consent(ctx: Ctx, request: AuthRequest, values: Params): Html { | ||
| 87 | 105 | </p> |
| 88 | 106 | <form method="post" action="/oauth/authorize"> |
| 89 | 107 | ${PARAMS.map((name) => hidden(name, values[name]))} |
| 108 | ${identity(request)} | |
| 90 | 109 | <fieldset class="field"> |
| 91 | 110 | <legend>What it may do</legend> |
| 92 | 111 | <label |
test/oauth-discovery.test.ts +21
| @@ -133,4 +133,25 @@ describe("oauth discovery and registration", () => { | ||
| 133 | 133 | expect(() => app.db.exec(ddl)).not.toThrow(); |
| 134 | 134 | expect(() => app.db.exec(ddl)).not.toThrow(); |
| 135 | 135 | }); |
| 136 | ||
| 137 | test("the OpenID configuration lists the issuer, endpoints, scopes and algorithm", async () => { | |
| 138 | const res = await fetch(`${base}/.well-known/openid-configuration`); | |
| 139 | expect(res.status).toBe(200); | |
| 140 | const doc = (await res.json()) as any; | |
| 141 | expect(doc.issuer).toBe(base); | |
| 142 | expect(doc.authorization_endpoint).toBe(`${base}/oauth/authorize`); | |
| 143 | expect(doc.token_endpoint).toBe(`${base}/oauth/token`); | |
| 144 | expect(doc.userinfo_endpoint).toBe(`${base}/oauth/userinfo`); | |
| 145 | expect(doc.jwks_uri).toBe(`${base}/oauth/jwks`); | |
| 146 | for (const scope of ["openid", "profile", "email", "ro", "rw", "admin"]) | |
| 147 | expect(doc.scopes_supported).toContain(scope); | |
| 148 | expect(doc.id_token_signing_alg_values_supported).toEqual(["ES256"]); | |
| 149 | }); | |
| 150 | ||
| 151 | test("the JWKS publishes a public ES256 key and no private part", async () => { | |
| 152 | const doc = (await (await fetch(`${base}/oauth/jwks`)).json()) as any; | |
| 153 | expect(doc.keys.length).toBe(1); | |
| 154 | expect(doc.keys[0]).toMatchObject({ kty: "EC", crv: "P-256", alg: "ES256", use: "sig" }); | |
| 155 | expect(doc.keys[0].d).toBeUndefined(); | |
| 156 | }); | |
| 136 | 157 | }); |
test/oauth-flow.test.ts +76
| @@ -12,6 +12,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 12 | import { listTokens } from "../src/core/tokens.ts"; |
| 13 | 13 | import { createSession } from "../src/core/sessions.ts"; |
| 14 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | import { verifyIdToken } from "../src/core/oidc.ts"; | |
| 15 | 16 | import { sha256 } from "../src/kernel/hash.ts"; |
| 16 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | 18 | |
| @@ -274,4 +275,79 @@ describe("oauth authorize and token", () => { | ||
| 274 | 275 | expect(to.searchParams.get("state")).toBe("st-1"); |
| 275 | 276 | expect(to.searchParams.get("code")).toBeNull(); |
| 276 | 277 | }); |
| 278 | ||
| 279 | const b64 = (part: string) => JSON.parse(Buffer.from(part, "base64url").toString()); | |
| 280 | ||
| 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" }); | |
| 283 | const code = new URL((await consent(q)).headers.get("location")!).searchParams.get("code")!; | |
| 284 | const res = await exchange({ code }); | |
| 285 | expect(res.status).toBe(200); | |
| 286 | const out = (await res.json()) as any; | |
| 287 | expect(out.access_token.startsWith("forge_")).toBe(true); | |
| 288 | const claims = b64(out.id_token.split(".")[1]); | |
| 289 | expect(claims.iss).toBe(base); | |
| 290 | expect(claims.aud).toBe(clientId); | |
| 291 | expect(claims.sub).toBe(alice.id); | |
| 292 | expect(claims.nonce).toBe("n-0S6_WzA2Mj"); | |
| 293 | expect(claims.preferred_username).toBe("alice"); | |
| 294 | expect(claims.email).toBe("[email protected]"); | |
| 295 | const verified = await verifyIdToken(app.db, dataDir, out.id_token, { issuer: base, clientId }); | |
| 296 | expect(verified.ok).toBe(true); | |
| 297 | // The same grant: the id_token is not a second credential. | |
| 298 | const info = await fetch(`${base}/oauth/userinfo`, { | |
| 299 | headers: { authorization: `Bearer ${out.access_token}` }, | |
| 300 | }); | |
| 301 | expect(info.status).toBe(200); | |
| 302 | expect(await info.json()).toMatchObject({ sub: alice.id, email: "[email protected]" }); | |
| 303 | expect((await fetch(`${base}/oauth/userinfo`, { headers: { authorization: `Bearer ${out.id_token}` } })).status).toBe(401); | |
| 304 | expect((await fetch(`${base}/oauth/userinfo`)).status).toBe(401); | |
| 305 | }); | |
| 306 | ||
| 307 | test("consent lists the identity an openid grant shares, and a plain grant lists none", async () => { | |
| 308 | const open = await (await get(query({ scope: "openid email" }))).text(); | |
| 309 | expect(open).toContain("What it will learn about you"); | |
| 310 | expect(open).toContain("your email address"); | |
| 311 | expect(open).not.toContain("your username and name"); | |
| 312 | const profile = await (await get(query({ scope: "openid profile email" }))).text(); | |
| 313 | expect(profile).toContain("your username and name"); | |
| 314 | const plain = await (await get(query())).text(); | |
| 315 | expect(plain).not.toContain("What it will learn about you"); | |
| 316 | expect(plain).not.toContain("your email address"); | |
| 317 | }); | |
| 318 | ||
| 319 | test("a plain grant has no id_token and its token is refused by userinfo", async () => { | |
| 320 | const out = (await (await exchange({ code: await codeFrom() })).json()) as any; | |
| 321 | expect(out.id_token).toBeUndefined(); | |
| 322 | const info = await fetch(`${base}/oauth/userinfo`, { | |
| 323 | headers: { authorization: `Bearer ${out.access_token}` }, | |
| 324 | }); | |
| 325 | expect(info.status).toBe(401); | |
| 326 | }); | |
| 327 | ||
| 328 | test("a person suspended between consent and exchange gets no token and no id_token", async () => { | |
| 329 | const tokensBefore = listTokens(app.db, alice.id).length; | |
| 330 | for (const q of [query({ scope: "openid", nonce: "n-0S6_WzA2Mj" }), query()]) { | |
| 331 | const code = new URL((await consent(q)).headers.get("location")!).searchParams.get("code")!; | |
| 332 | const tokensConsented = listTokens(app.db, alice.id).length; | |
| 333 | app.db.query("UPDATE user SET suspended_at = ?1 WHERE id = ?2").run(new Date().toISOString(), alice.id); | |
| 334 | try { | |
| 335 | const res = await exchange({ code }); | |
| 336 | expect(res.status).toBe(400); | |
| 337 | const out = (await res.json()) as any; | |
| 338 | expect(out.error).toBe("invalid_grant"); | |
| 339 | expect(out.id_token).toBeUndefined(); | |
| 340 | expect(out.access_token).toBeUndefined(); | |
| 341 | expect(listTokens(app.db, alice.id).length).toBe(tokensConsented); | |
| 342 | } finally { | |
| 343 | app.db.query("UPDATE user SET suspended_at = NULL WHERE id = ?1").run(alice.id); | |
| 344 | } | |
| 345 | } | |
| 346 | expect(listTokens(app.db, alice.id).length).toBe(tokensBefore); | |
| 347 | }); | |
| 348 | ||
| 349 | test("an overlong nonce is refused",async () => { | |
| 350 | const res = await get(query({ scope: "openid", nonce: "n".repeat(300) })); | |
| 351 | expect(res.status).toBe(400); | |
| 352 | }); | |
| 277 | 353 | }); |
test/oidc.test.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * OpenID Connect keys: a token signed before a rotation still verifies | |
| 3 | * after it, the JWKS holds the current key and the previous one, and a | |
| 4 | * token that is expired, forged or for another audience does not verify. | |
| 5 | */ | |
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { migrations } from "../src/core/schema.ts"; | |
| 11 | import { openDb } from "../src/kernel/db.ts"; | |
| 12 | import { jwks, rotateKeys, signIdToken, verifyIdToken, ID_TOKEN_TTL_S, ROTATE_MS } from "../src/core/oidc.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | ||
| 15 | const setup = async () => { | |
| 16 | const dir = mkdtempSync(join(tmpdir(), "forge-oidc-")); | |
| 17 | const db = openDb(":memory:", migrations); | |
| 18 | const made = await createUser(db, { | |
| 19 | trusted: true, | |
| 20 | username: "bob", | |
| 21 | email: "[email protected]", | |
| 22 | password: "a-good-passphrase", | |
| 23 | adminIfFirst: true, | |
| 24 | }); | |
| 25 | if (!made.ok) throw new Error(made.error); | |
| 26 | return { db, dir, user: made.value }; | |
| 27 | }; | |
| 28 | const expectFor = { issuer: "https://forge.test", clientId: "c1" }; | |
| 29 | ||
| 30 | describe("oidc keys", () => { | |
| 31 | test("a token signed with the previous key verifies until it expires", async () => { | |
| 32 | const { db, dir, user } = await setup(); | |
| 33 | const t0 = Date.now(); | |
| 34 | const old = await signIdToken(db, dir, { ...expectFor, user, scopes: ["openid"], nonce: "n" }, t0); | |
| 35 | const [first] = (await jwks(db, dir)).keys; | |
| 36 | await rotateKeys(db, dir, t0 + 1000); | |
| 37 | const keys = (await jwks(db, dir)).keys; | |
| 38 | expect(keys.length).toBe(2); | |
| 39 | expect(keys.map((k) => k["kid"])).toContain(first!["kid"]); | |
| 40 | expect((await verifyIdToken(db, dir, old, expectFor, t0 + 2000)).ok).toBe(true); | |
| 41 | expect((await verifyIdToken(db, dir, old, expectFor, t0 + (ID_TOKEN_TTL_S + 1) * 1000)).ok).toBe(false); | |
| 42 | // A token from the new key carries the new kid. | |
| 43 | const fresh = await signIdToken(db, dir, { ...expectFor, user, scopes: ["openid"], nonce: "" }, t0 + 3000); | |
| 44 | expect(JSON.parse(Buffer.from(fresh.split(".")[0]!, "base64url").toString()).kid).toBe(keys[0]!["kid"]); | |
| 45 | }); | |
| 46 | ||
| 47 | test("only the current and previous keys are kept; an old key rotates itself", async () => { | |
| 48 | const { db, dir, user } = await setup(); | |
| 49 | const t0 = Date.now(); | |
| 50 | await signIdToken(db, dir, { ...expectFor, user, scopes: ["openid"], nonce: "" }, t0); | |
| 51 | await rotateKeys(db, dir, t0 + 1); | |
| 52 | await rotateKeys(db, dir, t0 + 2); | |
| 53 | expect((await jwks(db, dir)).keys.length).toBe(2); | |
| 54 | const before = (await jwks(db, dir)).keys[0]!["kid"]; | |
| 55 | await signIdToken(db, dir, { ...expectFor, user, scopes: ["openid"], nonce: "" }, t0 + ROTATE_MS + 10); | |
| 56 | expect((await jwks(db, dir)).keys[0]!["kid"]).not.toBe(before); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a forged, mis-addressed or malformed token is refused", async () => { | |
| 60 | const { db, dir, user } = await setup(); | |
| 61 | const jwt = await signIdToken(db, dir, { ...expectFor, user, scopes: ["openid"], nonce: "" }); | |
| 62 | const [h, p, s] = jwt.split("."); | |
| 63 | const tampered = `${h}.${Buffer.from(JSON.stringify({ sub: "root" })).toString("base64url")}.${s}`; | |
| 64 | expect((await verifyIdToken(db, dir, tampered, expectFor)).ok).toBe(false); | |
| 65 | expect((await verifyIdToken(db, dir, jwt, { ...expectFor, clientId: "other" })).ok).toBe(false); | |
| 66 | expect((await verifyIdToken(db, dir, jwt, { ...expectFor, issuer: "https://evil" })).ok).toBe(false); | |
| 67 | expect((await verifyIdToken(db, dir, `${h}.${p}`, expectFor)).ok).toBe(false); | |
| 68 | expect((await verifyIdToken(db, dir, "a.b.c", expectFor)).ok).toBe(false); | |
| 69 | }); | |
| 70 | ||
| 71 | test("the private key is sealed in the database, never a JWK", async () => { | |
| 72 | const { db, dir, user } = await setup(); | |
| 73 | const jwt = await signIdToken(db, dir, { ...expectFor, user, scopes: ["openid"], nonce: "" }); | |
| 74 | const rows = db.query<{ private_jwk: string }, []>("SELECT private_jwk FROM oidc_key").all(); | |
| 75 | expect(rows.length).toBe(1); | |
| 76 | for (const r of rows) { | |
| 77 | expect(r.private_jwk.startsWith("v1:")).toBe(true); | |
| 78 | expect(r.private_jwk).not.toContain('"d"'); | |
| 79 | expect(() => JSON.parse(r.private_jwk)).toThrow(); | |
| 80 | } | |
| 81 | expect((await verifyIdToken(db, dir, jwt, expectFor)).ok).toBe(true); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a sealed key lifted onto another kid does not sign", async () => { | |
| 85 | const { db, dir, user } = await setup(); | |
| 86 | await signIdToken(db, dir, { ...expectFor, user, scopes: ["openid"], nonce: "" }); | |
| 87 | db.query("UPDATE oidc_key SET kid = 'other'").run(); | |
| 88 | await expect( | |
outdated
This needs |
||
| 89 | signIdToken(db, dir, { ...expectFor, user, scopes: ["openid"], nonce: "" }), | |
| 90 | ).rejects.toThrow(); | |
| 91 | }); | |
| 92 | }); |
A suspended account still gets a signed id_token here, and the access token is minted above before this check runs. Check
isActive(user)beforecreateTokenand returninvalid_grant. Add a test that suspends the user between consent and exchange.