Skip to content

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

Open
sglyon wants to merge 1 commit into
voidzero-dev:mainfrom
sglyon:feat/oauth-connected-app-refresh-tokens
Open

sglyon wants to merge 1 commit into
voidzero-dev:mainfrom
sglyon:feat/oauth-connected-app-refresh-tokens

Conversation

@sglyon

@sglyon sglyon commented Oct 28, 2026

Copy link
Copy Markdown

Problem

Connected-app identity grants (profile, preview:deploy) are capped at a 1h access token (TOKEN_SECONDS, platform/packages/api/src/routes/oauth.ts:37), and POST /oauth/token only supports grant_type=authorization_code (platform/packages/api/src/routes/oauth.ts:538 pre-change). There is no way to renew access without sending the user through the consent screen again every hour, which is disruptive for long-lived apps (e.g. LumiOS) that want to keep a connection alive silently.

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

Design

  • Added oauth_refresh_tokens (migration 0063_oauth_refresh_tokens.sql): id, grant_id (FK → oauth_grants, cascade delete), token_hash (unique, same SHA-256 hashing as existing token_hash/code_hash/secret_hash via hashLoginProof), expires_at (absolute per-family cap), used_at (null while live), created_at.
  • The authorization_code exchange now also mints a refresh token alongside the access token, anchored to min(now + 90d, session.exp).
  • New grant_type=refresh_token branch on POST /oauth/token:
    • Claims the presented refresh token with an atomic compare-and-swap UPDATE (token_hash match, used_at IS NULL, not expired) outside any transaction, so a redemption attempt always burns the token even if a later check (client match, session, scope) rejects the request.
    • If the CAS fails because the token was already used, the whole grant is revoked (reuse = treated as a stolen/replayed credential), not just that token.
    • On success, re-validates the grant with the existing activeOAuthGrant(..., lock=true) helper (grant not revoked, client enabled, user not suspended, underlying platform login session still valid) — extended with a new requireFreshAccessToken flag so refresh (which is explicitly for an expired access token) skips the access-token-freshness check while keeping every other check.
    • Rotates to a new access token + refresh token; the new refresh token's expires_at is copied unchanged from the one it replaces, so rotation cannot extend the family's absolute lifetime.
    • Accepts an optional scope param to request a narrower (never wider) set of scopes than the grant; narrowing is persisted on the grant (consistent with the existing single-active-token-per-grant model used by previews.ts, /userinfo, /connections).
  • POST /oauth/revoke now also matches a refresh token hash and revokes its whole grant (previously only matched the access token hash).
  • Disconnect (/oauth/connections/revoke) and disableOAuthClient already revoke the grant — both now also transitively block any further refresh, since the refresh-grant lookup requires isNull(oauth_grants.revoked_at).
  • grant_types_supported in /.well-known/oauth-authorization-server metadata now advertises refresh_token.
  • deploy:approve publish credentials are untouched: they're minted as short-lived signed JWTs in a completely separate code path (kind === 'deployment') that never touches oauth_grants/oauth_refresh_tokens, so they structurally cannot receive a refresh token. Added a regression test asserting the exchange response has no refresh_token field.
  • Added a cleanup pass to purgeExpiredOAuthRequests for stale oauth_refresh_tokens rows, mirroring the existing oauth_requests/oauth_grants purge pattern.
  • Updated docs/guide/platform/administration/connected-apps.md: removed the "No refresh token... is issued" line and documented the refresh flow, rotation, reuse handling, scope narrowing, and that deploy:approve is excluded.

Tests

Added to platform/packages/api/test/integration/oauth.test.ts (follows the existing connect()/exchange() helper patterns):

  • issues a rotating refresh token on connect; using it mints a new access token and invalidates the prior one
  • detects refresh token reuse and revokes the whole grant
  • rejects an unknown or garbage refresh token
  • enforces an absolute refresh lifetime that rotation cannot extend
  • narrows scope on refresh and does not let a later refresh widen it back
  • rejects a refresh token presented to a different client
  • blocks refresh once the grant is revoked, client disabled, account suspended, or the login session is invalidated
  • revokes a refresh token (and its grant) through /oauth/revoke
  • never issues a refresh token for a deploy:approve publish credential
  • advertises refresh_token in authorization server metadata

Verification

  • vp test run platform/packages/api/test/integration/oauth.test.ts — 29/29 passing (19 pre-existing + 10 new)
  • vp run test --base origin/main (affected selector against this diff) — 218 test files / 4502 tests passing, 0 failed
  • tsc --noEmit on platform/packages/api — clean
  • vp lint / vp fmt --check on changed files — clean

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.

This branch has not been deployed

No deployments
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.

1 participant