From 2319b9fa97fb814b366b4fde0406b9965470dd10 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 29 Oct 2026 18:39:41 -0600 Subject: [PATCH] fix: preserve OAuth revocation for legacy tokens --- harness-tests/core/shared-oauth-db.test.ts | 49 ++++++++++++++++++++++ src/auth/oauthClient.server.ts | 23 +++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/harness-tests/core/shared-oauth-db.test.ts b/harness-tests/core/shared-oauth-db.test.ts index 6cf1c0889..7ce327779 100644 --- a/harness-tests/core/shared-oauth-db.test.ts +++ b/harness-tests/core/shared-oauth-db.test.ts @@ -168,6 +168,34 @@ describe.skipIf(!fixture.url)('OAuth grants in PostgreSQL', () => { ).toEqual({ success: false, error: 'invalid_grant' }) }) + it.each(['access', 'refresh'])( + 'revokes a pre-migration %s token without touching unrelated tokens', + async (kind) => { + const accessToken = 'oa_legacy-access' + const refreshToken = 'or_legacy-refresh' + const hash = async (token: string) => + Buffer.from( + await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(token), + ), + ).toString('hex') + const [access] = + await sql`INSERT INTO oauth_mcp_access_tokens (token_hash, user_id, client_id, expires_at) VALUES (${await hash(accessToken)}, ${userId}, ${clientId}, now() + interval '1 hour') RETURNING id` + await sql`INSERT INTO oauth_mcp_refresh_tokens (token_hash, user_id, client_id, access_token_id, expires_at) VALUES (${await hash(refreshToken)}, ${userId}, ${clientId}, ${access.id}, now() + interval '1 day')` + await sql`INSERT INTO oauth_mcp_access_tokens (token_hash, user_id, client_id, expires_at) VALUES (${await hash('oa_unrelated')}, ${userId}, ${clientId}, now() + interval '1 hour')` + const token = kind === 'access' ? accessToken : refreshToken + await revokeOAuthGrant(token, 'wrong-client') + expect((await validateOAuthToken(accessToken)).success).toBe(true) + await revokeOAuthGrant(token, clientId) + expect((await validateOAuthToken(accessToken)).success).toBe(false) + expect((await validateOAuthToken('oa_unrelated')).success).toBe(true) + if (kind === 'refresh') + expect((await refreshAccessToken(refreshToken)).success).toBe(false) + await revokeOAuthGrant(token, clientId) + }, + ) + it('rotates refresh tokens without changing audience, scope or absolute expiry, and revokes on replay', async () => { const issued = await issue() expect(await listConnectedApps(userId)).toMatchObject([ @@ -264,6 +292,27 @@ describe.skipIf(!fixture.url)('OAuth grants in PostgreSQL', () => { }) }) + it('revokes access tokens created by legacy-scope refresh', async () => { + const code = await createAuthorizationCode({ + userId, + clientId, + redirectUri, + codeChallenge: challenge, + }) + const issued = await exchangeAuthorizationCode({ + code, + codeVerifier: verifier, + redirectUri, + }) + if (!issued.success) throw new Error(issued.error) + const refreshed = await refreshAccessToken(issued.refreshToken) + if (!refreshed.success) throw new Error(refreshed.error) + await revokeOAuthGrant(issued.refreshToken, clientId) + expect((await validateOAuthToken(refreshed.accessToken)).success).toBe( + false, + ) + }) + it('requires service authentication for introspection and isolates identity grants', async () => { const issued = await issue() const request = (authorization: string) => diff --git a/src/auth/oauthClient.server.ts b/src/auth/oauthClient.server.ts index 8018d0191..8e8f9e0a3 100644 --- a/src/auth/oauthClient.server.ts +++ b/src/auth/oauthClient.server.ts @@ -512,6 +512,7 @@ export async function refreshAccessToken( userId: token.userId, clientId: token.clientId, scope: 'api', + grantId: token.grantId, expiresAt: accessExpiresAt, }) .returning({ id: oauthAccessTokens.id }) @@ -821,7 +822,27 @@ export async function revokeOAuthGrant(token: string, clientId: string) { ) .limit(1) const grantId = access?.grantId ?? refresh?.grantId - if (!grantId) return + if (!grantId) { + await db.transaction(async (tx) => { + if (access) { + await tx + .delete(oauthAccessTokens) + .where(eq(oauthAccessTokens.id, access.id)) + } + if (refresh) { + const [deleted] = await tx + .delete(oauthRefreshTokens) + .where(eq(oauthRefreshTokens.id, refresh.id)) + .returning({ accessTokenId: oauthRefreshTokens.accessTokenId }) + if (deleted?.accessTokenId) { + await tx + .delete(oauthAccessTokens) + .where(eq(oauthAccessTokens.id, deleted.accessTokenId)) + } + } + }) + return + } await db.transaction(async (tx) => { await tx.execute( sql`select pg_advisory_xact_lock(hashtextextended(${grantId}, 0))`,