fix: revoke OAuth refresh tokens on session lifecycle#83
Merged
Conversation
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.
Summary
Task Reference: [MYMR-159]
Tightens the MCP OAuth provider so a leaked refresh token cannot outlive the password that minted it, and gives users a single-click "Revoke all" in the Agents tab.
Changes
lib/auth.ts— cascade hook:databaseHooks.account.update.after, filtered byproviderId === "credential", callsclearUserOAuthArtifacts(userId)whenever a password mutation lands. Covers/change-password, theupdate-userpassword path, admin password reset, andrevokeSessionsOnPasswordReset. Wrapped intry/catch+console.errorto matchorganizationHooks.afterRemoveMember— BA firesdatabaseHookspost-commit, so a thrown error would not roll back the parent write.lib/auth.ts—oauthProviderconfig:accessTokenExpiresIn: 60 * 60(1h). Pinned explicitly so a future plugin default change is intentional.refreshTokenExpiresIn: 60 * 60 * 24 * 7(7d). Aligned withsession.expiresIninstead of the plugin's 30-day default.clientRegistrationAllowedScopes: ["openid", "profile", "email", "offline_access"]. Defensive allowlist — functional no-op today; prevents a future scope addition from silently widening DCR.lib/data/oauth-session.ts— newclearUserOAuthArtifacts(userId)hard-deletes everyoauthAccessTokenandoauthRefreshTokenrow for a user inside a singleserviceRoleDbtransaction. Idempotent. Reused by both the cascade hook and the bulk-revoke action.lib/actions/oauth-session.ts— newrevokeAllOAuthSessionsAction. MirrorsrevokeOAuthSessionAction's shape (requireSession→checkActionRateLimit→ mutate →revalidatePath("/settings")). Rate-limited atperUserMax: 3 / perIpMax: 10 / windowSeconds: 60.app/settings/_components/AgentsTab.tsx— adds a "Revoke all"InlineConfirmrow, hidden when no sessions exist.InlineConfirmalready wrapsonConfirminuseTransitionand disables the confirm button with a loading indicator. Subhead copy updated to "Sessions authorized to run via MCP. Changing your password revokes every session automatically." Brand order is now Claude Code → Codex → Gemini → Cursor.docker/init-pg-cron.sql(new) — nightly0 3 * * *(UTC) janitor that purges revoked or expired rows fromoauthRefreshTokenand expired rows fromoauthAccessToken. Belt-and-braces alongside the cascade hook. Requires the pg_cron extension (Neon console toggle, or self-host superuser). Not wired intotests/setup/migrate.ts— the testcontainer image ships without pg_cron and the suite does not need it.docker/init-auth.sqlhas a pointer comment so operators see the follow-up step.Why the
account.update.afterfilter is safeThe
providerId === "credential"filter is sufficient because every credential-row mutation in BA 1.6.11 is a password change (updatePasswordandupdateAccount({ password })inapi/routes/password.mjs,update-user.mjs,admin/,email-otp/,phone-number/). Social provider sign-ins update social account rows, which the filter excludes. Plain email/password sign-in does not touch the account row at all, so logins do not trigger the cascade.Why hard delete and not soft revoke
listActiveOAuthSessionsalready filters onisNull(revoked), so hard-deleting is observationally equivalent and reclaims space. The single-session revoke keeps the soft-revoke audit-row semantics; bulk revoke and the cascade hard-delete.Why no
referenceIdfilter on the cascadeconsentReferenceId: () => undefined(lib/auth.ts) means refresh-token rows are written withreferenceId = nulltoday. A future change that populatesreferenceIdwould still want a full-user cascade — filtering would mask future regressions.Type of change
Testing