Public: anyone can read this; the crew can change it.

An OAuth token that expires can be refreshed: the code exchange returns a rotating refresh_token, so a harness never has to send the person back to sign in #8412

Merged opened this

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

Merged

scout/forge:scout/8361-an-oauth-token-that-expires-can-be-refre → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/8361-an-oauth-token-that-expires-can-be-refre was merged into main as be75938.

Review

Sign in to review this change.
6 files changed +472 −20
docs/features/oauth-refresh.md added +18
@@ -0,0 +1,18 @@
1 # OAuth refresh
2
3 When the person chose a lifetime for the token (`lifetime_s`), the code
4 exchange also returns a `refresh_token`. A token with no lifetime gets none and
5 the answer is unchanged.
6
7 ```
8 curl -X POST https://<forge>/oauth/token \
9 -d grant_type=refresh_token -d refresh_token=<rt> -d client_id=<id> [-d scope=ro]
10 ```
11
12 - The answer is a new `access_token` and a new `refresh_token`; the old pair stops working.
13 - A spent refresh token presented again ends the whole family (OAuth 2.1 §4.3.1)
14 and leaves a `token.deleted` event with `via: oauth_refresh_reuse`.
15 - `scope` may only narrow (`rw` to `ro`); more is `invalid_scope` and spends nothing.
16 - `POST /oauth/revoke` with either token ends the family.
17 - Refresh tokens live 90 days from their issue, are stored hashed (`oauth_refresh`),
18 and are bound to the client that got them. Sign-in-only (OpenID) grants get none.
src/core/migration-pins/20261102170000-oauth-refresh.pin added +1
@@ -0,0 +1,1 @@
1 0149d15efb27570aa70395b5550d91ea1a71170368d53c963b29636b04bc4a99
src/core/migrations/20261102170000-oauth-refresh.ts added +22
@@ -0,0 +1,22 @@
1 // A rotating refresh token for an OAuth access token that expires (#8361).
2 // Only the hash is kept. `family` ties every rotation of one grant together
3 // so a spent token presented again can end them all (OAuth 2.1 §4.3.1).
4 // `token_id` is the access token the row last bought; it clears when that
5 // token goes. Additive; the table starts empty.
6 export default `
7 CREATE TABLE oauth_refresh (
8 token_hash TEXT PRIMARY KEY,
9 family TEXT NOT NULL,
10 token_id TEXT REFERENCES token(id) ON DELETE SET NULL,
11 client_id TEXT NOT NULL,
12 user_id TEXT NOT NULL,
13 scope TEXT NOT NULL,
14 name TEXT NOT NULL,
15 lifetime_s INTEGER NOT NULL,
16 oidc_scopes TEXT NOT NULL DEFAULT '',
17 spent INTEGER NOT NULL DEFAULT 0,
18 expires_at TEXT NOT NULL
19 );
20 CREATE INDEX oauth_refresh_family ON oauth_refresh(family);
21 CREATE INDEX oauth_refresh_token ON oauth_refresh(token_id);
22 `;
src/core/oauth.ts +177 −7
@@ -272,6 +272,66 @@ export interface Minted {
272272 readonly expiresIn: number | null;
273273 /** The signed id_token, when the grant was an OpenID one. */
274274 readonly idToken?: string;
275 /** Rotating refresh token, present only when the access token expires. */
276 readonly refreshToken?: string;
277 }
278
279 /** A refresh token outlives its access token by a good margin; a person returns before this. */
280 export const REFRESH_TTL_MS = 90 * 86_400_000;
281
282 /** Store a new refresh token (hash only) in `family`, bought with access token `tokenId`. */
283 function mintRefresh(
284 db: Database,
285 grant: {
286 family: string;
287 tokenId: string;
288 clientId: string;
289 userId: string;
290 scope: OAuthScope;
291 name: string;
292 lifetimeS: number;
293 oidcScopes: string;
294 },
295 ): string {
296 const refresh = secret();
297 // Keep the table bounded: only the newest spent predecessor of a family is
298 // needed to detect reuse, and a row past its expiry is dead weight.
299 db.query(
300 `DELETE FROM oauth_refresh
301 WHERE expires_at <= ?1
302 OR (family = ?2 AND spent = 1
303 AND rowid NOT IN (SELECT MAX(rowid) FROM oauth_refresh WHERE family = ?2 AND spent = 1))`,
304 ).run(new Date().toISOString(), grant.family);
305 db.query(
306 `INSERT INTO oauth_refresh (token_hash, family, token_id, client_id, user_id, scope, name, lifetime_s, oidc_scopes, expires_at)
307 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)`,
308 ).run(
309 sha256(refresh),
310 grant.family,
311 grant.tokenId,
312 grant.clientId,
313 grant.userId,
314 grant.scope,
315 grant.name,
316 grant.lifetimeS,
317 grant.oidcScopes,
318 new Date(Date.now() + REFRESH_TTL_MS).toISOString(),
319 );
320 return refresh;
321 }
322
323 /** End every access token and refresh token of one family. Returns the owner and tokens ended. */
324 function endFamily(db: Database, family: string): { userId: string; tokenIds: string[] } | null {
325 const rows = db
326 .query<{ token_id: string | null; user_id: string }, [string]>(
327 "SELECT token_id, user_id FROM oauth_refresh WHERE family = ?1",
328 )
329 .all(family);
330 if (rows.length === 0) return null;
331 const tokenIds = rows.flatMap((r) => (r.token_id === null ? [] : [r.token_id]));
332 for (const tokenId of tokenIds) db.query("DELETE FROM token WHERE id = ?1").run(tokenId);
333 db.query("DELETE FROM oauth_refresh WHERE family = ?1").run(family);
334 return { userId: rows[0]?.user_id ?? "", tokenIds };
275335 }
276336
277337 /**
@@ -339,8 +399,23 @@ export async function redeemCode(
339399 row.client_id,
340400 );
341401 const scopes = row.oidc_scopes === "" ? [] : row.oidc_scopes.split(" ");
402 const refreshToken =
403 row.lifetime_s === null
404 ? {}
405 : {
406 refreshToken: mintRefresh(db, {
407 family: id(),
408 tokenId: minted.id,
409 clientId: row.client_id,
410 userId: row.user_id,
411 scope: row.scope,
412 name: row.name,
413 lifetimeS: row.lifetime_s,
414 oidcScopes: row.oidc_scopes,
415 }),
416 };
342417 if (scopes.length === 0)
343 return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s });
418 return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s, ...refreshToken });
344419 recordGrant(db, minted.id, scopes);
345420 const idToken = await signIdToken(db, dataDir, {
346421 issuer: input.issuer,
@@ -349,7 +424,89 @@ export async function redeemCode(
349424 scopes,
350425 nonce: row.nonce,
351426 });
352 return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s, idToken });
427 return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s, idToken, ...refreshToken });
428 }
429
430 /** A refused refresh: RFC 6749 §5.2 code. */
431 export type RefreshRefusal = "invalid_grant" | "invalid_scope";
432
433 /** What a reuse of a spent refresh token ended, for the audit trail. */
434 export interface FamilyRevoked {
435 readonly userId: string;
436 readonly tokenIds: readonly string[];
437 }
438
439 const SCOPE_RANK: Readonly<Record<OAuthScope, number>> = { ro: 0, rw: 1 };
440
441 /**
442 * Swap a refresh token for a new access token and a new refresh token; the
443 * old pair stops working. A spent token presented again ends the whole
444 * family (OAuth 2.1 §4.3.1): `reuse` then names what was ended. Scope may
445 * only narrow: asking for more than was granted is `invalid_scope`.
446 */
447 export function refreshGrant(
448 db: Database,
449 input: { refreshToken: string; clientId: string; scope: string },
450 ): Result<Minted, RefreshRefusal> & { reuse?: FamilyRevoked } {
451 const row = db
452 .query<
453 {
454 family: string;
455 client_id: string;
456 user_id: string;
457 scope: OAuthScope;
458 name: string;
459 lifetime_s: number;
460 oidc_scopes: string;
461 spent: number;
462 expires_at: string;
463 token_id: string | null;
464 },
465 [string]
466 >("SELECT * FROM oauth_refresh WHERE token_hash = ?1")
467 .get(sha256(input.refreshToken));
468 if (row === null || row.client_id !== input.clientId) return err("invalid_grant");
outdated

LOW: the inactive-user branch returns invalid_grant but leaves the family alive. End the family there, and add a test for a deactivated user refreshing.

469 if (row.spent === 1) {
470 const ended = endFamily(db, row.family);
471 return { ...err("invalid_grant"), ...(ended === null ? {} : { reuse: ended }) };
472 }
473 if (Date.parse(row.expires_at) <= Date.now()) {
474 endFamily(db, row.family);
475 return err("invalid_grant");
476 }
477 const user = getUserById(db, row.user_id);
478 if (user === null || user === undefined || !isActive(user)) {
479 endFamily(db, row.family);
480 return err("invalid_grant");
481 }
482 const asked = input.scope.split(/\s+/).filter((v) => v !== "");
483 let scope = row.scope;
484 if (asked.length > 0) {
485 if (!asked.every(isOAuthScope)) return err("invalid_scope");
486 const widest = asked.reduce((a, b) => (SCOPE_RANK[b] > SCOPE_RANK[a] ? b : a));
487 if (SCOPE_RANK[widest] > SCOPE_RANK[row.scope]) return err("invalid_scope");
488 scope = widest;
489 }
490 const spend = db.query("UPDATE oauth_refresh SET spent = 1 WHERE token_hash = ?1 AND spent = 0");
491 if (spend.run(sha256(input.refreshToken)).changes !== 1) return err("invalid_grant");
492 if (row.token_id !== null) db.query("DELETE FROM token WHERE id = ?1").run(row.token_id);
493 const minted = createToken(db, row.user_id, row.name, scope, {
494 expiresAt: new Date(Date.now() + row.lifetime_s * 1000).toISOString(),
495 });
496 db.query("INSERT INTO oauth_token (token_id, client_id) VALUES (?1, ?2)").run(minted.id, row.client_id);
497 const scopes = row.oidc_scopes === "" ? [] : row.oidc_scopes.split(" ");
outdated

MEDIUM: every rotation leaves a spent row and rows only go when a family is revoked or an expired token is presented. A client that refreshes daily for 90 days leaves ~90 rows forever, and abandoned families never go. Fix: when minting (mintRefresh or refreshGrant), delete the family's spent rows and any row past expires_at, keeping only the latest spent predecessor needed for reuse detection. Add a test that the row count of a family stays bounded after N refreshes.

498 if (scopes.length > 0) recordGrant(db, minted.id, scopes);
499 const refreshToken = mintRefresh(db, {
500 family: row.family,
501 tokenId: minted.id,
502 clientId: row.client_id,
503 userId: row.user_id,
504 scope,
505 name: row.name,
506 lifetimeS: row.lifetime_s,
507 oidcScopes: row.oidc_scopes,
508 });
509 return ok({ token: minted.token, scope, expiresIn: row.lifetime_s, refreshToken });
353510 }
354511
355512 /**
@@ -361,14 +518,27 @@ export async function redeemCode(
361518 export function revokeOAuthToken(
362519 db: Database,
363520 presented: string,
364 ): { tokenId: string; userId: string } | null {
521 ): { tokenId: string | null; userId: string } | null {
522 const hash = sha256(presented);
365523 const row = db
366524 .query<{ id: string; user_id: string }, [string]>(
367525 `SELECT t.id, t.user_id FROM token t JOIN oauth_token o ON o.token_id = t.id
368526 WHERE t.token_hash = ?1`,
369527 )
370 .get(sha256(presented));
371 if (row === null) return null;
372 db.query("DELETE FROM token WHERE id = ?1").run(row.id);
373 return { tokenId: row.id, userId: row.user_id };
528 .get(hash);
529 if (row !== null) {
530 const family = db
531 .query<{ family: string }, [string]>("SELECT family FROM oauth_refresh WHERE token_id = ?1")
532 .get(row.id);
533 db.query("DELETE FROM token WHERE id = ?1").run(row.id);
outdated

LOW: when the family's access token is already gone (token_id NULL), this returns tokenId "" and the route appends a token.deleted event with an empty tokenId. Return null-safe data (skip the event, or carry the family id) and test revoking a refresh token after its access token was deleted.

534 if (family !== null) endFamily(db, family.family);
535 return { tokenId: row.id, userId: row.user_id };
536 }
537 // A refresh token: its whole family goes (RFC 7009 §2.1).
538 const refresh = db
539 .query<{ family: string }, [string]>("SELECT family FROM oauth_refresh WHERE token_hash = ?1")
540 .get(hash);
541 if (refresh === null) return null;
542 const ended = endFamily(db, refresh.family);
543 return ended === null ? null : { tokenId: ended.tokenIds[0] ?? null, userId: ended.userId };
374544 }
src/http/oauth.ts +35 −13
@@ -14,6 +14,7 @@ import {
1414 OAUTH_SCOPES,
1515 parseRegistration,
1616 redeemCode,
17 refreshGrant,
1718 revokeOAuthToken,
1819 } from "../core/oauth.ts";
1920 import { jwks, OIDC_SCOPES, userinfo } from "../core/oidc.ts";
@@ -97,16 +98,33 @@ export function oauthRoutes(router: Router<Handler>): void {
9798 router.add("POST", "/oauth/token", async (ctx) => {
9899 const get = await tokenParams(ctx.request);
99100 if (get === null) return grantError("invalid_request", 413);
100 if (get("grant_type") !== "authorization_code")
101 const grantType = get("grant_type");
102 if (grantType !== "authorization_code" && grantType !== "refresh_token")
101103 return grantError("unsupported_grant_type");
102 const minted = await redeemCode(ctx.app.db, ctx.app.config.dataDir, {
103 code: get("code"),
104 clientId: get("client_id"),
105 redirectUri: get("redirect_uri"),
106 verifier: get("code_verifier"),
107 issuer: ctx.url.origin,
108 });
109 if (!minted.ok) return grantError(minted.error);
104 const minted =
105 grantType === "refresh_token"
106 ? refreshGrant(ctx.app.db, {
107 refreshToken: get("refresh_token"),
108 clientId: get("client_id"),
109 scope: get("scope"),
110 })
111 : await redeemCode(ctx.app.db, ctx.app.config.dataDir, {
112 code: get("code"),
113 clientId: get("client_id"),
114 redirectUri: get("redirect_uri"),
115 verifier: get("code_verifier"),
116 issuer: ctx.url.origin,
117 });
118 if (!minted.ok) {
119 const reuse = "reuse" in minted ? minted.reuse : undefined;
120 if (reuse !== undefined)
121 ctx.app.bus.append({
122 kind: "token.deleted",
123 actorId: reuse.userId,
124 payload: { tokenIds: [...reuse.tokenIds], via: "oauth_refresh_reuse" },
125 });
126 return grantError(minted.error);
127 }
110128 return json(
111129 {
112130 ...(minted.value.token === undefined ? {} : { access_token: minted.value.token }),
@@ -116,6 +134,9 @@ export function oauthRoutes(router: Router<Handler>): void {
116134 ...(minted.value.expiresIn === null
117135 ? {}
118136 : { expires_in: minted.value.expiresIn }),
137 ...(minted.value.refreshToken === undefined
138 ? {}
139 : { refresh_token: minted.value.refreshToken }),
119140 },
120141 200,
121142 noStore,
@@ -137,7 +158,8 @@ export function oauthRoutes(router: Router<Handler>): void {
137158 // RFC 7009 §2.2: unknown, foreign and already-revoked tokens all get the
138159 // same empty 200, so the route never says whether a token existed.
139160 const revoked = presented === "" ? null : revokeOAuthToken(ctx.app.db, presented);
140 if (revoked !== null)
161 // A family whose access token was already gone has no token to name.
162 if (revoked !== null && revoked.tokenId !== null)
141163 ctx.app.bus.append({
142164 kind: "token.deleted",
143165 actorId: revoked.userId,
@@ -173,7 +195,7 @@ export function oauthRoutes(router: Router<Handler>): void {
173195 registration_endpoint: `${ctx.url.origin}/oauth/register`,
174196 scopes_supported: [...OIDC_SCOPES, ...OAUTH_SCOPES],
175197 response_types_supported: ["code"],
176 grant_types_supported: ["authorization_code"],
198 grant_types_supported: ["authorization_code", "refresh_token"],
177199 subject_types_supported: ["public"],
178200 id_token_signing_alg_values_supported: ["ES256"],
179201 code_challenge_methods_supported: ["S256"],
@@ -209,7 +231,7 @@ export function oauthRoutes(router: Router<Handler>): void {
209231 registration_endpoint: `${ctx.url.origin}/oauth/register`,
210232 scopes_supported: [...OAUTH_SCOPES],
211233 response_types_supported: ["code"],
212 grant_types_supported: ["authorization_code"],
234 grant_types_supported: ["authorization_code", "refresh_token"],
213235 code_challenge_methods_supported: ["S256"],
214236 token_endpoint_auth_methods_supported: ["none"],
215237 revocation_endpoint_auth_methods_supported: ["none"],
@@ -243,7 +265,7 @@ export function oauthRoutes(router: Router<Handler>): void {
243265 client_name: client.name,
244266 redirect_uris: client.redirectUris,
245267 token_endpoint_auth_method: "none",
246 grant_types: ["authorization_code"],
268 grant_types: ["authorization_code", "refresh_token"],
247269 response_types: ["code"],
248270 },
249271 201,
test/oauth-refresh.test.ts added +219
@@ -0,0 +1,219 @@
1 /**
2 * grant_type=refresh_token (#8361): an expiring OAuth token renews without a
3 * new sign-in; the refresh token rotates, reuse ends the family, scope only narrows.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createClient, issueCode, parseAuthRequest, type OAuthClient } from "../src/core/oauth.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13
14 const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
15 const verifier = "v".repeat(60);
16 const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url");
17
18 describe("oauth refresh", () => {
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let base: string;
23 let alice: User;
24 let client: OAuthClient;
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-oauth-refresh-"));
28 app = createApp({ dataDir, port: 0 });
29 servers = await serve(app);
30 base = servers.url;
31 const made = await createUser(app.db, {
32 origin: "seed",
33 trusted: true,
34 username: "alice",
35 email: "[email protected]",
36 password: "a-good-passphrase",
37 adminIfFirst: true,
38 });
39 if (!made.ok) throw new Error(made.error);
40 alice = made.value;
41 client = createClient(app.db, { name: "Fake", redirectUris: [REDIRECT] });
42 });
43 afterAll(async () => {
44 servers.stop();
45 await rm(dataDir, { recursive: true, force: true });
46 });
47
48 const post = async (path: string, params: Record<string, string>) => {
49 const res = await fetch(`${base}${path}`, {
50 method: "POST",
51 headers: { "content-type": "application/x-www-form-urlencoded" },
52 body: new URLSearchParams(params),
53 });
54 const text = await res.text();
55 return { status: res.status, body: text === "" ? {} : (JSON.parse(text) as Record<string, unknown>) };
56 };
57 const exchange = async (scope: "ro" | "rw", lifetimeS: number | null) => {
58 const params: Record<string, string> = {
59 client_id: client.id,
60 redirect_uri: REDIRECT,
61 response_type: "code",
62 code_challenge_method: "S256",
63 code_challenge: challenge,
64 scope,
65 };
66 const request = parseAuthRequest(app.db, (n) => params[n] ?? "");
67 if (!request.ok) throw new Error(request.error);
68 const code = issueCode(app.db, alice.id, request.value, { scope, name: "Fake", lifetimeS });
69 return post("/oauth/token", {
70 grant_type: "authorization_code",
71 code,
72 client_id: client.id,
73 redirect_uri: REDIRECT,
74 code_verifier: verifier,
75 });
76 };
77 const refresh = (token: unknown, extra: Record<string, string> = {}) =>
78 post("/oauth/token", { grant_type: "refresh_token", refresh_token: String(token), client_id: client.id, ...extra });
79 const api = (token: unknown) =>
80 fetch(`${base}/api/v1/asked`, { headers: { authorization: `Bearer ${String(token)}` } }).then((r) => r.status);
81
82 test("a code with a lifetime returns a refresh token, stored hashed; one without does not", async () => {
83 const lived = await exchange("rw", 86_400);
84 const refreshToken = String(lived.body["refresh_token"]);
85 expect(refreshToken.length).toBeGreaterThan(20);
86 const rows = app.db.query<{ token_hash: string }, []>("SELECT token_hash FROM oauth_refresh").all();
87 expect(rows.some((r) => r.token_hash === refreshToken)).toBe(false);
88 expect(rows.length).toBeGreaterThan(0);
89 const forever = await exchange("rw", null);
90 expect(forever.body["refresh_token"]).toBeUndefined();
91 expect(forever.body["expires_in"]).toBeUndefined();
92 });
93
94 test("refresh swaps for a new pair and the old pair stops working", async () => {
95 const first = await exchange("rw", 86_400);
96 expect(await api(first.body["access_token"])).not.toBe(401);
97 const next = await refresh(first.body["refresh_token"]);
98 expect(next.status).toBe(200);
99 expect(next.body["token_type"]).toBe("Bearer");
100 expect(next.body["expires_in"]).toBe(86_400);
101 expect(next.body["scope"]).toBe("rw");
102 expect(next.body["access_token"]).not.toBe(first.body["access_token"]);
103 expect(next.body["refresh_token"]).not.toBe(first.body["refresh_token"]);
104 expect(await api(first.body["access_token"])).toBe(401);
105 expect(await api(next.body["access_token"])).not.toBe(401);
106 expect((await refresh(next.body["refresh_token"])).status).toBe(200);
107 });
108
109 test("reusing a spent refresh token revokes the whole family and audits it", async () => {
110 const first = await exchange("rw", 86_400);
111 const second = await refresh(first.body["refresh_token"]);
112 expect(second.status).toBe(200);
113 const replay = await refresh(first.body["refresh_token"]);
114 expect(replay.status).toBe(400);
115 expect(replay.body["error"]).toBe("invalid_grant");
116 expect(await api(second.body["access_token"])).toBe(401);
117 expect((await refresh(second.body["refresh_token"])).body["error"]).toBe("invalid_grant");
118 const audit = app.db
119 .query<{ actor_id: string; payload: string }, []>(
120 "SELECT actor_id, payload FROM event WHERE kind = 'token.deleted' ORDER BY seq DESC LIMIT 1",
121 )
122 .get();
123 expect(audit?.actor_id).toBe(alice.id);
124 expect(audit?.payload).toContain("oauth_refresh_reuse");
125 });
126
127 test("scope narrows on refresh and never widens", async () => {
128 const first = await exchange("rw", 86_400);
129 const narrow = await refresh(first.body["refresh_token"], { scope: "ro" });
130 expect(narrow.status).toBe(200);
131 expect(narrow.body["scope"]).toBe("ro");
132 const widen = await refresh(narrow.body["refresh_token"], { scope: "rw" });
133 expect(widen.status).toBe(400);
134 expect(widen.body["error"]).toBe("invalid_scope");
135 const both = await refresh(narrow.body["refresh_token"], { scope: "ro rw" });
136 expect(both.body["error"]).toBe("invalid_scope");
137 const unknown = await refresh(narrow.body["refresh_token"], { scope: "admin" });
138 expect(unknown.body["error"]).toBe("invalid_scope");
139 // a refused widening spends nothing
140 const kept = await refresh(narrow.body["refresh_token"]);
141 expect(kept.status).toBe(200);
142 expect(kept.body["scope"]).toBe("ro");
143 });
144
145 test("an unknown token, another client and an access token are all invalid_grant", async () => {
146 const first = await exchange("rw", 86_400);
147 expect((await refresh("nope")).body["error"]).toBe("invalid_grant");
148 expect((await refresh(first.body["access_token"])).body["error"]).toBe("invalid_grant");
149 const other = createClient(app.db, { name: "Other", redirectUris: [REDIRECT] });
150 const res = await post("/oauth/token", {
151 grant_type: "refresh_token",
152 refresh_token: String(first.body["refresh_token"]),
153 client_id: other.id,
154 });
155 expect(res.body["error"]).toBe("invalid_grant");
156 expect((await refresh(first.body["refresh_token"])).status).toBe(200);
157 });
158
159 test("revoking the refresh token ends the family; so does revoking the access token", async () => {
160 const a = await exchange("rw", 86_400);
161 await post("/oauth/revoke", { token: String(a.body["refresh_token"]) });
162 expect(await api(a.body["access_token"])).toBe(401);
163 expect((await refresh(a.body["refresh_token"])).body["error"]).toBe("invalid_grant");
164 const b = await exchange("rw", 86_400);
165 await post("/oauth/revoke", { token: String(b.body["access_token"]) });
166 expect((await refresh(b.body["refresh_token"])).body["error"]).toBe("invalid_grant");
167 });
168
169 test("spent rows stay bounded: a family keeps one predecessor however often it refreshes", async () => {
170 const a = await exchange("rw", 86_400);
171 let token = a.body["refresh_token"];
172 for (let i = 0; i < 6; i++) token = (await refresh(token)).body["refresh_token"];
173 const rows = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM oauth_refresh WHERE family = (SELECT family FROM oauth_refresh ORDER BY rowid DESC LIMIT 1)").get();
174 expect(rows?.n).toBe(2);
175 });
176
177 test("minting purges rows past expiry, abandoned families included", async () => {
178 await exchange("rw", 86_400);
179 app.db.query("UPDATE oauth_refresh SET expires_at = '2000-01-01T00:00:00.000Z'").run();
180 const b = await exchange("rw", 86_400);
181 await refresh(b.body["refresh_token"]);
182 const old = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM oauth_refresh WHERE expires_at < '2001'").get();
183 expect(old?.n).toBe(0);
184 });
185
186 test("revoking a refresh token after its access token was deleted succeeds without an empty audit", async () => {
187 const a = await exchange("rw", 86_400);
188 app.db.query("DELETE FROM token WHERE id = (SELECT token_id FROM oauth_refresh ORDER BY rowid DESC LIMIT 1)").run();
189 app.db.query("UPDATE oauth_refresh SET token_id = NULL WHERE token_id NOT IN (SELECT id FROM token)").run();
190 const res = await post("/oauth/revoke", { token: String(a.body["refresh_token"]) });
191 expect(res.status).toBe(200);
192 expect((await refresh(a.body["refresh_token"])).body["error"]).toBe("invalid_grant");
193 const empty = app.db.query("SELECT 1 FROM event WHERE kind = 'token.deleted' AND payload LIKE '%\"tokenId\":\"\"%'").get();
194 expect(empty).toBeNull();
195 });
196
197 test("a deactivated user's refresh ends the family", async () => {
198 const a = await exchange("rw", 86_400);
199 app.db.query("UPDATE user SET suspended_at = ?1 WHERE id = ?2").run(new Date().toISOString(), alice.id);
200 expect((await refresh(a.body["refresh_token"])).body["error"]).toBe("invalid_grant");
201 app.db.query("UPDATE user SET suspended_at = NULL WHERE id = ?1").run(alice.id);
202 expect((await refresh(a.body["refresh_token"])).body["error"]).toBe("invalid_grant");
203 const left = app.db.query("SELECT 1 FROM oauth_refresh WHERE token_hash = ?1").get(new Bun.CryptoHasher("sha256").update(String(a.body["refresh_token"])).digest("hex"));
204 expect(left).toBeNull();
205 });
206
207 test("an expired refresh token is refused", async () => {
208 const a = await exchange("rw", 86_400);
209 app.db.query("UPDATE oauth_refresh SET expires_at = '2000-01-01T00:00:00.000Z'").run();
210 expect((await refresh(a.body["refresh_token"])).body["error"]).toBe("invalid_grant");
211 });
212
213 test("both metadata documents list refresh_token", async () => {
214 for (const path of ["oauth-authorization-server", "openid-configuration"]) {
215 const doc = (await (await fetch(`${base}/.well-known/${path}`)).json()) as Record<string, unknown>;
216 expect(doc["grant_types_supported"]).toEqual(["authorization_code", "refresh_token"]);
217 }
218 });
219 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs