Skip to content

feat(platform): add refresh tokens for connected-app identity grants - #631

Merged
Brooooooklyn merged 2 commits into
voidzero-dev:mainfrom
sglyon:feat/oauth-connected-app-refresh-tokens
Nov 2, 2026
Merged

Brooooooklyn merged 2 commits into
voidzero-dev:mainfrom
sglyon:feat/oauth-connected-app-refresh-tokens

Conversation

@sglyon

@sglyon sglyon commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Connected-app identity grants (profile, preview:deploy) get a 1h access token, and POST /oauth/token only supported grant_type=authorization_code. Long-lived apps (e.g. LumiOS) had to send the user through consent again every hour.

deploy:approve publish approvals must stay interactive, one-shot, short-lived, and never refreshable.

Design

code exchange ──► access token (1h) + refresh token RT1
                         │
refresh(RT1) ──► access token + RT2   (RT1 marked used, RT2 keeps RT1's expiry)
refresh(RT1) again ──► reuse ──► grant revoked, invalid_grant
family expiry = expiry of the login session the user approved from (30 days)
  • New oauth_refresh_tokens table (migration 0063): hashed token, grant_id (cascade delete), family expires_at, used_at.
  • grant_type=refresh_token on POST /oauth/token (client Basic auth):
    • Unknown, expired, or another client's token → invalid_grant, no side effects.
    • Already-used token from the owning client → the grant is revoked.
    • Otherwise, in one transaction: re-validate the grant with activeOAuthGrant (grant not revoked, client enabled, user not suspended, login session valid; access-token freshness skipped), lock the token row, rotate, and issue a new access token. A check that fails (scope, grant, session) rolls back, so the token stays usable and a client retry is not mistaken for reuse.
    • Lock order follows the design record (configuration → client → grant FOR UPDATE → user → connection → session → token), the same grant → token order as the delete cascade. Concurrent redemptions of one token run one after the other: one rotates, the rest count as reuse.
    • Revoked/suspended/signed-out grants return invalid_grant (400) per RFC 6749 §5.2.
    • Optional scope narrows the grant; a later refresh cannot widen it again.
  • POST /oauth/revoke also accepts a refresh token and revokes its grant.
  • Housekeeping keeps a non-revoked grant while its refresh family is live (oauth_grants.expires_at is only the access-token expiry). /oauth/connections lists those grants, so users can still disconnect them.
  • deploy:approve credentials are separate signed JWTs and never get a refresh token.
  • Metadata advertises refresh_token in grant_types_supported.
  • Docs: connected-apps.md and design record 0091.

Tests

platform/packages/api/test/integration/oauth.test.ts:

  • issues and rotates a refresh token; the old access token stops working
  • refreshes after the access token has expired
  • reuse revokes the grant; concurrent redemptions (PGlite + a real PostgreSQL runIf case)
  • rotation keeps the family expiry; an expired family is rejected
  • a rejected refresh (scope, suspension) does not spend the token
  • another client's token is rejected without spending it or revoking the grant
  • revoked grant, disabled client, suspended account, revoked session block refresh
  • housekeeping keeps refreshable grants, deletes expired or disconnected ones; connections page lists refreshable grants
  • revoke via refresh token; scope narrowing; no refresh token for deploy:approve; metadata

Verification

  • vp test run platform/packages/api/test/integration/oauth.test.ts: 33 passed, 1 skipped (PostgreSQL-only case, runs in the CI Postgres job)
  • vp run test --base origin/main: 763 files, 15469 tests passed
  • typecheck (api, platform, dashboard), vp lint, vp fmt: clean

🤖 Generated with Claude Code

@cpojer
cpojer requested a review from Brooooooklyn November 2, 2026 00:39
@Brooooooklyn
Brooooooklyn force-pushed the feat/oauth-connected-app-refresh-tokens branch from 8e34b61 to ad79e01 Compare November 2, 2026 03:46
sglyon and others added 2 commits November 2, 2026 11:47
Connected-app identity grants (profile, preview:deploy) were capped at a
1h access token with no renewal, forcing a consent click every hour.
Issue a rotating refresh token on the initial authorization_code
exchange; grant_type=refresh_token with HTTP Basic client auth mints a
new access token (same scope or a narrower subset) and rotates the
refresh token.

Security properties:
- Single-use, rotating refresh tokens stored as a hash
  (oauth_refresh_tokens.token_hash), claimed with an atomic
  compare-and-swap so a redemption attempt always burns the token even
  if a later check rejects it.
- Reusing an already-rotated refresh token revokes the whole grant
  (and every refresh token issued from it), not just that token.
- Refresh still requires: grant not revoked, client enabled, user not
  suspended, and the underlying platform login session still valid
  (reuses the existing activeOAuthGrant checks, with the access-token
  freshness check skipped since refresh is explicitly for an expired
  access token).
- Each refresh token family carries an absolute expiry
  (REFRESH_TOKEN_MAX_SECONDS, 90 days, capped by session.exp) copied
  forward unchanged on every rotation, so rotation cannot extend it.
- /oauth/revoke now also accepts a refresh token and revokes its whole
  grant; disconnect (/oauth/connections/revoke) and disabling a client
  already revoke the grant and so block further refresh.
- deploy:approve publish credentials are unaffected: they are issued
  as short-lived JWTs outside the oauth_grants/refresh-token model and
  never receive a refresh token.

Advertises refresh_token in grant_types_supported metadata and
documents the flow in connected-apps.md.

Tests: issuance, rotation, reuse detection, absolute lifetime, scope
narrowing (and that it cannot be widened back), wrong-client
rejection, revoked/disabled/suspended/invalidated-session rejection,
revocation via refresh token, and that deploy:approve exchanges never
return a refresh token.

platform/packages/api/test/integration/oauth.test.ts: 29/29 passing.
Full affected suite (vp run test --base origin/main): 218 test files,
4502 tests passing. tsc --noEmit and vp lint clean on changed files.
… retry

- Housekeeping deleted a grant one day after its 1h access token expired,
  and the cascade removed its live refresh token, so refresh broke after a
  day of inactivity. Keep non-revoked grants while their refresh family is
  live.
- /oauth/connections hid grants whose access token had expired but could
  still be refreshed, so users could not disconnect them. List them.
- Redeem and rotate in one transaction. A refresh rejected by a scope,
  grant or session check now rolls back and leaves the token usable, so a
  retry no longer looks like reuse and revokes the grant. Locks follow the
  documented order and take the grant FOR UPDATE before the token row, the
  same order as the delete cascade, so concurrent refreshes serialize
  without deadlocking against user deletion or housekeeping.
- A token owned by another client is rejected without side effects.
- Revoked, suspended or signed-out grants return invalid_grant (400), per
  RFC 6749 section 5.2.
- The refresh family ends with the login session it was approved from; drop
  the 90-day constant that never applied.
- Tests: refresh after real access-token expiry, rotation keeps the family
  expiry, rejected refreshes keep the token, housekeeping and connections
  page, other-client tokens, concurrent redemptions (plus a real-PostgreSQL
  case).
- Docs: accurate lifetime and reuse wording; design record describes
  refresh tokens and the lock order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn force-pushed the feat/oauth-connected-app-refresh-tokens branch from ad79e01 to 6737663 Compare November 2, 2026 03:47
@Brooooooklyn
Brooooooklyn merged commit a274d4f into voidzero-dev:main Nov 2, 2026
18 checks passed
Brooooooklyn added a commit that referenced this pull request Nov 2, 2026
)

Fixes #664

Migration `0063` (OAuth refresh tokens, #631) shipped without
`rollbackSafeFrom` edges for releases on the `0000`–`0062` schema.
Without them, a 1.3.0, 1.4.0 or 1.5.0 platform cannot upgrade to the
next release:

```
platform: Void Platform 1.5.0 cannot safely roll back after migrating database schema 8bde0b5b… to this release.
```

## Change

```
compatibility.json  rollbackSafeFrom
  + { 1.3.0, 8bde0b5b… }
  + { 1.4.0, 8bde0b5b… }
  + { 1.5.0, 8bde0b5b… }
```

`8bde0b5b…` is computed from each release tag's journal and SQL with the
runtime build's formula. The same script reproduces the existing 1.2.1
edge (`d8e007d2…`).

## Why the edge is safe

`0063` only creates `oauth_refresh_tokens`, and its foreign key to
`oauth_grants` deletes on cascade. 1.3.0, 1.4.0 and 1.5.0 have identical
OAuth code and never read the new table.

| Older runtime action on the new schema | Result |
| -------------------------------------- | ------ |
| create / revoke a grant                | works as before |
| hourly purge of expired grants | works; refresh tokens of purged
grants go with them |
| delete a user | works; grants and refresh tokens cascade |
| client disable, suspension, logout | sets fields the new refresh path
checks |

**What a rollback costs.** After rolling back to 1.3–1.5, connected apps
cannot refresh, since the older `/oauth/token` only accepts
authorization codes, so they reconnect. The older `/oauth/revoke`
matches only access tokens, so a refresh token revoked *during* the
rollback stays valid after you upgrade again, until the older hourly
purge removes its grant, roughly a day later. 1.1.11 through 1.2.1 have
the same revoke code, and `main` already lists them as safe for `0063`.

## Verification

- Ran the CI `schema-compat` steps locally against Postgres 18 for
**each** of `v1.3.0`, `v1.4.0`, `v1.5.0`: migrate with the tag, seed,
migrate with this branch, verify the seeded data, then run the tag's 106
API integration files on the upgraded schema.
- 100–101 of 106 files pass. The failing files are all email-destination
tests.
- On `v1.5.0`, those same files fail the same tests on its **own**
schema, without `0063`. So they are local-environment failures, not
caused by the migration.
- New unit test in `admin-upgrade-compatibility.test.ts`: checks the
three edges, then applies `0063` with PGlite and runs the older
runtime's grant insert, revoke, hourly purge SQL and user delete. It
fails without the edges.
- `vp run test --base origin/main`: 219 files, 4,515 tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants