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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8361-an-oauth-token-that-expires-can-be-refre → main 2 commits fork 3 threads · 3 unresolved
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 { | ||
| 272 | 272 | readonly expiresIn: number | null; |
| 273 | 273 | /** The signed id_token, when the grant was an OpenID one. */ |
| 274 | 274 | 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 }; | |
| 275 | 335 | } |
| 276 | 336 | |
| 277 | 337 | /** |
| @@ -339,8 +399,23 @@ export async function redeemCode( | ||
| 339 | 399 | row.client_id, |
| 340 | 400 | ); |
| 341 | 401 | 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 | }; | |
| 342 | 417 | 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 }); | |
| 344 | 419 | recordGrant(db, minted.id, scopes); |
| 345 | 420 | const idToken = await signIdToken(db, dataDir, { |
| 346 | 421 | issuer: input.issuer, |
| @@ -349,7 +424,89 @@ export async function redeemCode( | ||
| 349 | 424 | scopes, |
| 350 | 425 | nonce: row.nonce, |
| 351 | 426 | }); |
| 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"); | |
| 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 }); | |
| 353 | 510 | } |
| 354 | 511 | |
| 355 | 512 | /** |
| @@ -361,14 +518,27 @@ export async function redeemCode( | ||
| 361 | 518 | export function revokeOAuthToken( |
| 362 | 519 | db: Database, |
| 363 | 520 | presented: string, |
| 364 | ): { tokenId: string; userId: string } | null { | |
| 521 | ): { tokenId: string | null; userId: string } | null { | |
| 522 | const hash = sha256(presented); | |
| 365 | 523 | const row = db |
| 366 | 524 | .query<{ id: string; user_id: string }, [string]>( |
| 367 | 525 | `SELECT t.id, t.user_id FROM token t JOIN oauth_token o ON o.token_id = t.id |
| 368 | 526 | WHERE t.token_hash = ?1`, |
| 369 | 527 | ) |
| 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 }; | |
| 374 | 544 | } |
src/http/oauth.ts +35 −13
| @@ -14,6 +14,7 @@ import { | ||
| 14 | 14 | OAUTH_SCOPES, |
| 15 | 15 | parseRegistration, |
| 16 | 16 | redeemCode, |
| 17 | refreshGrant, | |
| 17 | 18 | revokeOAuthToken, |
| 18 | 19 | } from "../core/oauth.ts"; |
| 19 | 20 | import { jwks, OIDC_SCOPES, userinfo } from "../core/oidc.ts"; |
| @@ -97,16 +98,33 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 97 | 98 | router.add("POST", "/oauth/token", async (ctx) => { |
| 98 | 99 | const get = await tokenParams(ctx.request); |
| 99 | 100 | 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") | |
| 101 | 103 | 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 | } | |
| 110 | 128 | return json( |
| 111 | 129 | { |
| 112 | 130 | ...(minted.value.token === undefined ? {} : { access_token: minted.value.token }), |
| @@ -116,6 +134,9 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 116 | 134 | ...(minted.value.expiresIn === null |
| 117 | 135 | ? {} |
| 118 | 136 | : { expires_in: minted.value.expiresIn }), |
| 137 | ...(minted.value.refreshToken === undefined | |
| 138 | ? {} | |
| 139 | : { refresh_token: minted.value.refreshToken }), | |
| 119 | 140 | }, |
| 120 | 141 | 200, |
| 121 | 142 | noStore, |
| @@ -137,7 +158,8 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 137 | 158 | // RFC 7009 §2.2: unknown, foreign and already-revoked tokens all get the |
| 138 | 159 | // same empty 200, so the route never says whether a token existed. |
| 139 | 160 | 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) | |
| 141 | 163 | ctx.app.bus.append({ |
| 142 | 164 | kind: "token.deleted", |
| 143 | 165 | actorId: revoked.userId, |
| @@ -173,7 +195,7 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 173 | 195 | registration_endpoint: `${ctx.url.origin}/oauth/register`, |
| 174 | 196 | scopes_supported: [...OIDC_SCOPES, ...OAUTH_SCOPES], |
| 175 | 197 | response_types_supported: ["code"], |
| 176 | grant_types_supported: ["authorization_code"], | |
| 198 | grant_types_supported: ["authorization_code", "refresh_token"], | |
| 177 | 199 | subject_types_supported: ["public"], |
| 178 | 200 | id_token_signing_alg_values_supported: ["ES256"], |
| 179 | 201 | code_challenge_methods_supported: ["S256"], |
| @@ -209,7 +231,7 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 209 | 231 | registration_endpoint: `${ctx.url.origin}/oauth/register`, |
| 210 | 232 | scopes_supported: [...OAUTH_SCOPES], |
| 211 | 233 | response_types_supported: ["code"], |
| 212 | grant_types_supported: ["authorization_code"], | |
| 234 | grant_types_supported: ["authorization_code", "refresh_token"], | |
| 213 | 235 | code_challenge_methods_supported: ["S256"], |
| 214 | 236 | token_endpoint_auth_methods_supported: ["none"], |
| 215 | 237 | revocation_endpoint_auth_methods_supported: ["none"], |
| @@ -243,7 +265,7 @@ export function oauthRoutes(router: Router<Handler>): void { | ||
| 243 | 265 | client_name: client.name, |
| 244 | 266 | redirect_uris: client.redirectUris, |
| 245 | 267 | token_endpoint_auth_method: "none", |
| 246 | grant_types: ["authorization_code"], | |
| 268 | grant_types: ["authorization_code", "refresh_token"], | |
| 247 | 269 | response_types: ["code"], |
| 248 | 270 | }, |
| 249 | 271 | 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 | }); |
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.