diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts index 1b722dc1a7..4fed085da8 100644 --- a/packages/api/src/mcp/MCPConnectionFactory.ts +++ b/packages/api/src/mcp/MCPConnectionFactory.ts @@ -77,6 +77,8 @@ export class MCPConnectionFactory { protected readonly oboTrustChecker?: OboTrustChecker; protected readonly upstreamTokenProvider?: UpstreamTokenProvider; protected readonly oboIdentityContext?: AuthIdentityContext; + /** Why the OBO re-exchange failed, when that is more actionable than the server's 401. */ + private oboRefreshError?: Error; private connectionReady = false; /** * Snapshot of the tenant context at factory construction time. Captured eagerly @@ -451,8 +453,13 @@ export class MCPConnectionFactory { } } - /** Resolves OBO tokens when the server config specifies obo, returns null otherwise */ - protected async getOboTokens(): Promise { + /** + * Resolves OBO tokens when the server config specifies obo, returns null otherwise. + * + * @param forceRefresh Bypasses the OBO token cache; used when the downstream server + * has rejected the credential we currently hold. + */ + protected async getOboTokens(forceRefresh = false): Promise { const oboConfig = this.serverConfig.obo; if (!oboConfig || !this.oboTokenResolver || !this.user) { return null; @@ -488,6 +495,7 @@ export class MCPConnectionFactory { this.oboTokenResolver, this.upstreamTokenProvider, this.oboIdentityContext, + forceRefresh, ); } @@ -543,6 +551,8 @@ export class MCPConnectionFactory { let cleanupOAuthHandlers: (() => void) | null = null; if (this.useOAuth && !this.usesObo) { cleanupOAuthHandlers = this.handleOAuthEvents(connection); + } else if (this.usesObo) { + cleanupOAuthHandlers = this.handleOboEvents(connection); } else { const nonOAuthHandler = () => { logger.info( @@ -575,6 +585,9 @@ export class MCPConnectionFactory { } catch { logger.warn(`${this.logPrefix} Failed to clean up rejected MCP connection`); } + if (this.oboRefreshError) { + throw this.oboRefreshError; + } throw error; } } @@ -1109,6 +1122,89 @@ export class MCPConnectionFactory { return expiresAt > Date.now() ? expiresAt : undefined; } + /** + * Sets up the authentication handler for an OBO connection. + * + * An OBO server rejecting our bearer is recoverable in a way the non-OAuth + * fallback cannot express: the downstream credential is minted from the user's + * live upstream session, so a fresh exchange produces a working token without + * any interactive step. What it needs is that live session, which only exists + * while the request that created this connection is still running. Past that + * point `upstreamTokenProvider` closes over a finished request, so a cached + * connection stops reconnecting instead and its next borrower rebuilds it + * against a live provider. + */ + protected handleOboEvents(connection: MCPConnection): () => void { + let refreshAttempted = false; + + const oboHandler = async (): Promise => { + if (this.connectionReady) { + logger.info( + `${this.logPrefix} Cached OBO connection was rejected; deferring to a live request for re-exchange`, + ); + this.abandonOboConnection(connection, new Error('OBO re-exchange requires a live request')); + return; + } + + if (refreshAttempted) { + logger.warn(`${this.logPrefix} Refreshed OBO token was rejected as well`); + this.abandonOboConnection(connection, new Error('Refreshed OBO token was rejected')); + return; + } + refreshAttempted = true; + + logger.info(`${this.logPrefix} OBO token rejected by server; re-running token exchange`); + try { + const tokens = await this.getOboTokens(true); + if (!tokens?.access_token) { + throw new Error(`OBO token exchange returned no token for "${this.serverName}".`); + } + connection.setOAuthTokens(tokens); + connection.setAuthorizationHeader(tokens.access_token); + logger.info(`${this.logPrefix} OBO token re-exchanged; retrying connection`); + connection.emit('oauthHandled'); + } catch (error) { + logger.error(`${this.logPrefix} OBO token re-exchange failed`, error); + /** + * `connectClient` rejects its handling promise with this error and then + * rethrows the server's original 401, so a diagnosis like an unrefreshable + * sign-in session would be lost. `createConnection` reads it back and + * surfaces it in place of the generic 401 — the same substitution the + * initial OBO resolution already makes. + */ + this.oboRefreshError = this.toOboRefreshError(error); + this.abandonOboConnection(connection, this.oboRefreshError); + } + }; + + connection.on('oauthRequired', oboHandler); + + return () => { + connection.removeListener('oauthRequired', oboHandler); + }; + } + + private toOboRefreshError(error: unknown): Error { + if (error instanceof OboTokenResolutionError) { + return this.createOboConnectionError(error); + } + if (error instanceof Error) { + return error; + } + return new Error(`OBO token re-exchange failed for "${this.serverName}".`); + } + + /** + * Ends recovery for an OBO connection holding a credential nothing can replace. + * Reconnect attempts would replay the rejected bearer through every backoff step + * and spend the circuit breaker's cycle budget, so the connection is retired and + * left for its next borrower to dispose and rebuild. + */ + private abandonOboConnection(connection: MCPConnection, error: Error): void { + connection.stopReconnecting(); + connection.emit('oauthFailed', error); + } + /** Sets up OAuth event handlers for the connection */ protected handleOAuthEvents( connection: MCPConnection, diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index f3f703faa1..b5aff7b21f 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -1000,9 +1000,19 @@ Please follow these instructions when using tools from the respective MCP server const resolvedHeaders: Record = 'headers' in currentOptions ? { ...(currentOptions.headers || {}) } : {}; - /** Resolve the current OBO token for this tool call; the resolver may serve cached tokens. */ const oboConfig = rawConfig.obo; - if (oboConfig && oboTokenResolver && user) { + const usesObo = Boolean(oboConfig && oboTokenResolver && user); + + /** + * Resolves the downstream token for this call and installs it as the request + * bearer. `forceRefresh` bypasses the resolver's cache, which is what a + * rejected credential needs: a revoked or scope-invalidated token is still + * inside its cached lifetime, so a cached read returns the same dead bearer. + */ + const applyOboAuthorization = async (forceRefresh: boolean): Promise => { + if (!oboConfig || !oboTokenResolver || !user) { + return; + } if (!upstreamTokenProvider) { throw new McpError( ErrorCode.InternalError, @@ -1035,6 +1045,7 @@ Please follow these instructions when using tools from the respective MCP server oboTokenResolver, upstreamTokenProvider, oboIdentityContext, + forceRefresh, ); } catch (error) { if (error instanceof OboTokenResolutionError) { @@ -1053,7 +1064,18 @@ Please follow these instructions when using tools from the respective MCP server ); } resolvedHeaders['Authorization'] = `Bearer ${oboTokens.access_token}`; - } + /** + * Runtime request headers do not reach a legacy SSE connection's event + * stream — `eventSourceInit.fetch` bypasses `createFetchFunction` and sends + * the headers `constructTransport` captured from `oauthTokens`. Without + * this the next transport rebuild re-bakes the rejected bearer, 401s, and + * retires a connection that had already recovered. + */ + connection!.setOAuthTokens(oboTokens); + }; + + /** Resolve the current OBO token for this tool call; the resolver may serve cached tokens. */ + await applyOboAuthorization(false); if ( userId && user && @@ -1150,41 +1172,58 @@ Please follow these instructions when using tools from the respective MCP server try { result = await requestTool(); } catch (error) { - const requestOAuthHandler = attachSharedOAuthHandler; - if (!requestOAuthHandler || !userId) { - throw error; - } - - if (!connection.isOAuthAuthenticationError(error)) { - throw error; - } - - try { - await waitForRecoveryWithoutLease(() => - this.recoverOAuthConnection( - connection!, - error, - serverName, - userId, - requestOAuthHandler, - oauthStart, - oauthEnd, - flowManager, - options?.signal, - !recoveryTakeoverConsumed, - ), + /** + * An OBO server rejecting the bearer mid-session is recoverable here and + * nowhere else: the downstream token is minted from the upstream session + * this request still holds, and `attachSharedOAuthHandler` is never set for + * an OBO-only config, so the OAuth recovery below would rethrow untouched. + * Without this the rejected token is re-served from cache on every later + * call until it expires. + */ + if (usesObo && connection.isOAuthAuthenticationError(error)) { + logger.info( + `${logPrefix}[${toolName}] OBO token rejected by server; re-exchanging and retrying once`, ); - } catch (recoveryError) { - if (recoveryError instanceof OAuthRecoveryTakeoverRequired) { - throw recoveryError; + await applyOboAuthorization(true); + connection.setRequestHeaders(resolvedHeaders); + result = await requestTool(); + } else { + const requestOAuthHandler = attachSharedOAuthHandler; + if (!requestOAuthHandler || !userId) { + throw error; } - if (options?.signal?.aborted) { - throw recoveryError; + + if (!connection.isOAuthAuthenticationError(error)) { + throw error; } - logger.warn(`${logPrefix}[${toolName}] Runtime OAuth recovery failed`, recoveryError); - throw error; + + try { + await waitForRecoveryWithoutLease(() => + this.recoverOAuthConnection( + connection!, + error, + serverName, + userId, + requestOAuthHandler, + oauthStart, + oauthEnd, + flowManager, + options?.signal, + !recoveryTakeoverConsumed, + ), + ); + } catch (recoveryError) { + if (recoveryError instanceof OAuthRecoveryTakeoverRequired) { + throw recoveryError; + } + if (options?.signal?.aborted) { + throw recoveryError; + } + logger.warn(`${logPrefix}[${toolName}] Runtime OAuth recovery failed`, recoveryError); + throw error; + } + result = await requestTool(); } - result = await requestTool(); } const hasPersistentUserConnections = !!userId && (this.userConnections.get(userId)?.size ?? 0) > 0; diff --git a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts index 094a6d396c..d08e179de3 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts @@ -136,6 +136,8 @@ describe('MCPConnectionFactory', () => { connect: jest.fn(), isConnected: jest.fn(), setOAuthTokens: jest.fn(), + setAuthorizationHeader: jest.fn(), + stopReconnecting: jest.fn(), on: jest.fn().mockReturnValue(mockConnectionInstance), once: jest.fn().mockReturnValue(mockConnectionInstance), off: jest.fn().mockReturnValue(mockConnectionInstance), @@ -4859,6 +4861,7 @@ describe('MCPConnectionFactory', () => { oboTokenResolver, upstreamTokenProvider, undefined, + false, ); }); @@ -4890,4 +4893,173 @@ describe('MCPConnectionFactory', () => { expect(resolveOboToken).not.toHaveBeenCalled(); }); }); + + describe('OBO authentication failures', () => { + const oboServerConfig = { + type: 'sse' as const, + url: 'https://obo.example.com', + requiresOAuth: false, + obo: { scopes: 'api://obo-server/Mcp.Tools.ReadWrite' }, + } as unknown as t.MCPOptions; + + const connectionTokens = { + access_token: 'rejected-obo-token', + token_type: 'Bearer' as const, + obtained_at: Date.now(), + expires_at: Date.now() + 3600_000, + }; + const refreshedTokens = { + access_token: 'refreshed-obo-token', + token_type: 'Bearer' as const, + obtained_at: Date.now(), + expires_at: Date.now() + 3600_000, + }; + + let resolveOboToken: jest.Mock; + + /** The `oauthRequired` listener the factory attached for this connection. */ + const getAuthHandler = (): (() => Promise) => { + const onCall = (mockConnectionInstance.on as jest.Mock).mock.calls.find( + ([event]: [string]) => event === 'oauthRequired', + ); + return onCall![1] as () => Promise; + }; + + const createOboConnection = () => + MCPConnectionFactory.create( + { serverName: 'obo-srv', serverConfig: oboServerConfig }, + { + useOAuth: true, + user: mockUser, + flowManager: mockFlowManager, + tokenMethods: { + findToken: jest.fn(), + createToken: jest.fn(), + updateToken: jest.fn(), + deleteTokens: jest.fn(), + }, + oboTokenResolver: jest.fn(), + upstreamTokenProvider: jest.fn(), + }, + ); + + beforeEach(() => { + mockProcessMCPEnv.mockReturnValue(oboServerConfig); + mockConnectionInstance.connect.mockResolvedValue(undefined); + mockConnectionInstance.isConnected.mockResolvedValue(true); + ({ resolveOboToken } = jest.requireMock('~/mcp/oauth') as { + resolveOboToken: jest.Mock; + }); + resolveOboToken.mockReset(); + resolveOboToken.mockResolvedValue(connectionTokens); + }); + + it('re-exchanges a rejected token and lets the connect retry proceed', async () => { + resolveOboToken + .mockResolvedValueOnce(connectionTokens) + .mockResolvedValueOnce(refreshedTokens); + /** The real handler runs inside `connectClient`, so fire it from within `connect()`. */ + mockConnectionInstance.connect.mockImplementationOnce(async () => { + await getAuthHandler()(); + }); + + await createOboConnection(); + + expect(resolveOboToken).toHaveBeenCalledTimes(2); + /** The rejected credential is still inside its cached lifetime, so the cache is bypassed. */ + expect(resolveOboToken).toHaveBeenLastCalledWith( + mockUser, + oboServerConfig.obo, + expect.any(Function), + expect.any(Function), + undefined, + true, + ); + expect(mockConnectionInstance.setOAuthTokens).toHaveBeenCalledWith(refreshedTokens); + /** Runtime headers outrank the transport's, so the stale bearer has to be replaced too. */ + expect(mockConnectionInstance.setAuthorizationHeader).toHaveBeenCalledWith( + 'refreshed-obo-token', + ); + expect(mockConnectionInstance.emit).toHaveBeenCalledWith('oauthHandled'); + expect(mockConnectionInstance.emit).not.toHaveBeenCalledWith( + 'oauthFailed', + expect.anything(), + ); + expect(mockConnectionInstance.stopReconnecting).not.toHaveBeenCalled(); + }); + + it('retires the connection when the re-exchange fails', async () => { + resolveOboToken + .mockResolvedValueOnce(connectionTokens) + .mockRejectedValueOnce(new Error('IdP rejected the exchange')); + mockConnectionInstance.connect.mockImplementationOnce(async () => { + await getAuthHandler()(); + }); + + await createOboConnection(); + + expect(mockConnectionInstance.setOAuthTokens).not.toHaveBeenCalled(); + expect(mockConnectionInstance.stopReconnecting).toHaveBeenCalled(); + expect(mockConnectionInstance.emit).toHaveBeenCalledWith( + 'oauthFailed', + expect.objectContaining({ message: 'IdP rejected the exchange' }), + ); + }); + + it('surfaces why the re-exchange failed instead of the generic 401', async () => { + const authError = new Error('HTTP 401 Unauthorized'); + resolveOboToken + .mockResolvedValueOnce(connectionTokens) + .mockRejectedValueOnce(new Error('Your sign-in session expired. Please sign in again.')); + /** + * `connectClient` rethrows the server's original 401 after the handler fails, + * so without propagation the caller would only ever see `authError`. + */ + mockConnectionInstance.connect.mockImplementationOnce(async () => { + await getAuthHandler()(); + throw authError; + }); + mockConnectionInstance.isConnected.mockResolvedValue(false); + + await expect(createOboConnection()).rejects.toThrow( + 'Your sign-in session expired. Please sign in again.', + ); + }); + + it('re-exchanges at most once per connection attempt', async () => { + resolveOboToken + .mockResolvedValueOnce(connectionTokens) + .mockResolvedValueOnce(refreshedTokens); + mockConnectionInstance.connect.mockImplementationOnce(async () => { + const handler = getAuthHandler(); + await handler(); + await handler(); + }); + + await createOboConnection(); + + expect(resolveOboToken).toHaveBeenCalledTimes(2); + expect(mockConnectionInstance.stopReconnecting).toHaveBeenCalled(); + expect(mockConnectionInstance.emit).toHaveBeenCalledWith( + 'oauthFailed', + expect.objectContaining({ message: 'Refreshed OBO token was rejected' }), + ); + }); + + it('defers to a live request instead of re-exchanging on a cached connection', async () => { + await createOboConnection(); + expect(resolveOboToken).toHaveBeenCalledTimes(1); + + await getAuthHandler()(); + + /** The creating request is over, so `upstreamTokenProvider` no longer has a live session. */ + expect(resolveOboToken).toHaveBeenCalledTimes(1); + expect(mockConnectionInstance.setOAuthTokens).not.toHaveBeenCalled(); + expect(mockConnectionInstance.stopReconnecting).toHaveBeenCalled(); + expect(mockConnectionInstance.emit).toHaveBeenCalledWith( + 'oauthFailed', + expect.objectContaining({ message: 'OBO re-exchange requires a live request' }), + ); + }); + }); }); diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index df42a7bdb1..9fff1b915f 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -2659,6 +2659,7 @@ describe('MCPManager', () => { const mockConnection = { isConnected: jest.fn().mockResolvedValue(true), setRequestHeaders: jest.fn(), + setOAuthTokens: jest.fn(), timeout: 30000, client: { request: jest.fn().mockResolvedValue({ @@ -2695,6 +2696,7 @@ describe('MCPManager', () => { const sharedAppConnection = { isConnected: jest.fn().mockResolvedValue(true), setRequestHeaders: jest.fn(), + setOAuthTokens: jest.fn(), timeout: 30000, client: { request: jest.fn().mockResolvedValue({ @@ -2707,6 +2709,7 @@ describe('MCPManager', () => { const userConnection = { isConnected: jest.fn().mockResolvedValue(true), setRequestHeaders: jest.fn(), + setOAuthTokens: jest.fn(), timeout: 30000, client: { request: jest.fn().mockResolvedValue({ @@ -2804,6 +2807,7 @@ describe('MCPManager', () => { mockOboTokenResolver, mockUpstreamTokenProvider, undefined, + false, ); expect(appConnections.get).not.toHaveBeenCalled(); expect(getUserConnectionSpy).toHaveBeenCalled(); @@ -2934,6 +2938,123 @@ describe('MCPManager', () => { }); expect(mockResolveOboToken).not.toHaveBeenCalled(); }); + + it('re-exchanges past the token cache when the server rejects the bearer mid-call', async () => { + const authError = new Error('HTTP 401 Unauthorized'); + const rejectingConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + setOAuthTokens: jest.fn(), + isOAuthAuthenticationError: jest.fn().mockReturnValue(true), + timeout: 30000, + client: { + request: jest + .fn() + .mockRejectedValueOnce(authError) + .mockResolvedValueOnce({ + content: [{ type: 'text', text: 'Tool result' }], + isError: false, + }), + }, + } as unknown as MCPConnection; + + mockResolveOboToken + .mockResolvedValueOnce({ + access_token: 'rejected-obo-token', + token_type: 'Bearer', + obtained_at: Date.now(), + expires_at: Date.now() + 3600_000, + }) + .mockResolvedValueOnce({ + access_token: 'reminted-obo-token', + token_type: 'Bearer', + obtained_at: Date.now(), + expires_at: Date.now() + 3600_000, + }); + + mockAppConnections({ get: jest.fn().mockResolvedValue(rejectingConnection) }); + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + jest.spyOn(manager, 'getUserConnection').mockResolvedValue(rejectingConnection); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + oboTokenResolver: mockOboTokenResolver, + upstreamTokenProvider: mockUpstreamTokenProvider, + }); + + /** The rejected token is still inside its cached lifetime, so the cache is bypassed. */ + expect(mockResolveOboToken).toHaveBeenNthCalledWith( + 2, + mockUser, + serverConfig.obo, + mockOboTokenResolver, + mockUpstreamTokenProvider, + undefined, + true, + ); + expect(rejectingConnection.setRequestHeaders as jest.Mock).toHaveBeenLastCalledWith( + expect.objectContaining({ Authorization: 'Bearer reminted-obo-token' }), + ); + /** + * A legacy SSE event stream reads its bearer from `oauthTokens` at transport + * construction and never sees runtime request headers, so leaving the rejected + * credential there would 401 the next rebuild and retire a recovered connection. + */ + expect(rejectingConnection.setOAuthTokens as jest.Mock).toHaveBeenLastCalledWith( + expect.objectContaining({ access_token: 'reminted-obo-token' }), + ); + expect((rejectingConnection.client.request as jest.Mock).mock.calls).toHaveLength(2); + }); + + it('does not retry a non-auth failure', async () => { + const toolError = new Error('tool blew up'); + const failingConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + setOAuthTokens: jest.fn(), + isOAuthAuthenticationError: jest.fn().mockReturnValue(false), + timeout: 30000, + client: { request: jest.fn().mockRejectedValue(toolError) }, + } as unknown as MCPConnection; + + mockResolveOboToken.mockResolvedValue({ + access_token: 'fresh-obo-token', + token_type: 'Bearer', + obtained_at: Date.now(), + expires_at: Date.now() + 3600_000, + }); + + mockAppConnections({ get: jest.fn().mockResolvedValue(failingConnection) }); + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + jest.spyOn(manager, 'getUserConnection').mockResolvedValue(failingConnection); + + await expect( + manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + oboTokenResolver: mockOboTokenResolver, + upstreamTokenProvider: mockUpstreamTokenProvider, + }), + ).rejects.toThrow('tool blew up'); + + expect(mockResolveOboToken).toHaveBeenCalledTimes(1); + expect((failingConnection.client.request as jest.Mock).mock.calls).toHaveLength(1); + }); }); describe('getConnection', () => { diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 2ec69c9830..cd61548ec1 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -1158,6 +1158,30 @@ export class MCPConnection extends EventEmitter { return this.requestHeaders; } + /** + * Replaces only the runtime bearer, leaving the rest of the per-request headers + * a tool call installed intact. Runtime headers outrank the ones baked into the + * transport (see `buildFetchInit`), so a credential refreshed mid-connection has + * to land here as well or the stale `authorization` from the last tool call would + * keep winning on the retry. + */ + setAuthorizationHeader(accessToken: string): void { + this.requestHeaders = { + ...(this.requestHeaders ?? {}), + authorization: `Bearer ${accessToken}`, + }; + } + + /** + * Halts reconnect attempts for a credential this connection cannot refresh on its + * own. Retrying would replay the rejected bearer through every backoff step and + * spend the circuit breaker's cycle budget; the connection is instead left for its + * next borrower to dispose and rebuild with a live credential. + */ + stopReconnecting(): void { + this.shouldStopReconnecting = true; + } + constructor(params: MCPConnectionParams) { super(); this.options = params.serverConfig; diff --git a/packages/api/src/mcp/oauth/obo.spec.ts b/packages/api/src/mcp/oauth/obo.spec.ts index 9a837694b7..6f90b2c4aa 100644 --- a/packages/api/src/mcp/oauth/obo.spec.ts +++ b/packages/api/src/mcp/oauth/obo.spec.ts @@ -94,6 +94,30 @@ describe('resolveOboToken', () => { expect(result.access_token).toBe('exchanged-mcp-token'); }); + it('bypasses the resolver cache when forceRefresh is set', async () => { + /** + * A downstream 401 can arrive while the credential is still inside its cached + * lifetime (revoked, or its scopes invalidated), so a cached read would hand + * back the same rejected bearer. + */ + await resolveOboToken( + mockUser as IUser, + oboConfig, + mockResolver, + liveProvider, + undefined, + true, + ); + + expect(mockResolver).toHaveBeenCalledWith( + mockUser, + 'live-access-token', + 'api://mcp-server-id/Mcp.Tools.ReadWrite', + false, + undefined, + ); + }); + it('throws missing_upstream_token when federated fallback token is invalid', async () => { mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token', diff --git a/packages/api/src/mcp/oauth/obo.ts b/packages/api/src/mcp/oauth/obo.ts index a87e444e86..65d26ef9ee 100644 --- a/packages/api/src/mcp/oauth/obo.ts +++ b/packages/api/src/mcp/oauth/obo.ts @@ -191,6 +191,11 @@ function buildUpstreamTokenInfo( * itself carries that verified upstream bearer — still works. Browser requests * whose Express session was cleared reject in the provider instead of reaching * this fallback with a stale strategy-time snapshot. + * + * @param forceRefresh Bypasses the resolver's token cache. Set it when the downstream + * server has rejected the current credential: a revoked or scope-invalidated token is + * still inside its cached lifetime, so a cached read would hand back the same rejected + * bearer instead of minting a replacement. */ export async function resolveOboToken( user: IUser, @@ -198,6 +203,7 @@ export async function resolveOboToken( oboTokenResolver: OboTokenResolver, upstreamTokenProvider: UpstreamTokenProvider, identityContext?: AuthIdentityContext, + forceRefresh = false, ): Promise { let liveTokens: OIDCTokens | null; try { @@ -240,7 +246,7 @@ export async function resolveOboToken( user, tokenInfo.accessToken, oboConfig.scopes, - true, + !forceRefresh, identityContext, );