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

Merged opened this

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

Merged

scout/forge:scout/1459-the-forge-is-an-openid-connect-provider → main 5 commits fork 6 threads · 6 unresolved

  • Merged. scout/1459-the-forge-is-an-openid-connect-provider was merged into main as 17b8f9d.

Review

Sign in to review this change.
10 files changed +542 −7
docs/APPS.md +11
@@ -1122,3 +1122,14 @@ refused), `placement` (`"metal"` or `"cloud"`, optional) and `volumes`
11221122 sync cadence in seconds, 10–86400). `src/apps/service.ts` validates it; the
11231123 manifest carries it as `service`, `null` for a web app. Running it, exposing
11241124 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[] = [
235235 "test/home-feed.test.ts",
236236 "test/oauth-discovery.test.ts",
237237 "test/oauth-flow.test.ts",
238 "test/oidc.test.ts",
238239 "test/data-home.test.ts",
239240 "test/tree.test.ts",
240241 "test/reports.test.ts",
src/core/oauth.ts +44 −6
@@ -7,7 +7,9 @@ import type { Database } from "bun:sqlite";
77 import { sha256 } from "../kernel/hash.ts";
88 import { id, secret } from "../kernel/id.ts";
99 import { err, ok, type Result } from "../kernel/result.ts";
10 import { NONCE_MAX, oidcScopes, recordGrant, signIdToken } from "./oidc.ts";
1011 import { createToken } from "./tokens.ts";
12 import { getUserById, isActive } from "./users.ts";
1113
1214 export const NAME_MAX = 100;
1315 export const REDIRECTS_MAX = 10;
@@ -158,6 +160,9 @@ export interface AuthRequest {
158160 readonly challenge: string;
159161 readonly state: string;
160162 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;
161166 }
162167
163168 const CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
@@ -189,7 +194,19 @@ export function parseAuthRequest(
189194 const state = get("state");
190195 if (state.length > STATE_MAX) return err("The state is too long.");
191196 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 });
193210 }
194211
195212 /** The name a person gave a token: plain text, bounded, never empty. */
@@ -219,8 +236,8 @@ export function issueCode(
219236 ): string {
220237 const code = secret();
221238 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)`,
224241 ).run(
225242 sha256(code),
226243 request.client.id,
@@ -231,6 +248,8 @@ export function issueCode(
231248 grant.name,
232249 grant.lifetimeS,
233250 new Date(Date.now() + CODE_TTL_MS).toISOString(),
251 request.oidcScopes.join(" "),
252 request.nonce,
234253 );
235254 db.query("DELETE FROM oauth_code WHERE expires_at <= ?1").run(
236255 new Date().toISOString(),
@@ -243,6 +262,8 @@ export interface Minted {
243262 readonly scope: OAuthScope;
244263 /** Seconds the token lives, or null for one that does not expire. */
245264 readonly expiresIn: number | null;
265 /** The signed id_token, when the grant was an OpenID one. */
266 readonly idToken?: string;
246267 }
247268
248269 /**
@@ -250,15 +271,17 @@ export interface Minted {
250271 * decided, so a replay, a wrong verifier or a wrong redirect all find it
251272 * gone: one try per code. Every refusal is `invalid_grant`.
252273 */
253 export function redeemCode(
274 export async function redeemCode(
254275 db: Database,
276 dataDir: string,
255277 input: {
256278 code: string;
257279 clientId: string;
258280 redirectUri: string;
259281 verifier: string;
282 issuer: string;
260283 },
261 ): Result<Minted, "invalid_grant"> {
284 ): Promise<Result<Minted, "invalid_grant">> {
262285 const row = db
263286 .query<
264287 {
@@ -270,6 +293,8 @@ export function redeemCode(
270293 name: string;
271294 lifetime_s: number | null;
272295 expires_at: string;
296 oidc_scopes: string;
297 nonce: string;
273298 },
274299 [string]
275300 >("DELETE FROM oauth_code WHERE code_hash = ?1 RETURNING *")
@@ -283,10 +308,23 @@ export function redeemCode(
283308 .update(input.verifier)
284309 .digest("base64url");
285310 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");
286313 const minted = createToken(db, row.user_id, row.name, row.scope, {
287314 ...(row.lifetime_s === null
288315 ? {}
289316 : { expiresAt: new Date(Date.now() + row.lifetime_s * 1000).toISOString() }),
290317 });
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 });
outdated

A suspended account still gets a signed id_token here, and the access token is minted above before this check runs. Check isActive(user) before createToken and return invalid_grant. Add a test that suspends the user between consent and exchange.

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 });
292330 }
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 kernel/secret.ts (seal(key, …)), the same way other secrets at rest are stored, and open it in signIdToken. A leaked db or backup must not let someone forge id_tokens.

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 signingKey(db, dir, t0 + ROTATE_MS + 1) calls should return the same kid, and the JWKS should still hold the previous key. Without the check, both would insert and the prune would drop the key that is still in use. I've filed this as a follow-up issue.

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: verifyToken above has already loaded this user and checked it is active. Use verified.value.user here instead of loading the user a second time. Then the null || undefined check goes away, along with the second lookup.

218 return ok(claimsFor(user, row.scopes.split(" ")));
219 }
src/core/schema.ts +17
@@ -1614,6 +1614,23 @@ export const migrations: readonly string[] = [
16141614 PRIMARY KEY (key, issue_id)
16151615 );
16161616 `,
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 `,
16171634 ];
16181635
16191636 /**
src/http/oauth.ts +42 −1
@@ -10,6 +10,7 @@
1010 */
1111 import type { App } from "../app.ts";
1212 import { createClient, parseRegistration, redeemCode } from "../core/oauth.ts";
13 import { jwks, userinfo } from "../core/oidc.ts";
1314 import { RateLimiter } from "../core/ratelimit.ts";
1415 import type { Handler } from "./context.ts";
1516 import { clientBucket } from "./peer.ts";
@@ -71,11 +72,12 @@ export function oauthRoutes(router: Router<Handler>): void {
7172 if (get === null) return grantError("invalid_request", 413);
7273 if (get("grant_type") !== "authorization_code")
7374 return grantError("unsupported_grant_type");
74 const minted = redeemCode(ctx.app.db, {
75 const minted = await redeemCode(ctx.app.db, ctx.app.config.dataDir, {
7576 code: get("code"),
7677 clientId: get("client_id"),
7778 redirectUri: get("redirect_uri"),
7879 verifier: get("code_verifier"),
80 issuer: ctx.url.origin,
7981 });
8082 if (!minted.ok) return grantError(minted.error);
8183 return json(
@@ -83,6 +85,7 @@ export function oauthRoutes(router: Router<Handler>): void {
8385 access_token: minted.value.token,
8486 token_type: "Bearer",
8587 scope: minted.value.scope,
88 ...(minted.value.idToken === undefined ? {} : { id_token: minted.value.idToken }),
8689 ...(minted.value.expiresIn === null
8790 ? {}
8891 : { expires_in: minted.value.expiresIn }),
@@ -91,6 +94,44 @@ export function oauthRoutes(router: Router<Handler>): void {
9194 noStore,
9295 );
9396 });
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 );
94135 router.add("GET", "/.well-known/oauth-protected-resource", (ctx) =>
95136 json(
96137 {
src/web/pages/oauth.ts +19
@@ -29,6 +29,7 @@ const PARAMS = [
2929 "code_challenge_method",
3030 "state",
3131 "scope",
32 "nonce",

The nonce is carried through, but the consent form never tells the person that profile/email hand the client their name and email address. Render the requested OIDC claims on the consent page from request.oidcScopes.

3233 ] as const;
3334
3435 /** 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 =>
7576 const hidden = (name: string, value: string): Html =>
7677 html`<input type="hidden" name="${name}" value="${value}" />`;
7778
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
7896 function consent(ctx: Ctx, request: AuthRequest, values: Params): Html {
7997 const host = new URL(request.redirectUri).host;
8098 return html`<div class="narrow">
@@ -87,6 +105,7 @@ function consent(ctx: Ctx, request: AuthRequest, values: Params): Html {
87105 </p>
88106 <form method="post" action="/oauth/authorize">
89107 ${PARAMS.map((name) => hidden(name, values[name]))}
108 ${identity(request)}
90109 <fieldset class="field">
91110 <legend>What it may do</legend>
92111 <label
test/oauth-discovery.test.ts +21
@@ -133,4 +133,25 @@ describe("oauth discovery and registration", () => {
133133 expect(() => app.db.exec(ddl)).not.toThrow();
134134 expect(() => app.db.exec(ddl)).not.toThrow();
135135 });
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 });
136157 });
test/oauth-flow.test.ts +76
@@ -12,6 +12,7 @@ import { createApp, type App } from "../src/app.ts";
1212 import { listTokens } from "../src/core/tokens.ts";
1313 import { createSession } from "../src/core/sessions.ts";
1414 import { createUser, type User } from "../src/core/users.ts";
15 import { verifyIdToken } from "../src/core/oidc.ts";
1516 import { sha256 } from "../src/kernel/hash.ts";
1617 import { serve, type Servers } from "../src/http/server.ts";
1718
@@ -274,4 +275,79 @@ describe("oauth authorize and token", () => {
274275 expect(to.searchParams.get("state")).toBe("st-1");
275276 expect(to.searchParams.get("code")).toBeNull();
276277 });
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 });
277353 });
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 await. Without it, the test passes even if signing succeeds.

89 signIdToken(db, dir, { ...expectFor, user, scopes: ["openid"], nonce: "" }),
90 ).rejects.toThrow();
91 });
92 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs