| 1 | # Expiring OAuth tokens and refresh tokens Implementation Plan |
| 2 | |
| 3 | > **For agentic workers: |
| 4 | |
| 5 | **Goal:** OAuth apps can opt into GitHub's expiring user tokens: an 8-hour `ghost_pat_ |
| 6 | |
| 7 | **Architecture:** The access token stays an api-key plugin key, created with `expiresIn |
| 8 | |
| 9 | **Tech Stack:** NestJS, Drizzle (Postgres), Better Auth `apiKey |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-10-10-oauth-refresh-tokens-design.md |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - Follow `docs/code-standards.md`: no `to<Format>` reshaping, no concatenated or wrapped strings or comments, comments say why only, pure functions under |
| 16 | - SQL migrations are generated with `bun db:generate` at the repository root. Never write or edit a `.sql` |
| 17 | - Access token lifetime `28800` seconds; refresh token lifetime `15897600` seconds. Fixed, not configurable. |
| 18 | - Refresh token prefix `ghost_rt_`; access token prefix stays `ghost_pat_`. |
| 19 | - OAuth errors answer HTTP 200 with `error` in the body: `bad_refresh_token`, |
| 20 | - Responses go through `sendOAuth`: form-encoded by default, JSON on `Accept: application/json`. |
| 21 | - Run e2e only against throwaway containers: Postgres `postgres:16-alpine` on 55433, RustFS on 59000. Never the dev database at 192.168.1.4. Set `TEST_DATABASE_URL` |
| 22 | - Commits carry no `Co-Authored-By` trailer. |
| 23 | |
| 24 | ## Review Focus |
| 25 | |
| 26 | 1. A refresh token used twice concurrently: exactly one call gets a new pair. Pinned in Task 4 by firing two refreshes with `Promise.all`. |
| 27 | 2. A refresh token sent with another app's valid credentials: `bad_refresh_token`, and the owner's token keeps working. Pinned in Task 4. |
| 28 | 3. An app whose only access key the plugin already deleted (expired): it still shows under Authorized apps, and revoking it kills the refresh token. Pinned in Task 5. |
| 29 | 4. Refresh with a missing `client_secret`: `incorrect_client_credentials`, never a new pair. Pinned in Task 4. |
| 30 | 5. Turning the switch off after tokens were issued: the next refresh returns a key with no expiry and no `refresh_token`, and the old refresh token is spent. Pinned in Task 4. |
| 31 | |
| 32 | --- |
| 33 | |
| 34 | ## File map |
| 35 | |
| 36 | | File | Responsibility | |
| 37 | | --- | --- | |
| 38 | | `packages/db/src/schema/oauth-app-refresh-token.ts` (new) | `oauthAppRefreshToken |
| 39 | | `packages/db/src/schema/index.ts` | export it | |
| 40 | | `packages/db/drizzle/*` | generated by `bun db:generate |
| 41 | | `apps/api/src/lib/github/refresh-tokens.ts` (new) | lifetimes, token generation, hashing, issue and spend | |
| 42 | | `apps/api/src/lib/github/refresh-tokens.spec.ts` (new) | unit tests for generation and hashing | |
| 43 | | `apps/api/src/lib/github/oauth-apps.ts` | `expireUserTokens |
| 44 | | `apps/api/src/resources/oauth-apps/dto/oauth-app.dto.ts` | `expireUserTokens |
| 45 | | `apps/api/src/resources/oauth-apps/oauth-apps.service.ts` | store the switch; authorized list and revoke read refresh rows | |
| 46 | | `apps/api/src/lib/auth.ts` | `keyExpiration.minExpiresIn |
| 47 | | `apps/api/src/github/oauth/oauth.controller.ts` | `mint |
| 48 | | `apps/api/test/github-oauth-refresh.e2e-spec.ts` (new) | e2e for issuing and refreshing | |
| 49 | | `apps/api/test/oauth-apps.e2e-spec.ts` | e2e for the switch and authorized apps | |
| 50 | | `apps/web/src/components/oauth-apps/{oauth-app-fields.tsx,actions.ts,oauth-app-settings-card.tsx}` | the switch | |
| 51 | | `apps/api/openapi.json`, `apps/web/src/lib/api/v1.d.ts` |
| 52 | | `apps/docs/content/docs/oauth-apps/refreshing-tokens.mdx` (new), `meta.json`, |
| 53 | | `docs/0040-github-compatibility-is-a-translation-layer.md` | addendum | |
| 54 | |
| 55 | ## Running tests |
| 56 | |
| 57 | Unit: `cd apps/api && bunx vitest run src/lib/github/refresh-tokens.spec.ts` |
| 58 | |
| 59 | E2E backends (once per session): |
| 60 | |
| 61 | ```bash |
| 62 | docker run -d --rm |
| 63 | docker run -d --rm |
| 64 | ``` |
| 65 | |
| 66 | E2E run (`E2E` below): |
| 67 | |
| 68 | ```bash |
| 69 | cd apps/api && TEST_DATABASE_URL |
| 70 | ``` |
| 71 | |
| 72 | --- |
| 73 | |
| 74 | ### Task 1: Refresh-token table and library |
| 75 | |
| 76 | **Files:** |
| 77 | - Create: `packages/db/src/schema/oauth-app-refresh-token.ts` |
| 78 | - Modify: `packages/db/src/schema/index.ts` |
| 79 | - Generated: `packages/db/drizzle/<n>_*.sql`, `packages/db/drizzle/meta/*` |
| 80 | - Create: `apps/api/src/lib/github/refresh-tokens.ts` |
| 81 | - Test: `apps/api/src/lib/github/refresh-tokens.spec.ts` |
| 82 | |
| 83 | **Interfaces:** |
| 84 | - Produces: `oauthAppRefreshToken` (table export); `ACCESS_TOKEN_EXPIRES_IN = 28800`; |
| 85 | |
| 86 | - [ ] **Step 1: Write the table** |
| 87 | |
| 88 | ```ts |
| 89 | // packages/db/src/schema/oauth-app-refresh-token.ts |
| 90 | import { createId } |
| 91 | import { index, pgTable |
| 92 | |
| 93 | import { user } |
| 94 | import { oauthClient } |
| 95 | |
| 96 | /** Ghost's refresh tokens for OAuth apps with expiring user tokens. Only the token's sha256 is stored. */ |
| 97 | export const oauthAppRefreshToken = |
| 98 | "oauth_app_refresh_token", |
| 99 | { |
| 100 | id: text() |
| 101 | .primaryKey() |
| 102 | .$defaultFn(() => |
| 103 | tokenHash: text().notNull |
| 104 | clientId: text() |
| 105 | .notNull() |
| 106 | .references(() => |
| 107 | userId: text() |
| 108 | .notNull() |
| 109 | .references(() => |
| 110 | scopes: text().array |
| 111 | // No foreign key: the api-key plugin deletes expired keys, and the refresh token must outlive its access key. |
| 112 | accessKeyId: text().notNull |
| 113 | expiresAt: timestamp({ |
| 114 | createdAt: timestamp({ |
| 115 | }, |
| 116 | (t) => |
| 117 | ); |
| 118 | ``` |
| 119 | |
| 120 | Add `export * from "./oauth-app-refresh-token.js";` to `packages/db/src/schema/index.ts` after the |
| 121 | |
| 122 | - [ ] **Step 2: Generate the migration** |
| 123 | |
| 124 | Run at the repository root: `bun db:generate` |
| 125 | Expected: one new `packages/db/drizzle/00NN_*.sql` creating `oauth_app_refresh_token` with snake_case columns, the unique index on |
| 126 | |
| 127 | - [ ] **Step 3: Write the failing unit test** |
| 128 | |
| 129 | ```ts |
| 130 | // apps/api/src/lib/github/refresh-tokens.spec.ts |
| 131 | import { describe, expect |
| 132 | |
| 133 | import { hashRefreshToken, newRefreshToken |
| 134 | |
| 135 | describe('refresh tokens', () |
| 136 | it('are ghost_rt_ followed by 32 random bytes, base64url', |
| 137 | const token = |
| 138 | expect(token).toMatch |
| 139 | expect(newRefreshToken()).not |
| 140 | }); |
| 141 | |
| 142 | it('hash to a stable sha256 hex digest', |
| 143 | expect(hashRefreshToken('ghost_rt_x |
| 144 | expect(hashRefreshToken('ghost_rt_x |
| 145 | expect(hashRefreshToken('ghost_rt_y |
| 146 | }); |
| 147 | }); |
| 148 | ``` |
| 149 | |
| 150 | - [ ] **Step 4: Run it to see it fail** |
| 151 | |
| 152 | Run: `cd apps/api && bunx vitest run src/lib/github/refresh-tokens.spec.ts` |
| 153 | Expected: FAIL, cannot resolve `./refresh-tokens.js`. |
| 154 | |
| 155 | - [ ] **Step 5: Write the library** |
| 156 | |
| 157 | ```ts |
| 158 | // apps/api/src/lib/github/refresh-tokens.ts |
| 159 | import { createHash, randomBytes |
| 160 | |
| 161 | import { type Database, |
| 162 | import { and, eq |
| 163 | |
| 164 | /** GitHub's lifetimes for expiring user tokens, in seconds. */ |
| 165 | export const ACCESS_TOKEN_EXPIRES_IN = |
| 166 | export const REFRESH_TOKEN_EXPIRES_IN = |
| 167 | |
| 168 | /** What a refresh token re-grants: the user, the scopes they approved, and the key it was issued beside. */ |
| 169 | export type RefreshGrant |
| 170 | userId: string |
| 171 | scopes: string |
| 172 | accessKeyId: string |
| 173 | }; |
| 174 | |
| 175 | export const newRefreshToken = |
| 176 | `ghost_rt_${randomBytes(32) |
| 177 | |
| 178 | export const hashRefreshToken = |
| 179 | createHash('sha256'). |
| 180 | |
| 181 | /** Stores a refresh token for the grant and returns it; only its hash is kept. */ |
| 182 | export async function issueRefreshToken |
| 183 | db: Database |
| 184 | clientId: string, |
| 185 | grant: RefreshGrant |
| 186 | ): Promise<string |
| 187 | const token = |
| 188 | await db.insert |
| 189 | ...grant, |
| 190 | clientId, |
| 191 | tokenHash: hashRefreshToken(token |
| 192 | expiresAt: new Date |
| 193 | }); |
| 194 | return token; |
| 195 | } |
| 196 | |
| 197 | /** Deletes the app's refresh token and returns what it granted, or null when it is unknown, another app's, or expired. Deleting in the same statement lets only one of two concurrent refreshes win. */ |
| 198 | export async function spendRefreshToken |
| 199 | db: Database |
| 200 | clientId: string, |
| 201 | token: string, |
| 202 | ): Promise<RefreshGrant |
| 203 | const table = |
| 204 | const [spent] |
| 205 | .delete(table) |
| 206 | .where( |
| 207 | and( |
| 208 | eq(table.tokenHash |
| 209 | eq(table.clientId |
| 210 | ), |
| 211 | ) |
| 212 | .returning({ |
| 213 | userId: table.userId |
| 214 | scopes: table.scopes |
| 215 | accessKeyId: table.accessKeyId |
| 216 | expiresAt: table.expiresAt |
| 217 | }); |
| 218 | if (!spent || |
| 219 | return { |
| 220 | userId: spent.userId |
| 221 | scopes: spent.scopes |
| 222 | accessKeyId: spent.accessKeyId |
| 223 | }; |
| 224 | } |
| 225 | ``` |
| 226 | |
| 227 | - [ ] **Step 6: Run the unit test and type check** |
| 228 | |
| 229 | Run: `cd apps/api && bunx vitest run src/lib/github/refresh-tokens.spec.ts && bun run check-types` |
| 230 | Expected: 2 passed; no type errors. |
| 231 | |
| 232 | - [ ] **Step 7: Commit** |
| 233 | |
| 234 | ```bash |
| 235 | git add packages/db apps/api/src/lib/github/refresh-tokens.ts |
| 236 | git commit -m "db: oauth_app_refresh_token, and the library that issues and spends refresh tokens |
| 237 | ``` |
| 238 | |
| 239 | --- |
| 240 | |
| 241 | ### Task 2: The "Expire user authorization tokens" switch on OAuth apps (API) |
| 242 | |
| 243 | **Files:** |
| 244 | - Modify: `apps/api/src/lib/github/oauth-apps.ts` |
| 245 | - Modify: `apps/api/src/resources/oauth-apps/dto/oauth-app.dto.ts` |
| 246 | - Modify: `apps/api/src/resources/oauth-apps/oauth-apps.service.ts` (`create`, |
| 247 | - Test: `apps/api/test/oauth-apps.e2e-spec.ts` |
| 248 | |
| 249 | **Interfaces:** |
| 250 | - Produces: `OauthApp.expireUserTokens: boolean`; `oauthAppColumns.expireUserTokens`; DTO field |
| 251 | |
| 252 | - [ ] **Step 1: Write the failing e2e test** (in |
| 253 | |
| 254 | ```ts |
| 255 | it('turns expiring user tokens on and off, off by default', |
| 256 | const { body |
| 257 | expect(body.expireUserTokens |
| 258 | const on = |
| 259 | expect(on.body |
| 260 | const created = |
| 261 | expect(created.body |
| 262 | await api().patch |
| 263 | await api().patch |
| 264 | }); |
| 265 | ``` |
| 266 | |
| 267 | - [ ] **Step 2: Run it to see it fail** |
| 268 | |
| 269 | Run: `E2E test/oauth-apps.e2e-spec.ts -t "expiring user tokens"` |
| 270 | Expected: FAIL, `expireUserTokens` is `undefined`. |
| 271 | |
| 272 | - [ ] **Step 3: Add the column and the type** |
| 273 | |
| 274 | In `apps/api/src/lib/github/oauth-apps.ts`, add to `OauthApp` after ` |
| 275 | |
| 276 | ```ts |
| 277 | /** GitHub's "Expire user authorization tokens": keys last 8 hours and come with a refresh token. */ |
| 278 | expireUserTokens: boolean; |
| 279 | ``` |
| 280 | |
| 281 | Add to `oauthAppColumns` after `deviceFlowEnabled`: |
| 282 | |
| 283 | ```ts |
| 284 | expireUserTokens: sql<boolean |
| 285 | ``` |
| 286 | |
| 287 | Add `expireUserTokens: oauthAppColumns.expireUserTokens,` to the `select` in ` |
| 288 | |
| 289 | - [ ] **Step 4: Add the DTO fields** |
| 290 | |
| 291 | In `CreateOauthAppDTO`, after `deviceFlowEnabled`: |
| 292 | |
| 293 | ```ts |
| 294 | @ApiProperty({ |
| 295 | required: false, |
| 296 | default: false, |
| 297 | description: 'Access tokens expire after 8 hours and come with a refresh token that lasts 6 months, as GitHub\'s "Expire user authorization tokens".' |
| 298 | }) |
| 299 | @IsOptional() |
| 300 | @IsBoolean() |
| 301 | expireUserTokens?: boolean; |
| 302 | ``` |
| 303 | |
| 304 | In `OauthAppDTO`, after `deviceFlowEnabled`: |
| 305 | |
| 306 | ```ts |
| 307 | @ApiProperty() |
| 308 | expireUserTokens: boolean; |
| 309 | ``` |
| 310 | |
| 311 | - [ ] **Step 5: Store the switch** |
| 312 | |
| 313 | In `oauth-apps.service.ts`, `create` passes ` |
| 314 | |
| 315 | - [ ] **Step 6: Run the test** |
| 316 | |
| 317 | Run: `E2E test/oauth-apps.e2e-spec.ts` |
| 318 | Expected: all pass. |
| 319 | |
| 320 | - [ ] **Step 7: Commit** |
| 321 | |
| 322 | ```bash |
| 323 | git add apps/api/src/lib/github/oauth-apps.ts apps/api/src/resources/oauth-apps |
| 324 | git commit -m "api: OAuth apps can turn on expiring user tokens |
| 325 | ``` |
| 326 | |
| 327 | --- |
| 328 | |
| 329 | ### Task 3: Both grants issue expiring keys and refresh tokens |
| 330 | |
| 331 | **Files:** |
| 332 | - Modify: `apps/api/src/lib/auth.ts` (api-key plugin options) |
| 333 | - Modify: `apps/api/src/github/oauth/oauth.controller.ts` (`issueKey` becomes |
| 334 | - Create: `apps/api/test/github-oauth-refresh.e2e-spec.ts` |
| 335 | |
| 336 | **Interfaces:** |
| 337 | - Consumes: `ACCESS_TOKEN_EXPIRES_IN`, `REFRESH_TOKEN_EXPIRES_IN`, |
| 338 | - Produces: `OauthController.mint(app: OauthApp, userId: string, scopes: string[]): Promise<Record<string, string | number>>`, used by the refresh grant in Task 4. |
| 339 | |
| 340 | - [ ] **Step 1: Write the failing e2e spec** |
| 341 | |
| 342 | ```ts |
| 343 | // apps/api/test/github-oauth-refresh.e2e-spec.ts |
| 344 | import { type Database, |
| 345 | import type { INestApplication |
| 346 | import { and, desc |
| 347 | import request from 'supertest |
| 348 | import { afterAll, beforeAll |
| 349 | |
| 350 | import { DATABASE } |
| 351 | import { hashRefreshToken } |
| 352 | import { hasBackends, signUp |
| 353 | |
| 354 | const CALLBACK = ' |
| 355 | const DEVICE_GRANT = ' |
| 356 | |
| 357 | describe.skipIf(!hasBackends |
| 358 | let app: |
| 359 | let db: |
| 360 | let owner: |
| 361 | let clientId: |
| 362 | let clientSecret: |
| 363 | const api = |
| 364 | const form = |
| 365 | const register = |
| 366 | const codeFor = |
| 367 | const first = |
| 368 | const plugin = |
| 369 | let callback = |
| 370 | if (callback.searchParams |
| 371 | return callback.searchParams |
| 372 | }; |
| 373 | const exchange = |
| 374 | const refresh = |
| 375 | const newestKey = |
| 376 | |
| 377 | beforeAll(async () |
| 378 | ({ app } |
| 379 | db = app. |
| 380 | owner = await |
| 381 | ({ clientId, clientSecret |
| 382 | }); |
| 383 | |
| 384 | afterAll(async () |
| 385 | await app?.close |
| 386 | }); |
| 387 | |
| 388 | describe('issuing', |
| 389 | it('adds no expiry and no refresh token while the switch is off', |
| 390 | const off = |
| 391 | const token = |
| 392 | expect(token).toEqual |
| 393 | expect((await newestKey |
| 394 | }); |
| 395 | |
| 396 | it("answers the code grant with GitHub's expiring token fields, form-encoded and JSON", |
| 397 | const token = |
| 398 | expect(token).toEqual |
| 399 | const json = |
| 400 | expect(json).toMatchObject |
| 401 | const [row] |
| 402 | expect(row?.expiresAt |
| 403 | }); |
| 404 | |
| 405 | it('gives the key an 8-hour expiry that the token guard enforces', |
| 406 | const token = |
| 407 | const key = |
| 408 | expect((key?.expiresAt |
| 409 | await api().get |
| 410 | await db.update |
| 411 | await api().get |
| 412 | }); |
| 413 | |
| 414 | it('answers the device grant with a refresh token too', |
| 415 | const device = |
| 416 | const code = |
| 417 | await api().get |
| 418 | await api().post |
| 419 | const token = |
| 420 | expect(token).toMatchObject |
| 421 | }); |
| 422 | }); |
| 423 | }); |
| 424 | ``` |
| 425 | |
| 426 | - [ ] **Step 2: Run it to see it fail** |
| 427 | |
| 428 | Run: `E2E test/github-oauth-refresh.e2e-spec.ts` |
| 429 | Expected: the switch-off test passes; the three switch-on tests FAIL with no `expires_in`. |
| 430 | |
| 431 | - [ ] **Step 3: Allow 8-hour keys** |
| 432 | |
| 433 | In `apps/api/src/lib/auth.ts`, import `ACCESS_TOKEN_EXPIRES_IN` from ` |
| 434 | |
| 435 | ```ts |
| 436 | // In days; OAuth apps with expiring user tokens get keys that last 8 hours. |
| 437 | keyExpiration: { minExpiresIn: ACCESS_TOKEN_EXPIRES_IN |
| 438 | ``` |
| 439 | |
| 440 | - [ ] **Step 4: Replace `issueKey |
| 441 | |
| 442 | In `oauth.controller.ts`, import `ACCESS_TOKEN_EXPIRES_IN, REFRESH_TOKEN_EXPIRES_IN, issueRefreshToken` from |
| 443 | |
| 444 | ```ts |
| 445 | private async issueKey |
| 446 | req: Request, |
| 447 | res: Response, |
| 448 | app: OauthApp, |
| 449 | userId: string, |
| 450 | scope: string, |
| 451 | ) { |
| 452 | sendOAuth(req, res |
| 453 | } |
| 454 | |
| 455 | /** A key for the app's user, and with expiring user tokens on, its expiry and a refresh token, in GitHub's token response. */ |
| 456 | private async mint |
| 457 | const { key, |
| 458 | body: { |
| 459 | userId, |
| 460 | name: app.name |
| 461 | permissions: { scopes |
| 462 | metadata: { oauthClientId: app |
| 463 | ...(app.expireUserTokens |
| 464 | }, |
| 465 | }); |
| 466 | const token = |
| 467 | access_token: key, |
| 468 | token_type: 'bearer', |
| 469 | scope: scopes.join |
| 470 | }; |
| 471 | if (!app.expireUserTokens |
| 472 | return { |
| 473 | ...token, |
| 474 | expires_in: ACCESS_TOKEN_EXPIRES_IN, |
| 475 | refresh_token: await issueRefreshToken |
| 476 | userId, |
| 477 | scopes, |
| 478 | accessKeyId: id, |
| 479 | }), |
| 480 | refresh_token_expires_in: REFRESH_TOKEN_EXPIRES_IN, |
| 481 | }; |
| 482 | } |
| 483 | ``` |
| 484 | |
| 485 | - [ ] **Step 5: Run the tests** |
| 486 | |
| 487 | Run: `E2E test/github-oauth-refresh.e2e-spec.ts test/github-oauth.e2e-spec.ts test/github-oauth-web.e2e-spec.ts` |
| 488 | Expected: all pass. The two existing specs prove the switch-off response is unchanged. |
| 489 | |
| 490 | - [ ] **Step 6: Commit** |
| 491 | |
| 492 | ```bash |
| 493 | git add apps/api/src/lib/auth.ts apps/api/src/github/oauth/oauth.controller.ts |
| 494 | git commit -m "api: expiring user tokens get an 8-hour key and a refresh token from both grants |
| 495 | ``` |
| 496 | |
| 497 | --- |
| 498 | |
| 499 | ### Task 4: The refresh grant |
| 500 | |
| 501 | **Files:** |
| 502 | - Modify: `apps/api/src/github/oauth/oauth.controller.ts` |
| 503 | - Test: `apps/api/test/github-oauth-refresh.e2e-spec.ts` |
| 504 | |
| 505 | **Interfaces:** |
| 506 | - Consumes: `spendRefreshToken` (Task 1); `mint` (Task 3). |
| 507 | |
| 508 | - [ ] **Step 1: Write the failing tests** (append a |
| 509 | |
| 510 | ```ts |
| 511 | describe('refreshing', |
| 512 | const pair = |
| 513 | |
| 514 | it('trades a refresh token for a new pair with the same scopes, and retires the old pair', |
| 515 | const first = |
| 516 | const next = |
| 517 | expect(next).toEqual |
| 518 | expect(next.access_token |
| 519 | const user = |
| 520 | expect(user.headers |
| 521 | await api().get |
| 522 | expect(form((await |
| 523 | }); |
| 524 | |
| 525 | it('answers JSON when asked', |
| 526 | const response = |
| 527 | expect(response.body |
| 528 | }); |
| 529 | |
| 530 | it('lets only one of two concurrent refreshes with the same token win', |
| 531 | const { refresh_token |
| 532 | const answers = |
| 533 | const tokens = |
| 534 | expect(tokens.filter |
| 535 | expect(tokens.filter |
| 536 | }); |
| 537 | |
| 538 | it('refuses an expired refresh token and an unknown one', |
| 539 | const { refresh_token |
| 540 | await db.update |
| 541 | expect(form((await |
| 542 | expect(form((await |
| 543 | }); |
| 544 | |
| 545 | it("refuses another app's refresh token and leaves it working for its own app", |
| 546 | const { refresh_token |
| 547 | const other = |
| 548 | expect(form((await |
| 549 | expect(form((await |
| 550 | }); |
| 551 | |
| 552 | it('refuses a wrong or missing client secret, and an unknown client, and spends nothing', |
| 553 | const { refresh_token |
| 554 | expect(form((await |
| 555 | const missing = |
| 556 | expect(form(missing |
| 557 | expect(form((await |
| 558 | expect(form((await |
| 559 | }); |
| 560 | |
| 561 | it('moves to keys that never expire once the switch is turned off', |
| 562 | const toggled = |
| 563 | const first = |
| 564 | await api().patch |
| 565 | const next = |
| 566 | expect(next).toEqual |
| 567 | const keys = |
| 568 | expect(keys).toEqual |
| 569 | expect(form((await |
| 570 | }); |
| 571 | }); |
| 572 | ``` |
| 573 | |
| 574 | - [ ] **Step 2: Run them to see them fail** |
| 575 | |
| 576 | Run: `E2E test/github-oauth-refresh.e2e-spec.ts -t refreshing` |
| 577 | Expected: FAIL; the endpoint answers `400 unsupported_grant_type`, so `.expect(200)` fails. |
| 578 | |
| 579 | - [ ] **Step 3: Check how the plugin answers an introspection with bad credentials** |
| 580 | |
| 581 | The refresh grant checks the client's secret through `auth.api.oauth2Introspect`, the plugin's own check, rather than re-hashing secrets. Confirm the plugin throws an `APIError` whose body has |
| 582 | |
| 583 | - [ ] **Step 4: Implement the grant** |
| 584 | |
| 585 | In `accessToken`'s body type add `refresh_token?: string;`. Before the ` |
| 586 | |
| 587 | ```ts |
| 588 | if (body.grant_type |
| 589 | if (!app) |
| 590 | return sendOAuth(req |
| 591 | error: 'incorrect_client_credentials', |
| 592 | }); |
| 593 | return this.refreshGrant |
| 594 | client_secret: body.client_secret |
| 595 | refresh_token: body.refresh_token |
| 596 | }); |
| 597 | } |
| 598 | ``` |
| 599 | |
| 600 | Update the method's doc comment to: `/** Dispatches as GitHub does: the device grant and the refresh grant when \`grant_type\` names them, the web flow's code exchange when a \`code |
| 601 | |
| 602 | Import `spendRefreshToken` and add: |
| 603 | |
| 604 | ```ts |
| 605 | /** GitHub's refresh for expiring user tokens: the token is spent, its key revoked, and a new pair minted under the app's current switch. */ |
| 606 | private async refreshGrant |
| 607 | req: Request, |
| 608 | res: Response, |
| 609 | app: OauthApp, |
| 610 | body: { client_secret?: string |
| 611 | ) { |
| 612 | if (!(await |
| 613 | return sendOAuth(req |
| 614 | error: 'incorrect_client_credentials', |
| 615 | }); |
| 616 | const grant = |
| 617 | this.db, |
| 618 | app.clientId, |
| 619 | body.refresh_token, |
| 620 | ); |
| 621 | if (!grant) |
| 622 | return sendOAuth(req |
| 623 | error: 'bad_refresh_token', |
| 624 | error_description: 'The refresh token passed is incorrect or expired.', |
| 625 | }); |
| 626 | await this.db |
| 627 | .delete(schema.apikey |
| 628 | .where(eq(schema |
| 629 | sendOAuth(req, res |
| 630 | } |
| 631 | |
| 632 | /** The plugin checks the secret its own way; introspecting a token it does not hold costs nothing and fails only on bad credentials. */ |
| 633 | private async authenticates |
| 634 | try { |
| 635 | await this.auth |
| 636 | body: { |
| 637 | client_id: app.clientId |
| 638 | client_secret: clientSecret, |
| 639 | token: 'ghost-client-check', |
| 640 | token_type_hint: 'access_token', |
| 641 | }, |
| 642 | }); |
| 643 | return true; |
| 644 | } catch (error) |
| 645 | if (!isAPIError(error |
| 646 | if ((error.body |
| 647 | return false; |
| 648 | throw error; |
| 649 | } |
| 650 | } |
| 651 | ``` |
| 652 | |
| 653 | Adjust `authenticates` to what Step 3 found; every branch kept must be reached by a test in Step 1. |
| 654 | |
| 655 | - [ ] **Step 5: Run the tests** |
| 656 | |
| 657 | Run: `E2E test/github-oauth-refresh.e2e-spec.ts test/github-oauth-web.e2e-spec.ts` |
| 658 | Expected: all pass. |
| 659 | |
| 660 | - [ ] **Step 6: Commit** |
| 661 | |
| 662 | ```bash |
| 663 | git add apps/api/src/github/oauth/oauth.controller.ts apps/api/test/github-oauth-refresh.e2e-spec.ts |
| 664 | git commit -m "api: refresh expiring user tokens with grant_type=refresh_token |
| 665 | ``` |
| 666 | |
| 667 | --- |
| 668 | |
| 669 | ### Task 5: Authorized apps read refresh tokens |
| 670 | |
| 671 | **Files:** |
| 672 | - Modify: `apps/api/src/resources/oauth-apps/oauth-apps.service.ts` (`listAuthorized`, |
| 673 | - Modify: `apps/api/src/resources/oauth-apps/dto/oauth-app.dto.ts` (`AuthorizedOauthAppDTO.scopes` |
| 674 | - Test: `apps/api/test/oauth-apps.e2e-spec.ts` (`describe('authorized apps')`) |
| 675 | |
| 676 | - [ ] **Step 1: Write the failing test** (inside |
| 677 | |
| 678 | ```ts |
| 679 | it('keeps an app whose key expired but whose refresh token lives, and revoking it deletes the refresh token', |
| 680 | const a = |
| 681 | await db.insert |
| 682 | const list = |
| 683 | expect(list.body |
| 684 | await api().delete |
| 685 | expect(await db |
| 686 | await api().delete |
| 687 | }); |
| 688 | |
| 689 | it('merges the scopes of keys and refresh tokens', |
| 690 | const a = |
| 691 | await key(`mk |
| 692 | await db.insert |
| 693 | const list = |
| 694 | expect(list.body |
| 695 | }); |
| 696 | ``` |
| 697 | |
| 698 | - [ ] **Step 2: Run them to see them fail** |
| 699 | |
| 700 | Run: `E2E test/oauth-apps.e2e-spec.ts -t "authorized apps"` |
| 701 | Expected: the first FAILS (app missing from the list); the second FAILS (`scopes: ['repo']`). |
| 702 | |
| 703 | - [ ] **Step 3: Rewrite `listAuthorized |
| 704 | |
| 705 | Import `unionAll` from `drizzle-orm/pg-core`. Replace the body of ` |
| 706 | |
| 707 | ```ts |
| 708 | const keys = |
| 709 | .select({ |
| 710 | clientId: sql<string |
| 711 | scopes: sql<unknown |
| 712 | createdAt: sql<Date |
| 713 | lastRequest: sql<Date |
| 714 | }) |
| 715 | .from(schema.apikey |
| 716 | .where(eq(schema |
| 717 | // An expiring key may already be deleted while its refresh token still lets the app back in. |
| 718 | const refreshTokens = |
| 719 | .select({ |
| 720 | clientId: sql<string |
| 721 | scopes: sql<unknown |
| 722 | createdAt: sql<Date |
| 723 | lastRequest: sql<Date |
| 724 | }) |
| 725 | .from(schema.oauthAppRefreshToken |
| 726 | .where(eq(schema |
| 727 | const grants = |
| 728 | const apps = |
| 729 | .select({ |
| 730 | clientId: oauthAppColumns.clientId |
| 731 | name: oauthAppColumns.name |
| 732 | logoUrl: oauthAppColumns.logoUrl |
| 733 | // jsonb_agg runs over the outer group; Postgres allows that only in a select list, hence the one-row derived table. |
| 734 | scopes: sql<string |
| 735 | authorizedAt: isoTimestamp(sql |
| 736 | lastUsedAt: sql<string |
| 737 | }) |
| 738 | .from(grants) |
| 739 | .innerJoin(schema.oauthClient |
| 740 | .groupBy(schema.oauthClient |
| 741 | .orderBy(oauthAppColumns.name |
| 742 | return { apps |
| 743 | ``` |
| 744 | |
| 745 | Update the doc comment to `/** Apps holding a key or a refresh token for this user, gh included. */`. Run biome format on the file afterwards. |
| 746 | |
| 747 | - [ ] **Step 4: Revoke refresh tokens too** |
| 748 | |
| 749 | Replace the start of `revokeAuthorized`: |
| 750 | |
| 751 | ```ts |
| 752 | /** Deletes this user's keys and refresh tokens for the app and their consent, so the app asks again next time. Other users' grants stay. */ |
| 753 | async revokeAuthorized(userId |
| 754 | const keys = await |
| 755 | .delete(schema.apikey) |
| 756 | .where(and(eq(schema.apikey.referenceId, |
| 757 | .returning({ id: |
| 758 | const refreshTokens = await |
| 759 | .delete(schema.oauthAppRefreshToken) |
| 760 | .where(and(eq(schema.oauthAppRefreshToken.userId, |
| 761 | .returning({ id: |
| 762 | if (keys.length === 0 && refreshTokens.length === 0) |
| 763 | throw new AuthorizedOauthAppNotFoundError(); |
| 764 | ``` |
| 765 | |
| 766 | The consent delete that follows is unchanged. |
| 767 | |
| 768 | In `AuthorizedOauthAppDTO.scopes`, change the description to `'Every scope the app holds across its keys and refresh tokens for this account.'`. |
| 769 | |
| 770 | - [ ] **Step 5: Add an end-to-end revoke over HTTP** (in |
| 771 | |
| 772 | ```ts |
| 773 | it('stops refreshing once the user revokes the app', |
| 774 | const revoked = |
| 775 | const first = |
| 776 | await api().delete |
| 777 | expect(form((await |
| 778 | }); |
| 779 | ``` |
| 780 | |
| 781 | - [ ] **Step 6: Run the tests** |
| 782 | |
| 783 | Run: `E2E test/oauth-apps.e2e-spec.ts test/github-oauth-refresh.e2e-spec.ts` |
| 784 | Expected: all pass, the existing authorized-apps tests included. |
| 785 | |
| 786 | - [ ] **Step 7: Commit** |
| 787 | |
| 788 | ```bash |
| 789 | git add apps/api/src/resources/oauth-apps apps/api/test/oauth-apps.e2e-spec.ts |
| 790 | git commit -m "api: authorized apps count refresh tokens, and revoking an app deletes them |
| 791 | ``` |
| 792 | |
| 793 | --- |
| 794 | |
| 795 | ### Task 6: The switch in the web app, and the regenerated client |
| 796 | |
| 797 | **Files:** |
| 798 | - Modify: `apps/api/openapi.json`, `apps/web/src/lib/api/v1.d.ts` (generated) |
| 799 | - Modify: `apps/web/src/components/oauth-apps/oauth-app-fields.tsx` |
| 800 | - Modify: `apps/web/src/components/oauth-apps/actions.ts` |
| 801 | - Modify: `apps/web/src/components/oauth-apps/oauth-app-settings-card.tsx` |
| 802 | |
| 803 | - [ ] **Step 1: Regenerate the OpenAPI document and the web client** |
| 804 | |
| 805 | Run at the repository root: `bun run openapi` |
| 806 | Expected: `openapi.json` and `v1.d.ts` gain ` |
| 807 | |
| 808 | - [ ] **Step 2: Add the field to the form values** |
| 809 | |
| 810 | In `oauth-app-fields.tsx`, add `expireUserTokens: boolean;` to ` |
| 811 | |
| 812 | - [ ] **Step 3: Add the switch** |
| 813 | |
| 814 | In `oauth-app-fields.tsx`, after the device flow `</FieldSet>`, add: |
| 815 | |
| 816 | ```tsx |
| 817 | <FieldSet> |
| 818 | <FieldLegend>Tokens</FieldLegend |
| 819 | <Field orientation= |
| 820 | <FieldContent> |
| 821 | <FieldLabel htmlFor |
| 822 | Expire user authorization tokens |
| 823 | </FieldLabel> |
| 824 | <FieldDescription> |
| 825 | Access tokens stop working after 8 hours, and come with a refresh token that lasts 6 months for getting a new one. Turning it off affects only tokens issued afterwards. |
| 826 | </FieldDescription> |
| 827 | </FieldContent> |
| 828 | <Switch |
| 829 | id="oauth-app-expire-user-tokens" |
| 830 | checked={values.expireUserTokens |
| 831 | disabled={disabled} |
| 832 | onCheckedChange={(checked) |
| 833 | /> |
| 834 | </Field> |
| 835 | </FieldSet> |
| 836 | ``` |
| 837 | |
| 838 | Keep the description string on one line (code standard 5), even if the formatter leaves it long. |
| 839 | |
| 840 | - [ ] **Step 4: Type check, lint, and look at it** |
| 841 | |
| 842 | Run: `bun run check-types` at the root, then `cd apps/web && bunx biome check src/components/oauth-apps`. |
| 843 | Expected: clean. Open the OAuth app form in the running web app, toggle the switch, save, and reload to see it kept. |
| 844 | |
| 845 | - [ ] **Step 5: Commit** |
| 846 | |
| 847 | ```bash |
| 848 | git add apps/api/openapi.json apps/web/src/lib/api/v1.d.ts |
| 849 | git commit -m "web: Expire user authorization tokens switch on OAuth apps |
| 850 | ``` |
| 851 | |
| 852 | --- |
| 853 | |
| 854 | ### Task 7: Docs and the ADR |
| 855 | |
| 856 | **Files:** |
| 857 | - Create: `apps/docs/content/docs/oauth-apps/refreshing-tokens.mdx` |
| 858 | - Modify: `apps/docs/content/docs/oauth-apps/meta.json` |
| 859 | - Modify: `apps/docs/content/docs/github-cli/index.mdx` |
| 860 | - Modify: `docs/0040-github-compatibility-is-a-translation-layer.md` |
| 861 | |
| 862 | - [ ] **Step 1: Write the guide** |
| 863 | |
| 864 | Create `refreshing-tokens.mdx`, matching the frontmatter of its siblings (read `exchanging-the-code.mdx` first and copy its frontmatter keys). Sections, prose in full sentences, one line per paragraph: |
| 865 | |
| 866 | 1. Intro: what the switch does and that it copies GitHub's expiring user tokens for GitHub Apps. |
| 867 | 2. "Turning it on": Settings → Developer settings → OAuth apps → the app → "Expire user authorization tokens"; or `expireUserTokens: true` on `PATCH /api/oauth-apps/:clientId` |
| 868 | 3. "The token response": the JSON example from the spec's "Issuing tokens", and a table of `expires_in` (28800, seconds) and `refresh_token_expires_in` |
| 869 | 4. "Refreshing": a `curl` example posting `client_id`, |
| 870 | 5. "Errors": the spec's error table. |
| 871 | 6. "Rotation": each refresh returns a new pair; the old refresh token and the old access token stop working at once; using a refresh token twice fails. |
| 872 | 7. "Turning it off": tokens already issued keep working until they expire; their next refresh returns a token that never expires and no refresh token. |
| 873 | 8. "Revoking": revoking the app under Authorized apps deletes its refresh tokens; regenerating the client secret does not, but the next refresh needs the new secret. |
| 874 | |
| 875 | Add `"refreshing-tokens"` to `meta.json` after ` |
| 876 | |
| 877 | - [ ] **Step 2: Update the gh compatibility page** |
| 878 | |
| 879 | In `github-cli/index.mdx` under "Known differences", after the OAuth apps client-secret line, add: |
| 880 | |
| 881 | ```md |
| 882 | - OAuth apps can turn on expiring user tokens with refresh tokens. GitHub offers them only to GitHub Apps. `gh`'s built-in app keeps tokens that never expire. |
| 883 | ``` |
| 884 | |
| 885 | - [ ] **Step 3: Add the ADR addendum** |
| 886 | |
| 887 | Append to `docs/0040-github-compatibility-is-a-translation-layer.md`: |
| 888 | |
| 889 | ```md |
| 890 | ## Addendum: expiring user tokens and refresh tokens |
| 891 | |
| 892 | An OAuth app can turn on GitHub's "Expire user authorization tokens", stored as `oauth_client.metadata.expireUserTokens`. Its grants then mint a `ghost_pat_` key with an 8-hour expiry, which the api-key plugin enforces, and a |
| 893 | |
| 894 | Refresh tokens live in `oauth_app_refresh_token`, a Ghost table holding the token's sha256. Its `access_key_id` has no foreign key: the api-key plugin deletes expired keys on its own, and a cascade would take the refresh token with them. For the same reason, Authorized apps lists an app when the user holds a key or a refresh token for it, and revoking deletes both. |
| 895 | |
| 896 | Rejected: |
| 897 | |
| 898 | - The plugin's `oauth_refresh_token` table: its rows belong to the plugin's access tokens and sessions, which Ghost revokes right after the code exchange. |
| 899 | - Refresh tokens as API keys with another prefix: the token guard would accept them as access tokens unless every check filtered them out. |
| 900 | ``` |
| 901 | |
| 902 | - [ ] **Step 4: Build the docs** |
| 903 | |
| 904 | Run: `cd apps/docs && bun run build` |
| 905 | Expected: builds; the new page is in the sidebar under OAuth apps. |
| 906 | |
| 907 | - [ ] **Step 5: Commit** |
| 908 | |
| 909 | ```bash |
| 910 | git add apps/docs docs/0040-github-compatibility-is-a-translation-layer.md |
| 911 | git commit -m "docs: refreshing expiring user tokens |
| 912 | ``` |
| 913 | |
| 914 | --- |
| 915 | |
| 916 | ### Task 8: Whole-branch check |
| 917 | |
| 918 | - [ ] **Step 1:** |
| 919 | - [ ] **Step 2:** Full API e2e with |
| 920 | - [ ] **Step 3:** |
| 921 | - [ ] **Step 4:** Commit the spec and plan with the code: |
| 922 | |
| 923 | ## Changes after the branch review |
| 924 | |
| 925 | - Refresh and revoke take one advisory lock per grant (`lockGrant` in `refresh-tokens.ts`); the refresh grant spends, mints and deletes the old key in one transaction, minting before the delete. |
| 926 | - Authorized apps ignores expired refresh rows. |
| 927 | - A `refresh_token` or `client_secret` that is not a string answers an OAuth error instead of a 500. |