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

Merged opened this

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

Merged

scout/forge:scout/748-sign-in-with-your-forge-a-forge-in-the-f → main 9 commits fork 10 threads · 10 unresolved

  • Merged. scout/748-sign-in-with-your-forge-a-forge-in-the-f was merged into main as abb00af.

Review

Sign in to review this change.
14 files changed +994 −9
README.md +17
@@ -173,6 +173,23 @@ The relying party is the address the browser used (or
173173 `FORGE_URL`), so a passkey does not follow a forge to another hostname.
174174 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.
175175
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
176193 Your SSH key is your identity. A key the forge knows signs you straight in; any
177194 other key browses public repos as a guest and can press `s` to sign in with a
178195 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. */
outdated

Low: this doc comment belongs to LOGIN_TTL_MS but sits above CALLBACK_PATH, which now carries two doc comments. Move it down.

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 {
163163 /** `openid` and the OIDC scopes asked with it; empty for a plain OAuth grant. */
164164 readonly oidcScopes: readonly string[];
165165 readonly nonce: string;
166 /** OpenID scopes and no `ro`/`rw`: the person is signed in, no API token is granted. */
167 readonly signInOnly: boolean;
166168 }
167169
168170 const CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
@@ -206,6 +208,7 @@ export function parseAuthRequest(
206208 scope: asked ?? "ro",
207209 oidcScopes: openid ? oidc : [],
208210 nonce: openid ? nonce : "",
211 signInOnly: openid && asked === undefined,
209212 });
210213 }
211214
@@ -234,10 +237,11 @@ export function issueCode(
234237 request: AuthRequest,
235238 grant: { scope: OAuthScope; name: string; lifetimeS: number | null },
236239 ): string {
240 const signInOnly = request.signInOnly ? 1 : 0;
237241 const code = secret();
238242 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)`,
241245 ).run(
242246 sha256(code),
243247 request.client.id,
@@ -250,6 +254,7 @@ export function issueCode(
250254 new Date(Date.now() + CODE_TTL_MS).toISOString(),
251255 request.oidcScopes.join(" "),
252256 request.nonce,
257 signInOnly,
253258 );
254259 db.query("DELETE FROM oauth_code WHERE expires_at <= ?1").run(
255260 new Date().toISOString(),
@@ -258,7 +263,8 @@ export function issueCode(
258263 }
259264
260265 export interface Minted {
261 readonly token: string;
266 /** Absent for a sign-in-only grant: no credential is left behind. */
267 readonly token?: string;
262268 readonly scope: OAuthScope;
263269 /** Seconds the token lives, or null for one that does not expire. */
264270 readonly expiresIn: number | null;
@@ -295,6 +301,7 @@ export async function redeemCode(
295301 expires_at: string;
296302 oidc_scopes: string;
297303 nonce: string;
304 signin_only: number;
298305 },
299306 [string]
300307 >("DELETE FROM oauth_code WHERE code_hash = ?1 RETURNING *")
@@ -310,6 +317,16 @@ export async function redeemCode(
310317 if (digest !== row.challenge) return err("invalid_grant");
311318 const user = getUserById(db, row.user_id);
312319 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 }
313330 const minted = createToken(db, row.user_id, row.name, row.scope, {
314331 ...(row.lifetime_s === null
315332 ? {}
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: res.text() has already buffered everything before text.length > BODY_MAX runs, so the cap protects nothing. It also counts UTF-16 units, not bytes. Read with a byte-bounded reader that stops at BODY_MAX.

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 {
8282 if (!minted.ok) return grantError(minted.error);
8383 return json(
8484 {
85 access_token: minted.value.token,
85 ...(minted.value.token === undefined ? {} : { access_token: minted.value.token }),
8686 token_type: "Bearer",
87 scope: minted.value.scope,
87 ...(minted.value.token === undefined ? {} : { scope: minted.value.scope }),
8888 ...(minted.value.idToken === undefined ? {} : { id_token: minted.value.idToken }),
8989 ...(minted.value.expiresIn === null
9090 ? {}
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 ro/rw in the scope, the parent's parseAuthRequest falls back to ro, and its consent page offers Read/Read-and-act plus a lifetime. A sign-in should not ask for or receive API access: the parent needs an OpenID-only path, with a 'Sign in to <host>' consent and no token minted.

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: res.json() reads the parent's whole answer into memory with no cap. Read it through readCapped (src/origins/providers/http.ts) with a small limit and refuse anything past it.

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 LOGIN_MAX evicts the oldest, so flooding this route cancels everyone's sign-in in flight. Throttle it (RateLimiter, keyed on the socket address or forge-wide) before startLogin.

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: signinAllowed takes the whole-forge bucket before the per-key one. A single address looping on this GET drains the forge-wide sign-in budget that password sign-ins share. Check the per-address bucket first, or give the parent start its own forge cap.

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 access_token, a live API token for the person's parent account (default lifetime "until I revoke it"). It is ignored here and never revoked, so each sign-in leaves one more behind at the parent. Make an OpenID-only grant at the parent mint no token, and assert here that none came back.

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 @@
88 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
99 import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts";
1010 import { unsealReleased } from "../actions/sealed.ts";
11 import { parentCallback, parentStart } from "./rp.ts";
1112 import { accessPage } from "../web/pages/access.ts";
1213 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1314 import { flowPage } from "../web/pages/flow.ts";
@@ -633,6 +634,8 @@ export function buildRouter(): Router<Handler> {
633634 router.add("POST", "/front", frontUpdate);
634635 router.add("GET", "/signin", signinPage);
635636 router.add("POST", "/signin", signinSubmit);
637 router.add("GET", "/signin/parent", parentStart);
638 router.add("GET", "/signin/parent/callback", parentCallback);
636639 router.add("GET", "/signup", signupPage);
637640 router.add("GET", "/verify", verifyPage);
638641 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 | "" =>
9393 </ul>
9494 </fieldset>`;
9595
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
96117 function consent(ctx: Ctx, request: AuthRequest, values: Params): Html {
118 if (request.signInOnly) return signInConsent(request, values);
97119 const host = new URL(request.redirectUri).host;
98120 return html`<div class="narrow">
99121 ${pageHead(`Connect ${request.client.name}`)}
@@ -179,6 +201,14 @@ export async function authorizeSubmit(ctx: Ctx): Promise<Response> {
179201 return guard(
180202 redirect(redirectWith(redirectUri, { error: "access_denied", state })),
181203 );
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 }
182212 const grant = field(form, "grant");
183213 const lifetime = LIFETIMES[field(form, "lifetime")];
184214 if (!isOAuthScope(grant) || lifetime === undefined)
src/web/pages/site.ts +14 −3
@@ -46,6 +46,7 @@ import {
4646 import { existsSync } from "node:fs";
4747 import { join } from "node:path";
4848 import { hasAdmin } from "../../spawn/claim.ts";
49 import { parentIssuerOf } from "../../core/federation.ts";
4950
5051 export function sessionCookie(
5152 value: string,
@@ -369,6 +370,15 @@ function authForm(
369370 <a class="small" href="/recover">Lost your device?</a>
370371 </p>`,
371372 )}
373 ${when(
outdated

Low: this calls readFileSync on parent.json for every /signin render. The spawn record does not change while the process runs; resolve it once (at app creation, or memoised per App).

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 )}
372382 ${claimable
373383 ? html`<p class="muted small mt" id="claim-door">
374384 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 {
426436 adminPerMinute: limitValue(ctx.app.db, "signin.per_forge"),
427437 enabled: true,
428438 });
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.
431440 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;
433444 }
434445
435446 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", () => {
279279 const b64 = (part: string) => JSON.parse(Buffer.from(part, "base64url").toString());
280280
281281 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" });
283283 const code = new URL((await consent(q)).headers.get("location")!).searchParams.get("code")!;
284284 const res = await exchange({ code });
285285 expect(res.status).toBe(200);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs