# Expiring OAuth tokens and refresh tokens Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** OAuth apps can opt into GitHub's expiring user tokens: an 8-hour `ghost_pat_` key plus a 6-month `ghost_rt_` refresh token, refreshed with `grant_type=refresh_token` on `POST /login/oauth/access_token`.

**Architecture:** The access token stays an api-key plugin key, created with `expiresIn` when the app's `expireUserTokens` switch is on. Refresh tokens live in a Ghost-owned table keyed by their sha256. The controller's existing `issueKey` grows into one `mint` that both grants and the refresh grant call. Authorized apps read refresh rows as well as keys, because the plugin deletes expired keys on its own.

**Tech Stack:** NestJS, Drizzle (Postgres), Better Auth `apiKey` + `@better-auth/oauth-provider`, vitest + supertest e2e, Next.js web app, Fumadocs.

**Spec:** `docs/superpowers/specs/2026-10-10-oauth-refresh-tokens-design.md`

## Global Constraints

- Follow `docs/code-standards.md`: no `to<Format>` reshaping, no concatenated or wrapped strings or comments, comments say why only, pure functions under `lib/`, every branch tested.
- SQL migrations are generated with `bun db:generate` at the repository root. Never write or edit a `.sql` file by hand.
- Access token lifetime `28800` seconds; refresh token lifetime `15897600` seconds. Fixed, not configurable.
- Refresh token prefix `ghost_rt_`; access token prefix stays `ghost_pat_`.
- OAuth errors answer HTTP 200 with `error` in the body: `bad_refresh_token`, `incorrect_client_credentials`.
- Responses go through `sendOAuth`: form-encoded by default, JSON on `Accept: application/json`.
- 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` per command, never export it.
- Commits carry no `Co-Authored-By` trailer.

## Review Focus

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`.
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.
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.
4. Refresh with a missing `client_secret`: `incorrect_client_credentials`, never a new pair. Pinned in Task 4.
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.

---

## File map

| File | Responsibility |
| --- | --- |
| `packages/db/src/schema/oauth-app-refresh-token.ts` (new) | `oauthAppRefreshToken` table |
| `packages/db/src/schema/index.ts` | export it |
| `packages/db/drizzle/*` | generated by `bun db:generate` |
| `apps/api/src/lib/github/refresh-tokens.ts` (new) | lifetimes, token generation, hashing, issue and spend |
| `apps/api/src/lib/github/refresh-tokens.spec.ts` (new) | unit tests for generation and hashing |
| `apps/api/src/lib/github/oauth-apps.ts` | `expireUserTokens` on `OauthApp` and `oauthAppColumns` |
| `apps/api/src/resources/oauth-apps/dto/oauth-app.dto.ts` | `expireUserTokens` on create/update/read DTOs |
| `apps/api/src/resources/oauth-apps/oauth-apps.service.ts` | store the switch; authorized list and revoke read refresh rows |
| `apps/api/src/lib/auth.ts` | `keyExpiration.minExpiresIn` |
| `apps/api/src/github/oauth/oauth.controller.ts` | `mint`, refresh grant |
| `apps/api/test/github-oauth-refresh.e2e-spec.ts` (new) | e2e for issuing and refreshing |
| `apps/api/test/oauth-apps.e2e-spec.ts` | e2e for the switch and authorized apps |
| `apps/web/src/components/oauth-apps/{oauth-app-fields.tsx,actions.ts,oauth-app-settings-card.tsx}` | the switch |
| `apps/api/openapi.json`, `apps/web/src/lib/api/v1.d.ts` | regenerated |
| `apps/docs/content/docs/oauth-apps/refreshing-tokens.mdx` (new), `meta.json`, `github-cli/index.mdx` | docs |
| `docs/0040-github-compatibility-is-a-translation-layer.md` | addendum |

## Running tests

Unit: `cd apps/api && bunx vitest run src/lib/github/refresh-tokens.spec.ts`

E2E backends (once per session):

```bash
docker run -d --rm --name ghost-rt-pg -e POSTGRES_PASSWORD=test -p 55433:5432 postgres:16-alpine
docker run -d --rm --name ghost-rt-s3 -e RUSTFS_ACCESS_KEY=ghost -e RUSTFS_SECRET_KEY=ghostsecret -e RUSTFS_VOLUMES=/data -e RUSTFS_ADDRESS=0.0.0.0:9000 -p 59000:9000 rustfs/rustfs:latest
```

E2E run (`E2E` below):

```bash
cd apps/api && TEST_DATABASE_URL=postgres://postgres:test@localhost:55433/postgres TEST_S3_ENDPOINT=http://localhost:59000 bunx vitest run --config ./vitest.config.e2e.ts --no-file-parallelism <files>
```

---

### Task 1: Refresh-token table and library

**Files:**
- Create: `packages/db/src/schema/oauth-app-refresh-token.ts`
- Modify: `packages/db/src/schema/index.ts`
- Generated: `packages/db/drizzle/<n>_*.sql`, `packages/db/drizzle/meta/*`
- Create: `apps/api/src/lib/github/refresh-tokens.ts`
- Test: `apps/api/src/lib/github/refresh-tokens.spec.ts`

**Interfaces:**
- Produces: `oauthAppRefreshToken` (table export); `ACCESS_TOKEN_EXPIRES_IN = 28800`; `REFRESH_TOKEN_EXPIRES_IN = 15897600`; `newRefreshToken(): string`; `hashRefreshToken(token: string): string`; `type RefreshGrant = { userId: string; scopes: string[]; accessKeyId: string }`; `issueRefreshToken(db: Database, clientId: string, grant: RefreshGrant): Promise<string>`; `spendRefreshToken(db: Database, clientId: string, token: string): Promise<RefreshGrant | null>`.

- [ ] **Step 1: Write the table**

```ts
// packages/db/src/schema/oauth-app-refresh-token.ts
import { createId } from "@paralleldrive/cuid2";
import { index, pgTable, text, timestamp } from "drizzle-orm/pg-core";

import { user } from "./auth.js";
import { oauthClient } from "./oauth.js";

/** Ghost's refresh tokens for OAuth apps with expiring user tokens. Only the token's sha256 is stored. */
export const oauthAppRefreshToken = pgTable(
  "oauth_app_refresh_token",
  {
    id: text()
      .primaryKey()
      .$defaultFn(() => `ort_${createId()}`),
    tokenHash: text().notNull().unique(),
    clientId: text()
      .notNull()
      .references(() => oauthClient.clientId, { onDelete: "cascade" }),
    userId: text()
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    scopes: text().array().notNull(),
    // No foreign key: the api-key plugin deletes expired keys, and the refresh token must outlive its access key.
    accessKeyId: text().notNull(),
    expiresAt: timestamp({ withTimezone: true }).notNull(),
    createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [index("oauth_app_refresh_token_client_user_idx").on(t.clientId, t.userId)],
);
```

Add `export * from "./oauth-app-refresh-token.js";` to `packages/db/src/schema/index.ts` after the `oauth.js` line.

- [ ] **Step 2: Generate the migration**

Run at the repository root: `bun db:generate`
Expected: one new `packages/db/drizzle/00NN_*.sql` creating `oauth_app_refresh_token` with snake_case columns, the unique index on `token_hash`, two foreign keys with `ON DELETE cascade`, and `oauth_app_refresh_token_client_user_idx`. If anything is wrong, fix the schema file and regenerate; do not edit the SQL.

- [ ] **Step 3: Write the failing unit test**

```ts
// apps/api/src/lib/github/refresh-tokens.spec.ts
import { describe, expect, it } from 'vitest';

import { hashRefreshToken, newRefreshToken } from './refresh-tokens.js';

describe('refresh tokens', () => {
  it('are ghost_rt_ followed by 32 random bytes, base64url', () => {
    const token = newRefreshToken();
    expect(token).toMatch(/^ghost_rt_[A-Za-z0-9_-]{43}$/);
    expect(newRefreshToken()).not.toBe(token);
  });

  it('hash to a stable sha256 hex digest', () => {
    expect(hashRefreshToken('ghost_rt_x')).toBe(hashRefreshToken('ghost_rt_x'));
    expect(hashRefreshToken('ghost_rt_x')).toMatch(/^[0-9a-f]{64}$/);
    expect(hashRefreshToken('ghost_rt_y')).not.toBe(hashRefreshToken('ghost_rt_x'));
  });
});
```

- [ ] **Step 4: Run it to see it fail**

Run: `cd apps/api && bunx vitest run src/lib/github/refresh-tokens.spec.ts`
Expected: FAIL, cannot resolve `./refresh-tokens.js`.

- [ ] **Step 5: Write the library**

```ts
// apps/api/src/lib/github/refresh-tokens.ts
import { createHash, randomBytes } from 'node:crypto';

import { type Database, schema } from '@ghost/db';
import { and, eq } from 'drizzle-orm';

/** GitHub's lifetimes for expiring user tokens, in seconds. */
export const ACCESS_TOKEN_EXPIRES_IN = 28800;
export const REFRESH_TOKEN_EXPIRES_IN = 15897600;

/** What a refresh token re-grants: the user, the scopes they approved, and the key it was issued beside. */
export type RefreshGrant = {
  userId: string;
  scopes: string[];
  accessKeyId: string;
};

export const newRefreshToken = () =>
  `ghost_rt_${randomBytes(32).toString('base64url')}`;

export const hashRefreshToken = (token: string) =>
  createHash('sha256').update(token).digest('hex');

/** Stores a refresh token for the grant and returns it; only its hash is kept. */
export async function issueRefreshToken(
  db: Database,
  clientId: string,
  grant: RefreshGrant,
): Promise<string> {
  const token = newRefreshToken();
  await db.insert(schema.oauthAppRefreshToken).values({
    ...grant,
    clientId,
    tokenHash: hashRefreshToken(token),
    expiresAt: new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000),
  });
  return token;
}

/** 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. */
export async function spendRefreshToken(
  db: Database,
  clientId: string,
  token: string,
): Promise<RefreshGrant | null> {
  const table = schema.oauthAppRefreshToken;
  const [spent] = await db
    .delete(table)
    .where(
      and(
        eq(table.tokenHash, hashRefreshToken(token)),
        eq(table.clientId, clientId),
      ),
    )
    .returning({
      userId: table.userId,
      scopes: table.scopes,
      accessKeyId: table.accessKeyId,
      expiresAt: table.expiresAt,
    });
  if (!spent || spent.expiresAt <= new Date()) return null;
  return {
    userId: spent.userId,
    scopes: spent.scopes,
    accessKeyId: spent.accessKeyId,
  };
}
```

- [ ] **Step 6: Run the unit test and type check**

Run: `cd apps/api && bunx vitest run src/lib/github/refresh-tokens.spec.ts && bun run check-types`
Expected: 2 passed; no type errors.

- [ ] **Step 7: Commit**

```bash
git add packages/db apps/api/src/lib/github/refresh-tokens.ts apps/api/src/lib/github/refresh-tokens.spec.ts
git commit -m "db: oauth_app_refresh_token, and the library that issues and spends refresh tokens"
```

---

### Task 2: The "Expire user authorization tokens" switch on OAuth apps (API)

**Files:**
- Modify: `apps/api/src/lib/github/oauth-apps.ts`
- Modify: `apps/api/src/resources/oauth-apps/dto/oauth-app.dto.ts`
- Modify: `apps/api/src/resources/oauth-apps/oauth-apps.service.ts` (`create`, `update`, `setGhostFields`)
- Test: `apps/api/test/oauth-apps.e2e-spec.ts`

**Interfaces:**
- Produces: `OauthApp.expireUserTokens: boolean`; `oauthAppColumns.expireUserTokens`; DTO field `expireUserTokens` (create/update optional, read required); stored as `oauth_client.metadata.expireUserTokens`.

- [ ] **Step 1: Write the failing e2e test** (in `oauth-apps.e2e-spec.ts`, after "updates an app and turns its device flow on")

```ts
  it('turns expiring user tokens on and off, off by default', async () => {
    const { body } = await create(owner.cookie).expect(201);
    expect(body.expireUserTokens).toBe(false);
    const on = await api().patch(`/api/oauth-apps/${body.clientId}`).set('cookie', owner.cookie).send({ expireUserTokens: true }).expect(200);
    expect(on.body).toMatchObject({ expireUserTokens: true, deviceFlowEnabled: false, name: 'Scoped Tool' });
    const created = await create(owner.cookie, { expireUserTokens: true }).expect(201);
    expect(created.body.expireUserTokens).toBe(true);
    await api().patch(`/api/oauth-apps/${GH}`).set('cookie', owner.cookie).send({ expireUserTokens: true }).expect(403);
    await api().patch(`/api/oauth-apps/${body.clientId}`).set('cookie', owner.cookie).send({ expireUserTokens: 'yes' }).expect(400);
  });
```

- [ ] **Step 2: Run it to see it fail**

Run: `E2E test/oauth-apps.e2e-spec.ts -t "expiring user tokens"`
Expected: FAIL, `expireUserTokens` is `undefined`.

- [ ] **Step 3: Add the column and the type**

In `apps/api/src/lib/github/oauth-apps.ts`, add to `OauthApp` after `deviceFlowEnabled`:

```ts
  /** GitHub's "Expire user authorization tokens": keys last 8 hours and come with a refresh token. */
  expireUserTokens: boolean;
```

Add to `oauthAppColumns` after `deviceFlowEnabled`:

```ts
  expireUserTokens: sql<boolean>`coalesce((${schema.oauthClient.metadata}->>'expireUserTokens')::boolean, false)`,
```

Add `expireUserTokens: oauthAppColumns.expireUserTokens,` to the `select` in `findOauthApp`, after `deviceFlowEnabled`.

- [ ] **Step 4: Add the DTO fields**

In `CreateOauthAppDTO`, after `deviceFlowEnabled`:

```ts
  @ApiProperty({
    required: false,
    default: false,
    description: 'Access tokens expire after 8 hours and come with a refresh token that lasts 6 months, as GitHub\'s "Expire user authorization tokens".',
  })
  @IsOptional()
  @IsBoolean()
  expireUserTokens?: boolean;
```

In `OauthAppDTO`, after `deviceFlowEnabled`:

```ts
  @ApiProperty()
  expireUserTokens: boolean;
```

- [ ] **Step 5: Store the switch**

In `oauth-apps.service.ts`, `create` passes `expireUserTokens: input.expireUserTokens ?? false` to `setGhostFields`, and `update` passes `expireUserTokens: input.expireUserTokens`. Widen `setGhostFields`'s parameter to `{ deviceFlow?: boolean; expireUserTokens?: boolean; description?: string }`. `select()` already spreads `oauthAppColumns`, so the read DTO carries the field with no further change.

- [ ] **Step 6: Run the test**

Run: `E2E test/oauth-apps.e2e-spec.ts`
Expected: all pass.

- [ ] **Step 7: Commit**

```bash
git add apps/api/src/lib/github/oauth-apps.ts apps/api/src/resources/oauth-apps apps/api/test/oauth-apps.e2e-spec.ts
git commit -m "api: OAuth apps can turn on expiring user tokens"
```

---

### Task 3: Both grants issue expiring keys and refresh tokens

**Files:**
- Modify: `apps/api/src/lib/auth.ts` (api-key plugin options)
- Modify: `apps/api/src/github/oauth/oauth.controller.ts` (`issueKey` becomes `mint`)
- Create: `apps/api/test/github-oauth-refresh.e2e-spec.ts`

**Interfaces:**
- Consumes: `ACCESS_TOKEN_EXPIRES_IN`, `REFRESH_TOKEN_EXPIRES_IN`, `issueRefreshToken` (Task 1); `OauthApp.expireUserTokens` (Task 2).
- Produces: `OauthController.mint(app: OauthApp, userId: string, scopes: string[]): Promise<Record<string, string | number>>`, used by the refresh grant in Task 4.

- [ ] **Step 1: Write the failing e2e spec**

```ts
// apps/api/test/github-oauth-refresh.e2e-spec.ts
import { type Database, schema } from '@ghost/db';
import type { INestApplication } from '@nestjs/common';
import { and, desc, eq } from 'drizzle-orm';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { DATABASE } from '../src/database/database.module.js';
import { hashRefreshToken } from '../src/lib/github/refresh-tokens.js';
import { hasBackends, signUp, startApp } from './harness.js';

const CALLBACK = 'https://tool.example/oauth/callback';
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';

describe.skipIf(!hasBackends)('GitHub OAuth expiring tokens and refresh', () => {
  let app: INestApplication;
  let db: Database;
  let owner: { cookie: string; key: string; userId: string };
  let clientId: string;
  let clientSecret: string;
  const api = () => request(app.getHttpServer());
  const form = (text: string) => Object.fromEntries(new URLSearchParams(text));
  const register = async (body: object = {}) => (await api().post('/api/oauth-apps').set('cookie', owner.cookie).send({ name: 'Expiring Tool', homepageUrl: 'https://tool.example', callbackUrls: [CALLBACK], expireUserTokens: true, ...body }).expect(201)).body as { clientId: string; clientSecret: string };
  const codeFor = async (client = clientId) => {
    const first = await api().get('/login/oauth/authorize').query({ client_id: client, scope: 'repo' }).set('cookie', owner.cookie).expect(302);
    const plugin = new URL(first.headers.location, 'http://api.local');
    let callback = new URL((await api().get(`${plugin.pathname}${plugin.search}`).set('cookie', owner.cookie).expect(302)).headers.location);
    if (callback.searchParams.has('sig')) callback = new URL((await api().post('/api/auth/oauth2/consent').set('cookie', owner.cookie).send({ accept: true, oauth_query: callback.search.slice(1) }).expect(200)).body.url);
    return callback.searchParams.get('code') as string;
  };
  const exchange = async (client = clientId, secret = clientSecret, accept = 'application/x-www-form-urlencoded') => api().post('/login/oauth/access_token').set('accept', accept).type('form').send({ client_id: client, client_secret: secret, code: await codeFor(client) }).expect(200);
  const refresh = (refreshToken: string, body: Record<string, string> = {}) => api().post('/login/oauth/access_token').type('form').send({ client_id: clientId, client_secret: clientSecret, grant_type: 'refresh_token', refresh_token: refreshToken, ...body }).expect(200);
  const newestKey = async () => (await db.select({ id: schema.apikey.id, expiresAt: schema.apikey.expiresAt }).from(schema.apikey).where(eq(schema.apikey.referenceId, owner.userId)).orderBy(desc(schema.apikey.createdAt)).limit(1))[0];

  beforeAll(async () => {
    ({ app } = await startApp({ WEB_APP_URL: 'https://web.example' }));
    db = app.get<Database>(DATABASE);
    owner = await signUp(app, `ghrt${Date.now()}`);
    ({ clientId, clientSecret } = await register());
  });

  afterAll(async () => {
    await app?.close();
  });

  describe('issuing', () => {
    it('adds no expiry and no refresh token while the switch is off', async () => {
      const off = await register({ expireUserTokens: false });
      const token = form((await exchange(off.clientId, off.clientSecret)).text);
      expect(token).toEqual({ access_token: expect.stringMatching(/^ghost_pat_/), token_type: 'bearer', scope: 'repo' });
      expect((await newestKey())?.expiresAt).toBeNull();
    });

    it("answers the code grant with GitHub's expiring token fields, form-encoded and JSON", async () => {
      const token = form((await exchange()).text);
      expect(token).toEqual({ access_token: expect.stringMatching(/^ghost_pat_/), expires_in: '28800', refresh_token: expect.stringMatching(/^ghost_rt_/), refresh_token_expires_in: '15897600', token_type: 'bearer', scope: 'repo' });
      const json = (await exchange(clientId, clientSecret, 'application/json')).body;
      expect(json).toMatchObject({ expires_in: 28800, refresh_token_expires_in: 15897600, refresh_token: expect.stringMatching(/^ghost_rt_/) });
      const [row] = await db.select({ expiresAt: schema.oauthAppRefreshToken.expiresAt }).from(schema.oauthAppRefreshToken).where(eq(schema.oauthAppRefreshToken.tokenHash, hashRefreshToken(json.refresh_token)));
      expect(row?.expiresAt.getTime()).toBeGreaterThan(Date.now() + 15897600 * 1000 - 60_000);
    });

    it('gives the key an 8-hour expiry that the token guard enforces', async () => {
      const token = form((await exchange()).text);
      const key = await newestKey();
      expect((key?.expiresAt as Date).getTime() - Date.now()).toBeGreaterThan(28800 * 1000 - 60_000);
      await api().get('/api/v3/user').set('authorization', `token ${token.access_token}`).expect(200);
      await db.update(schema.apikey).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(schema.apikey.id, key?.id as string));
      await api().get('/api/v3/user').set('authorization', `token ${token.access_token}`).expect(401);
    });

    it('answers the device grant with a refresh token too', async () => {
      const device = await register({ deviceFlowEnabled: true });
      const code = form((await api().post('/login/device/code').type('form').send({ client_id: device.clientId, scope: 'repo' }).expect(200)).text);
      await api().get('/api/auth/device').query({ user_code: code.user_code }).set('cookie', owner.cookie).expect(200);
      await api().post('/api/auth/device/approve').set('cookie', owner.cookie).send({ userCode: code.user_code }).expect(200);
      const token = form((await api().post('/login/oauth/access_token').type('form').send({ client_id: device.clientId, device_code: code.device_code, grant_type: DEVICE_GRANT }).expect(200)).text);
      expect(token).toMatchObject({ expires_in: '28800', refresh_token: expect.stringMatching(/^ghost_rt_/), refresh_token_expires_in: '15897600', scope: 'repo' });
    });
  });
});
```

- [ ] **Step 2: Run it to see it fail**

Run: `E2E test/github-oauth-refresh.e2e-spec.ts`
Expected: the switch-off test passes; the three switch-on tests FAIL with no `expires_in`.

- [ ] **Step 3: Allow 8-hour keys**

In `apps/api/src/lib/auth.ts`, import `ACCESS_TOKEN_EXPIRES_IN` from `./github/refresh-tokens.js` and add to the `apiKey({ … })` options:

```ts
        // In days; OAuth apps with expiring user tokens get keys that last 8 hours.
        keyExpiration: { minExpiresIn: ACCESS_TOKEN_EXPIRES_IN / 86400 },
```

- [ ] **Step 4: Replace `issueKey` with `mint`**

In `oauth.controller.ts`, import `ACCESS_TOKEN_EXPIRES_IN, REFRESH_TOKEN_EXPIRES_IN, issueRefreshToken` from `../../lib/github/refresh-tokens.js`. Replace `issueKey` with:

```ts
  private async issueKey(
    req: Request,
    res: Response,
    app: OauthApp,
    userId: string,
    scope: string,
  ) {
    sendOAuth(req, res, 200, await this.mint(app, userId, grantableScopes(scope)));
  }

  /** A key for the app's user, and with expiring user tokens on, its expiry and a refresh token, in GitHub's token response. */
  private async mint(app: OauthApp, userId: string, scopes: string[]) {
    const { key, id } = await this.auth.api.createApiKey({
      body: {
        userId,
        name: app.name,
        permissions: { scopes },
        metadata: { oauthClientId: app.clientId },
        ...(app.expireUserTokens && { expiresIn: ACCESS_TOKEN_EXPIRES_IN }),
      },
    });
    const token = {
      access_token: key,
      token_type: 'bearer',
      scope: scopes.join(','),
    };
    if (!app.expireUserTokens) return token;
    return {
      ...token,
      expires_in: ACCESS_TOKEN_EXPIRES_IN,
      refresh_token: await issueRefreshToken(this.db, app.clientId, {
        userId,
        scopes,
        accessKeyId: id,
      }),
      refresh_token_expires_in: REFRESH_TOKEN_EXPIRES_IN,
    };
  }
```

- [ ] **Step 5: Run the tests**

Run: `E2E test/github-oauth-refresh.e2e-spec.ts test/github-oauth.e2e-spec.ts test/github-oauth-web.e2e-spec.ts`
Expected: all pass. The two existing specs prove the switch-off response is unchanged.

- [ ] **Step 6: Commit**

```bash
git add apps/api/src/lib/auth.ts apps/api/src/github/oauth/oauth.controller.ts apps/api/test/github-oauth-refresh.e2e-spec.ts
git commit -m "api: expiring user tokens get an 8-hour key and a refresh token from both grants"
```

---

### Task 4: The refresh grant

**Files:**
- Modify: `apps/api/src/github/oauth/oauth.controller.ts`
- Test: `apps/api/test/github-oauth-refresh.e2e-spec.ts`

**Interfaces:**
- Consumes: `spendRefreshToken` (Task 1); `mint` (Task 3).

- [ ] **Step 1: Write the failing tests** (append a `describe('refreshing', …)` inside the top-level describe)

```ts
  describe('refreshing', () => {
    const pair = async () => form((await exchange()).text);

    it('trades a refresh token for a new pair with the same scopes, and retires the old pair', async () => {
      const first = await pair();
      const next = form((await refresh(first.refresh_token as string)).text);
      expect(next).toEqual({ access_token: expect.stringMatching(/^ghost_pat_/), expires_in: '28800', refresh_token: expect.stringMatching(/^ghost_rt_/), refresh_token_expires_in: '15897600', token_type: 'bearer', scope: 'repo' });
      expect(next.access_token).not.toBe(first.access_token);
      const user = await api().get('/api/v3/user').set('authorization', `token ${next.access_token}`).expect(200);
      expect(user.headers['x-oauth-scopes']).toBe('repo');
      await api().get('/api/v3/user').set('authorization', `token ${first.access_token}`).expect(401);
      expect(form((await refresh(first.refresh_token as string)).text)).toEqual({ error: 'bad_refresh_token', error_description: 'The refresh token passed is incorrect or expired.' });
    });

    it('answers JSON when asked', async () => {
      const response = await refresh((await pair()).refresh_token as string).set('accept', 'application/json');
      expect(response.body).toMatchObject({ expires_in: 28800, refresh_token_expires_in: 15897600, token_type: 'bearer' });
    });

    it('lets only one of two concurrent refreshes with the same token win', async () => {
      const { refresh_token } = await pair();
      const answers = await Promise.all([refresh(refresh_token as string), refresh(refresh_token as string)]);
      const tokens = answers.map((answer) => form(answer.text));
      expect(tokens.filter((token) => token.access_token)).toHaveLength(1);
      expect(tokens.filter((token) => token.error === 'bad_refresh_token')).toHaveLength(1);
    });

    it('refuses an expired refresh token and an unknown one', async () => {
      const { refresh_token } = await pair();
      await db.update(schema.oauthAppRefreshToken).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(schema.oauthAppRefreshToken.tokenHash, hashRefreshToken(refresh_token as string)));
      expect(form((await refresh(refresh_token as string)).text).error).toBe('bad_refresh_token');
      expect(form((await refresh('ghost_rt_nope')).text).error).toBe('bad_refresh_token');
    });

    it("refuses another app's refresh token and leaves it working for its own app", async () => {
      const { refresh_token } = await pair();
      const other = await register({ name: 'Other Tool' });
      expect(form((await refresh(refresh_token as string, { client_id: other.clientId, client_secret: other.clientSecret })).text).error).toBe('bad_refresh_token');
      expect(form((await refresh(refresh_token as string)).text).access_token).toMatch(/^ghost_pat_/);
    });

    it('refuses a wrong or missing client secret, and an unknown client, and spends nothing', async () => {
      const { refresh_token } = await pair();
      expect(form((await refresh(refresh_token as string, { client_secret: 'wrong' })).text)).toMatchObject({ error: 'incorrect_client_credentials' });
      const missing = await api().post('/login/oauth/access_token').type('form').send({ client_id: clientId, grant_type: 'refresh_token', refresh_token }).expect(200);
      expect(form(missing.text)).toMatchObject({ error: 'incorrect_client_credentials' });
      expect(form((await refresh(refresh_token as string, { client_id: 'nope' })).text)).toMatchObject({ error: 'incorrect_client_credentials' });
      expect(form((await refresh(refresh_token as string)).text).access_token).toMatch(/^ghost_pat_/);
    });

    it('moves to keys that never expire once the switch is turned off', async () => {
      const toggled = await register({ name: 'Toggled Tool' });
      const first = form((await exchange(toggled.clientId, toggled.clientSecret)).text);
      await api().patch(`/api/oauth-apps/${toggled.clientId}`).set('cookie', owner.cookie).send({ expireUserTokens: false }).expect(200);
      const next = form((await refresh(first.refresh_token as string, { client_id: toggled.clientId, client_secret: toggled.clientSecret })).text);
      expect(next).toEqual({ access_token: expect.stringMatching(/^ghost_pat_/), token_type: 'bearer', scope: 'repo' });
      const keys = await db.select({ expiresAt: schema.apikey.expiresAt }).from(schema.apikey).where(and(eq(schema.apikey.referenceId, owner.userId), eq(schema.apikey.name, 'Toggled Tool')));
      expect(keys).toEqual([{ expiresAt: null }]);
      expect(form((await refresh(first.refresh_token as string, { client_id: toggled.clientId, client_secret: toggled.clientSecret })).text).error).toBe('bad_refresh_token');
    });
  });
```

- [ ] **Step 2: Run them to see them fail**

Run: `E2E test/github-oauth-refresh.e2e-spec.ts -t refreshing`
Expected: FAIL; the endpoint answers `400 unsupported_grant_type`, so `.expect(200)` fails.

- [ ] **Step 3: Check how the plugin answers an introspection with bad credentials**

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 `error: 'invalid_client'` for a wrong or missing secret, and answers `{ active: false }` for good credentials with a token it does not know. Write a throwaway script in the scratchpad that boots the e2e app and logs both answers, then delete it. If the plugin throws something else for an unknown token, catch only `invalid_client` as bad credentials and treat any other answer as authenticated.

- [ ] **Step 4: Implement the grant**

In `accessToken`'s body type add `refresh_token?: string;`. Before the `if (body.code)` branch add:

```ts
    if (body.grant_type === 'refresh_token' && body.refresh_token) {
      if (!app)
        return sendOAuth(req, res, 200, {
          error: 'incorrect_client_credentials',
        });
      return this.refreshGrant(req, res, app, {
        client_secret: body.client_secret,
        refresh_token: body.refresh_token,
      });
    }
```

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\` is sent, which GitHub's clients send without a \`grant_type\`. */`

Import `spendRefreshToken` and add:

```ts
  /** 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. */
  private async refreshGrant(
    req: Request,
    res: Response,
    app: OauthApp,
    body: { client_secret?: string; refresh_token: string },
  ) {
    if (!(await this.authenticates(app, body.client_secret)))
      return sendOAuth(req, res, 200, {
        error: 'incorrect_client_credentials',
      });
    const grant = await spendRefreshToken(
      this.db,
      app.clientId,
      body.refresh_token,
    );
    if (!grant)
      return sendOAuth(req, res, 200, {
        error: 'bad_refresh_token',
        error_description: 'The refresh token passed is incorrect or expired.',
      });
    await this.db
      .delete(schema.apikey)
      .where(eq(schema.apikey.id, grant.accessKeyId));
    sendOAuth(req, res, 200, await this.mint(app, grant.userId, grant.scopes));
  }

  /** The plugin checks the secret its own way; introspecting a token it does not hold costs nothing and fails only on bad credentials. */
  private async authenticates(app: OauthApp, clientSecret?: string) {
    try {
      await this.auth.api.oauth2Introspect({
        body: {
          client_id: app.clientId,
          client_secret: clientSecret,
          token: 'ghost-client-check',
          token_type_hint: 'access_token',
        },
      });
      return true;
    } catch (error) {
      if (!isAPIError(error)) throw error;
      if ((error.body as { error?: string }).error === 'invalid_client')
        return false;
      throw error;
    }
  }
```

Adjust `authenticates` to what Step 3 found; every branch kept must be reached by a test in Step 1.

- [ ] **Step 5: Run the tests**

Run: `E2E test/github-oauth-refresh.e2e-spec.ts test/github-oauth-web.e2e-spec.ts`
Expected: all pass.

- [ ] **Step 6: Commit**

```bash
git add apps/api/src/github/oauth/oauth.controller.ts apps/api/test/github-oauth-refresh.e2e-spec.ts
git commit -m "api: refresh expiring user tokens with grant_type=refresh_token"
```

---

### Task 5: Authorized apps read refresh tokens

**Files:**
- Modify: `apps/api/src/resources/oauth-apps/oauth-apps.service.ts` (`listAuthorized`, `revokeAuthorized`)
- Modify: `apps/api/src/resources/oauth-apps/dto/oauth-app.dto.ts` (`AuthorizedOauthAppDTO.scopes` description)
- Test: `apps/api/test/oauth-apps.e2e-spec.ts` (`describe('authorized apps')`)

- [ ] **Step 1: Write the failing test** (inside `describe('authorized apps')`)

```ts
    it('keeps an app whose key expired but whose refresh token lives, and revoking it deletes the refresh token', async () => {
      const a = (await create(other.cookie, { name: 'App R' }).expect(201)).body.clientId;
      await db.insert(schema.oauthAppRefreshToken).values({ tokenHash: `rt-${stamp}`, clientId: a, userId: owner.userId, scopes: ['read:org', 'repo'], accessKeyId: 'gone', expiresAt: new Date(Date.now() + 60_000) });
      const list = await api().get('/api/oauth-apps/authorized').set('cookie', owner.cookie).expect(200);
      expect(list.body.apps.find((each: { clientId: string }) => each.clientId === a)).toMatchObject({ name: 'App R', scopes: ['read:org', 'repo'], lastUsedAt: null, authorizedAt: expect.any(String) });
      await api().delete(`/api/oauth-apps/authorized/${a}`).set('cookie', owner.cookie).expect(204);
      expect(await db.select().from(schema.oauthAppRefreshToken).where(eq(schema.oauthAppRefreshToken.clientId, a))).toEqual([]);
      await api().delete(`/api/oauth-apps/authorized/${a}`).set('cookie', owner.cookie).expect(404);
    });

    it('merges the scopes of keys and refresh tokens', async () => {
      const a = (await create(other.cookie, { name: 'App M' }).expect(201)).body.clientId;
      await key(`mk${stamp}`, owner.userId, a, ['repo']);
      await db.insert(schema.oauthAppRefreshToken).values({ tokenHash: `rm-${stamp}`, clientId: a, userId: owner.userId, scopes: ['gist', 'repo'], accessKeyId: `mk${stamp}`, expiresAt: new Date(Date.now() + 60_000) });
      const list = await api().get('/api/oauth-apps/authorized').set('cookie', owner.cookie).expect(200);
      expect(list.body.apps.filter((each: { clientId: string }) => each.clientId === a)).toEqual([expect.objectContaining({ scopes: ['gist', 'repo'] })]);
    });
```

- [ ] **Step 2: Run them to see them fail**

Run: `E2E test/oauth-apps.e2e-spec.ts -t "authorized apps"`
Expected: the first FAILS (app missing from the list); the second FAILS (`scopes: ['repo']`).

- [ ] **Step 3: Rewrite `listAuthorized` over keys and refresh tokens**

Import `unionAll` from `drizzle-orm/pg-core`. Replace the body of `listAuthorized`:

```ts
    const keys = this.db
      .select({
        clientId: sql<string>`${keyOauthClientId}`.as('client_id'),
        scopes: sql<unknown>`coalesce(${schema.apikey.permissions}::jsonb->'scopes', '[]'::jsonb)`.as('scopes'),
        createdAt: sql<Date>`${schema.apikey.createdAt}`.as('created_at'),
        lastRequest: sql<Date | null>`${schema.apikey.lastRequest}`.as('last_request'),
      })
      .from(schema.apikey)
      .where(eq(schema.apikey.referenceId, userId));
    // An expiring key may already be deleted while its refresh token still lets the app back in.
    const refreshTokens = this.db
      .select({
        clientId: sql<string>`${schema.oauthAppRefreshToken.clientId}`.as('client_id'),
        scopes: sql<unknown>`to_jsonb(${schema.oauthAppRefreshToken.scopes})`.as('scopes'),
        createdAt: sql<Date>`${schema.oauthAppRefreshToken.createdAt}`.as('created_at'),
        lastRequest: sql<Date | null>`null::timestamptz`.as('last_request'),
      })
      .from(schema.oauthAppRefreshToken)
      .where(eq(schema.oauthAppRefreshToken.userId, userId));
    const grants = unionAll(keys, refreshTokens).as('grants');
    const apps = await this.db
      .select({
        clientId: oauthAppColumns.clientId,
        name: oauthAppColumns.name,
        logoUrl: oauthAppColumns.logoUrl,
        // jsonb_agg runs over the outer group; Postgres allows that only in a select list, hence the one-row derived table.
        scopes: sql<string[]>`array(select distinct scope from (select jsonb_agg(${grants.scopes}) as granted) as each_grant, jsonb_array_elements(each_grant.granted) as grant_scopes, jsonb_array_elements_text(grant_scopes) as scope order by scope)`,
        authorizedAt: isoTimestamp(sql`min(${grants.createdAt})`),
        lastUsedAt: sql<string | null>`${isoTimestamp(sql`max(${grants.lastRequest})`)}`,
      })
      .from(grants)
      .innerJoin(schema.oauthClient, eq(schema.oauthClient.clientId, grants.clientId))
      .groupBy(schema.oauthClient.id)
      .orderBy(oauthAppColumns.name);
    return { apps };
```

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.

- [ ] **Step 4: Revoke refresh tokens too**

Replace the start of `revokeAuthorized`:

```ts
  /** 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. */
  async revokeAuthorized(userId: string, clientId: string): Promise<void> {
    const keys = await this.db
      .delete(schema.apikey)
      .where(and(eq(schema.apikey.referenceId, userId), eq(keyOauthClientId, clientId)))
      .returning({ id: schema.apikey.id });
    const refreshTokens = await this.db
      .delete(schema.oauthAppRefreshToken)
      .where(and(eq(schema.oauthAppRefreshToken.userId, userId), eq(schema.oauthAppRefreshToken.clientId, clientId)))
      .returning({ id: schema.oauthAppRefreshToken.id });
    if (keys.length === 0 && refreshTokens.length === 0)
      throw new AuthorizedOauthAppNotFoundError();
```

The consent delete that follows is unchanged.

In `AuthorizedOauthAppDTO.scopes`, change the description to `'Every scope the app holds across its keys and refresh tokens for this account.'`.

- [ ] **Step 5: Add an end-to-end revoke over HTTP** (in `github-oauth-refresh.e2e-spec.ts`, inside `describe('refreshing')`)

```ts
    it('stops refreshing once the user revokes the app', async () => {
      const revoked = await register({ name: 'Revoked Tool' });
      const first = form((await exchange(revoked.clientId, revoked.clientSecret)).text);
      await api().delete(`/api/oauth-apps/authorized/${revoked.clientId}`).set('cookie', owner.cookie).expect(204);
      expect(form((await refresh(first.refresh_token as string, { client_id: revoked.clientId, client_secret: revoked.clientSecret })).text).error).toBe('bad_refresh_token');
    });
```

- [ ] **Step 6: Run the tests**

Run: `E2E test/oauth-apps.e2e-spec.ts test/github-oauth-refresh.e2e-spec.ts`
Expected: all pass, the existing authorized-apps tests included.

- [ ] **Step 7: Commit**

```bash
git add apps/api/src/resources/oauth-apps apps/api/test/oauth-apps.e2e-spec.ts apps/api/test/github-oauth-refresh.e2e-spec.ts
git commit -m "api: authorized apps count refresh tokens, and revoking an app deletes them"
```

---

### Task 6: The switch in the web app, and the regenerated client

**Files:**
- Modify: `apps/api/openapi.json`, `apps/web/src/lib/api/v1.d.ts` (generated)
- Modify: `apps/web/src/components/oauth-apps/oauth-app-fields.tsx`
- Modify: `apps/web/src/components/oauth-apps/actions.ts`
- Modify: `apps/web/src/components/oauth-apps/oauth-app-settings-card.tsx`

- [ ] **Step 1: Regenerate the OpenAPI document and the web client**

Run at the repository root: `bun run openapi`
Expected: `openapi.json` and `v1.d.ts` gain `expireUserTokens` on the OAuth app schemas and the new scopes description.

- [ ] **Step 2: Add the field to the form values**

In `oauth-app-fields.tsx`, add `expireUserTokens: boolean;` to `OauthAppValues` after `deviceFlowEnabled`, and `expireUserTokens: false,` to `EMPTY_OAUTH_APP`. In `actions.ts`, add `expireUserTokens: z.boolean(),` after `deviceFlowEnabled`. In `oauth-app-settings-card.tsx`, add `expireUserTokens: app.expireUserTokens,` after `deviceFlowEnabled`. Check how `actions.ts` sends the values: if it forwards the parsed object as-is, nothing else changes; if it lists fields, add `expireUserTokens` there.

- [ ] **Step 3: Add the switch**

In `oauth-app-fields.tsx`, after the device flow `</FieldSet>`, add:

```tsx
      <FieldSet>
        <FieldLegend>Tokens</FieldLegend>
        <Field orientation="horizontal">
          <FieldContent>
            <FieldLabel htmlFor="oauth-app-expire-user-tokens">
              Expire user authorization tokens
            </FieldLabel>
            <FieldDescription>
              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.
            </FieldDescription>
          </FieldContent>
          <Switch
            id="oauth-app-expire-user-tokens"
            checked={values.expireUserTokens}
            disabled={disabled}
            onCheckedChange={(checked) => set({ expireUserTokens: checked })}
          />
        </Field>
      </FieldSet>
```

Keep the description string on one line (code standard 5), even if the formatter leaves it long.

- [ ] **Step 4: Type check, lint, and look at it**

Run: `bun run check-types` at the root, then `cd apps/web && bunx biome check src/components/oauth-apps`.
Expected: clean. Open the OAuth app form in the running web app, toggle the switch, save, and reload to see it kept.

- [ ] **Step 5: Commit**

```bash
git add apps/api/openapi.json apps/web/src/lib/api/v1.d.ts apps/web/src/components/oauth-apps
git commit -m "web: Expire user authorization tokens switch on OAuth apps"
```

---

### Task 7: Docs and the ADR

**Files:**
- Create: `apps/docs/content/docs/oauth-apps/refreshing-tokens.mdx`
- Modify: `apps/docs/content/docs/oauth-apps/meta.json`
- Modify: `apps/docs/content/docs/github-cli/index.mdx`
- Modify: `docs/0040-github-compatibility-is-a-translation-layer.md`

- [ ] **Step 1: Write the guide**

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:

1. Intro: what the switch does and that it copies GitHub's expiring user tokens for GitHub Apps.
2. "Turning it on": Settings → Developer settings → OAuth apps → the app → "Expire user authorization tokens"; or `expireUserTokens: true` on `PATCH /api/oauth-apps/:clientId`.
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` (15897600, seconds).
4. "Refreshing": a `curl` example posting `client_id`, `client_secret`, `grant_type=refresh_token`, `refresh_token` with `Accept: application/json`, and the response.
5. "Errors": the spec's error table.
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.
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.
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.

Add `"refreshing-tokens"` to `meta.json` after `"exchanging-the-code"`.

- [ ] **Step 2: Update the gh compatibility page**

In `github-cli/index.mdx` under "Known differences", after the OAuth apps client-secret line, add:

```md
- 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.
```

- [ ] **Step 3: Add the ADR addendum**

Append to `docs/0040-github-compatibility-is-a-translation-layer.md`:

```md
## Addendum: expiring user tokens and refresh tokens

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 `ghost_rt_` refresh token that lasts 6 months. `POST /login/oauth/access_token` with `grant_type=refresh_token` checks the client's secret through the plugin's introspection, spends the refresh token, deletes the old key and mints a new pair under the app's current switch.

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.

Rejected:

- 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.
- Refresh tokens as API keys with another prefix: the token guard would accept them as access tokens unless every check filtered them out.
```

- [ ] **Step 4: Build the docs**

Run: `cd apps/docs && bun run build`
Expected: builds; the new page is in the sidebar under OAuth apps.

- [ ] **Step 5: Commit**

```bash
git add apps/docs docs/0040-github-compatibility-is-a-translation-layer.md
git commit -m "docs: refreshing expiring user tokens"
```

---

### Task 8: Whole-branch check

- [ ] **Step 1:** `bun run check-types` at the root, and `cd apps/api && bunx vitest run` (unit). Expected: clean, all pass.
- [ ] **Step 2:** Full API e2e with `--no-file-parallelism` on the throwaway backends. Expected: all pass except the two known environmental failures (`storage-limits` fork quota with a root `.env`).
- [ ] **Step 3:** `bun scripts/check-standards.ts` if it exists, then read the branch diff against `docs/code-standards.md`: no `to<Format>` helpers, no wrapped strings or comments, no comment that says what instead of why.
- [ ] **Step 4:** Commit the spec and plan with the code: `git add docs/superpowers/specs/2026-10-10-oauth-refresh-tokens-design.md docs/superpowers/plans/2026-10-10-oauth-refresh-tokens.md && git commit -m "docs: spec and plan for expiring OAuth tokens"`.

## Changes after the branch review

- 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. `spendRefreshToken` and `issueRefreshToken` take an `Executor`.
- Authorized apps ignores expired refresh rows.
- A `refresh_token` or `client_secret` that is not a string answers an OAuth error instead of a 500.
