Repository navigation
feat(platform): add refresh tokens for connected-app identity grants - #631
Merged
Brooooooklyn merged 2 commits intoNov 2, 2026
Merged
Brooooooklyn merged 2 commits into
Brooooooklyn merged 2 commits into
Conversation
Brooooooklyn
force-pushed
the
feat/oauth-connected-app-refresh-tokens
branch
from
November 2, 2026 03:46
8e34b61 to
ad79e01
Compare
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
force-pushed
the
feat/oauth-connected-app-refresh-tokens
branch
from
November 2, 2026 03:47
ad79e01 to
6737663
Compare
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>
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) get a 1h access token, andPOST /oauth/tokenonly supportedgrant_type=authorization_code. Long-lived apps (e.g. LumiOS) had to send the user through consent again every hour.deploy:approvepublish approvals must stay interactive, one-shot, short-lived, and never refreshable.Design
oauth_refresh_tokenstable (migration0063): hashed token,grant_id(cascade delete), familyexpires_at,used_at.grant_type=refresh_tokenonPOST /oauth/token(client Basic auth):invalid_grant, no side effects.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.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.invalid_grant(400) per RFC 6749 §5.2.scopenarrows the grant; a later refresh cannot widen it again.POST /oauth/revokealso accepts a refresh token and revokes its grant.oauth_grants.expires_atis only the access-token expiry)./oauth/connectionslists those grants, so users can still disconnect them.deploy:approvecredentials are separate signed JWTs and never get a refresh token.refresh_tokeningrant_types_supported.connected-apps.mdand design record0091.Tests
platform/packages/api/test/integration/oauth.test.ts:runIfcase)deploy:approve; metadataVerification
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 passedapi,platform,dashboard),vp lint,vp fmt: clean🤖 Generated with Claude Code