Repository navigation
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), andPOST /oauth/tokenonly supportsgrant_type=authorization_code(platform/packages/api/src/routes/oauth.ts:538pre-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:approveper-publish approvals must stay interactive, one-shot, short-lived, and must not become refreshable.Design
oauth_refresh_tokens(migration0063_oauth_refresh_tokens.sql):id,grant_id(FK →oauth_grants, cascade delete),token_hash(unique, same SHA-256 hashing as existingtoken_hash/code_hash/secret_hashviahashLoginProof),expires_at(absolute per-family cap),used_at(null while live),created_at.authorization_codeexchange now also mints a refresh token alongside the access token, anchored tomin(now + 90d, session.exp).grant_type=refresh_tokenbranch onPOST /oauth/token:token_hashmatch,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.activeOAuthGrant(..., lock=true)helper (grant not revoked, client enabled, user not suspended, underlying platform login session still valid) — extended with a newrequireFreshAccessTokenflag so refresh (which is explicitly for an expired access token) skips the access-token-freshness check while keeping every other check.expires_atis copied unchanged from the one it replaces, so rotation cannot extend the family's absolute lifetime.scopeparam 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 bypreviews.ts,/userinfo,/connections).POST /oauth/revokenow also matches a refresh token hash and revokes its whole grant (previously only matched the access token hash)./oauth/connections/revoke) anddisableOAuthClientalready revoke the grant — both now also transitively block any further refresh, since the refresh-grant lookup requiresisNull(oauth_grants.revoked_at).grant_types_supportedin/.well-known/oauth-authorization-servermetadata now advertisesrefresh_token.deploy:approvepublish credentials are untouched: they're minted as short-lived signed JWTs in a completely separate code path (kind === 'deployment') that never touchesoauth_grants/oauth_refresh_tokens, so they structurally cannot receive a refresh token. Added a regression test asserting the exchange response has norefresh_tokenfield.purgeExpiredOAuthRequestsfor staleoauth_refresh_tokensrows, mirroring the existingoauth_requests/oauth_grantspurge pattern.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 thatdeploy:approveis excluded.Tests
Added to
platform/packages/api/test/integration/oauth.test.ts(follows the existingconnect()/exchange()helper patterns):/oauth/revokedeploy:approvepublish credentialrefresh_tokenin authorization server metadataVerification
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 failedtsc --noEmitonplatform/packages/api— cleanvp lint/vp fmt --checkon changed files — clean