diff --git a/src/services/mcp/auth.refreshLock.test.ts b/src/services/mcp/auth.refreshLock.test.ts new file mode 100644 index 000000000..c3a0dd887 --- /dev/null +++ b/src/services/mcp/auth.refreshLock.test.ts @@ -0,0 +1,2131 @@ +import { + afterAll, + afterEach, + beforeEach, + expect, + mock, + test, +} from 'bun:test' +import { access, mkdir, mkdtemp, rm, utimes } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import type { + SecureStorage, + SecureStorageData, +} from '../../utils/secureStorage/index.js' +import { resetSettingsCache, setSessionSettingsCache } from '../../utils/settings/settingsCache.js' +import type { McpHTTPServerConfig } from './types.js' + +const realSecureStorage = await import('../../utils/secureStorage/index.js') +const realKeychainHelpers = await import( + '../../utils/secureStorage/macOsKeychainHelpers.js' +) +const realLockfile = await import('../../utils/lockfile.js') +const realSleep = await import('../../utils/sleep.js') +const realDebug = await import('../../utils/debug.js') +const realLog = await import('../../utils/log.js') +const realBrowser = await import('../../utils/browser.js') +const realXaaIdpLogin = await import('./xaaIdpLogin.js') +const originalLock = realLockfile.lock +const originalSleep = realSleep.sleep +const originalLogForDebugging = realDebug.logForDebugging +const originalLogMCPDebug = realLog.logMCPDebug +const originalOpenBrowser = realBrowser.openBrowser + +type SharedTestStorage = SecureStorage & { + getData(): SecureStorageData + setData(next: SecureStorageData): void + updateCalls: number + readCalls: number +} + +let activeStorage: SharedTestStorage +let clearCacheCalls = 0 +let lockAttempts: string[] = [] +let debugMessages: string[] = [] +let mcpDebugMessages: string[] = [] +let debugShouldThrow = false +let browserOpenCalls = 0 +let lockOverride: typeof originalLock | undefined +let sleepOverride: typeof originalSleep | undefined + +mock.module('../../utils/secureStorage/index.js', () => ({ + ...realSecureStorage, + getSecureStorage: () => activeStorage, +})) +mock.module('../../utils/secureStorage/macOsKeychainHelpers.js', () => ({ + ...realKeychainHelpers, + clearKeychainCache: () => { + clearCacheCalls++ + }, +})) +mock.module('../../utils/lockfile.js', () => ({ + ...realLockfile, + lock: (...args: Parameters) => { + lockAttempts.push(args[0]) + return (lockOverride ?? originalLock)(...args) + }, +})) +mock.module('../../utils/sleep.js', () => ({ + ...realSleep, + sleep: (...args: Parameters) => + (sleepOverride ?? originalSleep)(...args), +})) +mock.module('../../utils/debug.js', () => ({ + ...realDebug, + logForDebugging: (message: string) => { + debugMessages.push(message) + if (debugShouldThrow) throw new Error('simulated diagnostic failure') + }, +})) +mock.module('../../utils/log.js', () => ({ + ...realLog, + logMCPDebug: (_serverName: string, message: string) => { + mcpDebugMessages.push(message) + }, +})) +mock.module('../../utils/browser.js', () => ({ + ...realBrowser, + openBrowser: async () => { + browserOpenCalls++ + return true + }, +})) +mock.module('./xaaIdpLogin.js', () => ({ + ...realXaaIdpLogin, + getCachedIdpIdToken: (idpIssuer: string) => { + const issuer = idpIssuer.endsWith('/') ? idpIssuer : `${idpIssuer}/` + const entry = activeStorage.read()?.mcpXaaIdp?.[issuer] + if (!entry || entry.expiresAt <= Date.now() + 60_000) return undefined + return entry.idToken + }, + getIdpClientSecret: (idpIssuer: string) => { + const issuer = idpIssuer.endsWith('/') ? idpIssuer : `${idpIssuer}/` + return activeStorage.read()?.mcpXaaIdpConfig?.[issuer]?.clientSecret + }, + getXaaIdpSettings: () => ({ + issuer: IDP_ISSUER, + clientId: 'idp-client', + }), +})) + +const { ClaudeAuthProvider, getServerKey, wrapFetchWithStepUpDetection } = + await import('./auth.js') +const { getMcpRefreshLockPath } = await import('./refreshLock.js') + +const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer' +const ID_JAG_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag' +const IDP_ISSUER = 'https://idp.example.test' +const MCP_URL = 'https://mcp.example.test/mcp' +const AS_ISSUER = 'https://as.example.test' + +let configDir: string +let originalFetch: typeof globalThis.fetch +let originalXaaFlag: string | undefined +let originalConfigDir: string | undefined + +function cloneData(data: SecureStorageData | null): SecureStorageData | null { + return data === null ? null : structuredClone(data) +} + +function createSharedStorage( + initialData: SecureStorageData, + staleAsyncReads: Array, + options?: { updateOutcomes?: boolean[] }, +): SharedTestStorage { + let data = structuredClone(initialData) + const queuedReads = staleAsyncReads.map(value => structuredClone(value)) + const updateOutcomes = [...(options?.updateOutcomes ?? [])] + let observedClearCalls = clearCacheCalls + return { + name: 'shared-test-storage', + updateCalls: 0, + readCalls: 0, + getData: () => structuredClone(data), + setData: next => { + data = structuredClone(next) + }, + read() { + this.readCalls++ + return cloneData(data) + }, + readAsync: async () => { + if (clearCacheCalls > observedClearCalls) { + observedClearCalls = clearCacheCalls + return cloneData(data) + } + return cloneData(queuedReads.length > 0 ? queuedReads.shift()! : data) + }, + update(next) { + this.updateCalls++ + const success = updateOutcomes.shift() ?? true + if (success) data = structuredClone(next) + return { success } + }, + delete: () => true, + } +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise(resolvePromise => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2000 + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for test state') + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +function installSuccessfulXaaFetch(): { exchangeCalls: () => number } { + let exchangeCalls = 0 + globalThis.fetch = mock(async (input: string | URL, init?: RequestInit) => { + const url = input.toString() + if (url === `${IDP_ISSUER}/.well-known/openid-configuration`) { + return jsonResponse({ + issuer: IDP_ISSUER, + authorization_endpoint: `${IDP_ISSUER}/authorize`, + token_endpoint: `${IDP_ISSUER}/token`, + jwks_uri: `${IDP_ISSUER}/jwks`, + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + code_challenge_methods_supported: ['S256'], + }) + } + if (url.includes('/.well-known/oauth-protected-resource')) { + const resourceUrl = new URL(url) + return jsonResponse({ + resource: `${resourceUrl.origin}/mcp`, + authorization_servers: [AS_ISSUER], + }) + } + if (url === `${AS_ISSUER}/.well-known/oauth-authorization-server`) { + return jsonResponse({ + issuer: AS_ISSUER, + authorization_endpoint: `${AS_ISSUER}/authorize`, + token_endpoint: `${AS_ISSUER}/token`, + response_types_supported: ['code'], + grant_types_supported: [JWT_BEARER_GRANT], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + }) + } + if (url === `${IDP_ISSUER}/token` && init?.method === 'POST') { + exchangeCalls++ + return jsonResponse({ + access_token: 'id-jag-secret', + issued_token_type: ID_JAG_TOKEN_TYPE, + expires_in: 300, + }) + } + if (url === `${AS_ISSUER}/token` && init?.method === 'POST') { + return jsonResponse({ + access_token: 'winner-access-secret', + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:read', + }) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${url}`) + }) as unknown as typeof globalThis.fetch + return { exchangeCalls: () => exchangeCalls } +} + +function makeXaaFixture( + serverName = 'enterprise', + serverUrl = MCP_URL, +): { + config: McpHTTPServerConfig + initialData: SecureStorageData +} { + const config: McpHTTPServerConfig = { + type: 'http', + url: serverUrl, + oauth: { xaa: true, clientId: 'as-client' }, + } + const serverKey = getServerKey(serverName, config) + const initialData: SecureStorageData = { + mcpOAuth: { + [serverKey]: { + serverName, + serverUrl, + accessToken: 'stale-access-secret', + expiresAt: Date.now() + 60_000, + }, + }, + mcpOAuthClientConfig: { + [serverKey]: { clientSecret: 'as-client-secret' }, + }, + mcpXaaIdp: { + [`${IDP_ISSUER}/`]: { + idToken: 'id-token-secret', + expiresAt: Date.now() + 3_600_000, + }, + }, + } + return { config, initialData } +} + +beforeEach(async () => { + originalFetch = globalThis.fetch + originalXaaFlag = process.env.CLAUDE_CODE_ENABLE_XAA + originalConfigDir = process.env.OPENCLAUDE_CONFIG_DIR + process.env.CLAUDE_CODE_ENABLE_XAA = '1' + configDir = await mkdtemp(join(tmpdir(), 'openclaude-mcp-refresh-test-')) + process.env.OPENCLAUDE_CONFIG_DIR = configDir + clearCacheCalls = 0 + lockAttempts = [] + debugMessages = [] + mcpDebugMessages = [] + debugShouldThrow = false + browserOpenCalls = 0 + lockOverride = undefined + sleepOverride = undefined + setSessionSettingsCache({ + settings: { + xaaIdp: { issuer: IDP_ISSUER, clientId: 'idp-client' }, + } as never, + errors: [], + }) +}) + +afterEach(async () => { + globalThis.fetch = originalFetch + if (originalXaaFlag === undefined) { + delete process.env.CLAUDE_CODE_ENABLE_XAA + } else { + process.env.CLAUDE_CODE_ENABLE_XAA = originalXaaFlag + } + if (originalConfigDir === undefined) { + delete process.env.OPENCLAUDE_CONFIG_DIR + } else { + process.env.OPENCLAUDE_CONFIG_DIR = originalConfigDir + } + resetSettingsCache() + await rm(configDir, { recursive: true, force: true }) +}) + +afterAll(() => { + mock.module('../../utils/secureStorage/index.js', () => realSecureStorage) + mock.module( + '../../utils/secureStorage/macOsKeychainHelpers.js', + () => realKeychainHelpers, + ) + mock.module('../../utils/lockfile.js', () => ({ + ...realLockfile, + lock: originalLock, + })) + mock.module('../../utils/sleep.js', () => ({ + ...realSleep, + sleep: originalSleep, + })) + mock.module('../../utils/debug.js', () => ({ + ...realDebug, + logForDebugging: originalLogForDebugging, + })) + mock.module('../../utils/log.js', () => ({ + ...realLog, + logMCPDebug: originalLogMCPDebug, + })) + mock.module('../../utils/browser.js', () => ({ + ...realBrowser, + openBrowser: originalOpenBrowser, + })) + mock.module('./xaaIdpLogin.js', () => realXaaIdpLogin) +}) + +test( + 'two provider instances share one XAA exchange and reuse the persisted winner', + async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData, initialData]) + let cachedRead = cloneData(initialData) + let observedClearCalls = clearCacheCalls + activeStorage.read = function () { + this.readCalls++ + if (clearCacheCalls > observedClearCalls) { + observedClearCalls = clearCacheCalls + cachedRead = cloneData(this.getData()) + } + return cloneData(cachedRead) + } + const network = installSuccessfulXaaFetch() + const firstProvider = new ClaudeAuthProvider('enterprise', config) + const secondProvider = new ClaudeAuthProvider('enterprise', config) + + const [first, second] = await Promise.all([ + firstProvider.prepareRequest(), + secondProvider.prepareRequest(), + ]) + + expect(network.exchangeCalls()).toBe(1) + expect(activeStorage.updateCalls).toBe(1) + expect(clearCacheCalls).toBeGreaterThanOrEqual(2) + expect(first?.access_token).toBe('winner-access-secret') + expect(second?.access_token).toBe('winner-access-secret') + }, + 10_000, +) + +test('two XAA callers on one provider reuse its in-process refresh', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData, initialData]) + const network = installSuccessfulXaaFetch() + const provider = new ClaudeAuthProvider('enterprise', config) + + const [first, second] = await Promise.all([ + provider.prepareRequest(), + provider.prepareRequest(), + ]) + + expect(first?.access_token).toBe('winner-access-secret') + expect(second?.access_token).toBe('winner-access-secret') + expect(network.exchangeCalls()).toBe(1) + expect(activeStorage.updateCalls).toBe(1) + expect(lockAttempts).toHaveLength(1) +}) + +test('aborting one XAA caller does not cancel another in-process waiter', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData, initialData]) + const network = installSuccessfulXaaFetch() + const baseFetch = globalThis.fetch + const exchangeStarted = deferred() + const releaseExchange = deferred() + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if ( + input.toString() === `${IDP_ISSUER}/token` && + init?.method === 'POST' + ) { + exchangeStarted.resolve() + await new Promise((resolve, reject) => { + const onAbort = () => reject(init.signal?.reason) + init.signal?.addEventListener('abort', onAbort, { once: true }) + releaseExchange.promise.then(() => { + init.signal?.removeEventListener('abort', onAbort) + resolve() + }) + }) + } + return baseFetch(input, init) + }, + ) as unknown as typeof globalThis.fetch + const firstController = new AbortController() + const secondController = new AbortController() + const provider = new ClaudeAuthProvider('enterprise', config) + + const first = provider.prepareRequest(firstController.signal) + const second = provider.prepareRequest(secondController.signal) + const secondOutcome = second.catch(error => error as Error) + await exchangeStarted.promise + firstController.abort(new DOMException('first cancelled', 'AbortError')) + + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + releaseExchange.resolve() + expect(await secondOutcome).toMatchObject({ + access_token: 'winner-access-secret', + }) + expect(network.exchangeCalls()).toBe(1) + expect(activeStorage.updateCalls).toBe(1) +}) + +test('an already-aborted late waiter does not cancel the active XAA owner', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData, initialData]) + const secondReadStarted = deferred() + const resumeSecondRead = deferred() + let asyncReads = 0 + activeStorage.readAsync = async () => { + asyncReads++ + if (asyncReads === 3) { + secondReadStarted.resolve() + await resumeSecondRead.promise + } + return cloneData(initialData) + } + const network = installSuccessfulXaaFetch() + const baseFetch = globalThis.fetch + const exchangeStarted = deferred() + const releaseExchange = deferred() + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if ( + input.toString() === `${IDP_ISSUER}/token` && + init?.method === 'POST' + ) { + exchangeStarted.resolve() + await releaseExchange.promise + } + return baseFetch(input, init) + }, + ) as unknown as typeof globalThis.fetch + const controller = new AbortController() + const provider = new ClaudeAuthProvider('enterprise', config) + + const owner = provider.prepareRequest() + await exchangeStarted.promise + const lateWaiter = provider.prepareRequest(controller.signal) + await secondReadStarted.promise + controller.abort(new DOMException('late waiter cancelled', 'AbortError')) + resumeSecondRead.resolve() + + await expect(lateWaiter).rejects.toMatchObject({ name: 'AbortError' }) + releaseExchange.resolve() + expect((await owner)?.access_token).toBe('winner-access-secret') + expect(network.exchangeCalls()).toBe(1) + expect(activeStorage.updateCalls).toBe(1) +}) + +test( + 'normal OAuth refresh and XAA for the same server contend on one lock', + async () => { + const { config: xaaConfig, initialData: noRefreshData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + const normalView = structuredClone(noRefreshData) + normalView.mcpOAuth![serverKey]!.refreshToken = 'normal-refresh-secret' + normalView.mcpOAuth![serverKey]!.clientId = 'normal-client' + activeStorage = createSharedStorage(normalView, [normalView, noRefreshData]) + + const xaaNetwork = installSuccessfulXaaFetch() + const xaaFetch = globalThis.fetch + const normalStarted = deferred() + const releaseNormal = deferred() + const normalTokenEndpoint = 'https://normal-as.example.test/token' + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if ( + input.toString() === normalTokenEndpoint && + init?.method === 'POST' + ) { + normalStarted.resolve() + await releaseNormal.promise + return jsonResponse({ + access_token: 'normal-winner-access-secret', + refresh_token: 'normal-rotated-refresh-secret', + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:read', + }) + } + return xaaFetch(input, init) + }, + ) as unknown as typeof globalThis.fetch + + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const normalProvider = new ClaudeAuthProvider('enterprise', normalConfig) + normalProvider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: normalTokenEndpoint, + response_types_supported: ['code'], + } as never) + const xaaProvider = new ClaudeAuthProvider('enterprise', xaaConfig) + + const normalPromise = normalProvider.prepareRequest() + await normalStarted.promise + const xaaPromise = xaaProvider.prepareRequest() + await waitFor(() => lockAttempts.length >= 2) + expect(new Set(lockAttempts).size).toBe(1) + releaseNormal.resolve() + + const [normalTokens, xaaTokens] = await Promise.all([ + normalPromise, + xaaPromise, + ]) + + expect(normalTokens?.access_token).toBe('normal-winner-access-secret') + expect(xaaTokens?.access_token).toBe('normal-winner-access-secret') + expect(xaaNetwork.exchangeCalls()).toBe(0) + expect(activeStorage.updateCalls).toBe(1) + }, + 10_000, +) + +test('two reactive 401 refreshes share the server lock and persisted winner', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'normal-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, [initialData, initialData]) + const tokenEndpoint = 'https://normal-as.example.test/token' + let refreshCalls = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if (input.toString() === tokenEndpoint && init?.method === 'POST') { + refreshCalls++ + return jsonResponse({ + access_token: 'normal-winner-access-secret', + refresh_token: 'normal-rotated-refresh-secret', + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:read', + }) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${input}`) + }, + ) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const metadata = { + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never + const firstProvider = new ClaudeAuthProvider('enterprise', normalConfig) + const secondProvider = new ClaudeAuthProvider('enterprise', normalConfig) + firstProvider.setMetadata(metadata) + secondProvider.setMetadata(metadata) + const retryAuthorizationHeaders: Array = [] + const unauthorizedOnce = () => { + let calls = 0 + return async (_url: string | URL, init?: RequestInit) => { + if (calls++ === 0) return new Response(null, { status: 401 }) + const authorization = new Headers(init?.headers).get('Authorization') + retryAuthorizationHeaders.push(authorization) + return new Response(null, { + status: + authorization === 'Bearer normal-winner-access-secret' ? 200 : 401, + }) + } + } + + const responses = await Promise.all([ + wrapFetchWithStepUpDetection(unauthorizedOnce(), firstProvider)(MCP_URL), + wrapFetchWithStepUpDetection(unauthorizedOnce(), secondProvider)(MCP_URL), + ]) + const [first, second] = await Promise.all([ + firstProvider.tokens(), + secondProvider.tokens(), + ]) + + expect(refreshCalls).toBe(1) + expect(activeStorage.updateCalls).toBe(1) + expect(responses.map(response => response.status)).toEqual([200, 200]) + expect(retryAuthorizationHeaders).toEqual([ + 'Bearer normal-winner-access-secret', + 'Bearer normal-winner-access-secret', + ]) + expect(first?.access_token).toBe('normal-winner-access-secret') + expect(second?.access_token).toBe('normal-winner-access-secret') +}) + +test('overlapping reactive 401 handlers retain per-call force intent', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'normal-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, []) + const tokenEndpoint = 'https://normal-as.example.test/token' + const refreshStarted = deferred() + const releaseRefresh = deferred() + let refreshCalls = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if (input.toString() === tokenEndpoint && init?.method === 'POST') { + refreshCalls++ + refreshStarted.resolve() + await releaseRefresh.promise + return jsonResponse({ + access_token: 'normal-winner-access-secret', + refresh_token: 'normal-rotated-refresh-secret', + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:read', + }) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${input}`) + }, + ) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + provider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never) + const makeResourceFetch = () => { + let calls = 0 + return async (_url: string | URL, init?: RequestInit) => { + if (calls++ === 0) return new Response(null, { status: 401 }) + const authorization = new Headers(init?.headers).get('Authorization') + return new Response(null, { + status: + authorization === 'Bearer normal-winner-access-secret' ? 200 : 401, + }) + } + } + + const first = wrapFetchWithStepUpDetection(makeResourceFetch(), provider)( + MCP_URL, + ) + await refreshStarted.promise + const second = wrapFetchWithStepUpDetection(makeResourceFetch(), provider)( + MCP_URL, + ) + releaseRefresh.resolve() + expect((await first).status).toBe(200) + expect((await second).status).toBe(200) + expect(refreshCalls).toBe(1) + expect(activeStorage.updateCalls).toBe(1) +}) + +test('a delayed 401 reuses a newer stored access token without refreshing', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'old-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, []) + const tokenEndpoint = 'https://normal-as.example.test/token' + let refreshCalls = 0 + globalThis.fetch = mock(async () => { + refreshCalls++ + throw new Error('A fresh stored winner must skip the token endpoint') + }) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + provider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never) + let resourceCalls = 0 + const wrappedFetch = wrapFetchWithStepUpDetection( + async (_url, init) => { + resourceCalls++ + if (resourceCalls === 1) { + const winnerData = activeStorage.getData() + winnerData.mcpOAuth![serverKey]!.accessToken = + 'external-winner-access-secret' + winnerData.mcpOAuth![serverKey]!.refreshToken = + 'external-winner-refresh-secret' + winnerData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage.setData(winnerData) + return new Response(null, { status: 401 }) + } + const authorization = new Headers(init?.headers).get('Authorization') + return new Response(null, { + status: + authorization === 'Bearer external-winner-access-secret' ? 200 : 401, + }) + }, + provider, + { resourceUrl: MCP_URL, providerOwnsAuthorization: true }, + ) + + const response = await wrappedFetch(MCP_URL, { + headers: { Authorization: 'Bearer stale-access-secret' }, + }) + + expect(response.status).toBe(200) + expect(resourceCalls).toBe(2) + expect(refreshCalls).toBe(0) + expect(activeStorage.updateCalls).toBe(0) +}) + +test('reactive recovery preserves explicit Authorization precedence', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.accessToken = 'stored-oauth-access-secret' + initialData.mcpOAuth![serverKey]!.refreshToken = 'stored-oauth-refresh-secret' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, [initialData]) + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + let resourceCalls = 0 + const seenAuthorization: Array = [] + const wrappedFetch = wrapFetchWithStepUpDetection( + async (_url, init) => { + resourceCalls++ + seenAuthorization.push( + new Headers(init?.headers).get('Authorization'), + ) + return new Response(null, { status: 401 }) + }, + provider, + { allowUnauthorizedRefresh: false }, + ) + + const response = await wrappedFetch(MCP_URL, { + headers: { Authorization: 'Bearer configured-access-secret' }, + }) + + expect(response.status).toBe(401) + expect(resourceCalls).toBe(1) + expect(seenAuthorization).toEqual(['Bearer configured-access-secret']) + expect(lockAttempts).toHaveLength(0) +}) + +test('request auth preserves caller-owned Bearer and Basic credentials by default', async () => { + const seenAuthorization: Array = [] + let prepareCalls = 0 + const provider = { + prepareRequest: async () => { + prepareCalls++ + return { access_token: 'provider-access-secret' } + }, + refreshAfterUnauthorized: async () => undefined, + markStepUpPending: () => {}, + } + const wrappedFetch = wrapFetchWithStepUpDetection( + async (_url, init) => { + seenAuthorization.push( + new Headers(init?.headers).get('Authorization'), + ) + return new Response(null, { status: 200 }) + }, + provider as never, + { resourceUrl: MCP_URL }, + ) + + await wrappedFetch(MCP_URL, { + headers: { Authorization: 'Bearer configured-access-secret' }, + }) + await wrappedFetch(`${AS_ISSUER}/token`, { + method: 'POST', + headers: { Authorization: 'Basic Y2xpZW50OnNlY3JldA==' }, + }) + + expect(seenAuthorization).toEqual([ + 'Bearer configured-access-secret', + 'Basic Y2xpZW50OnNlY3JldA==', + ]) + expect(prepareCalls).toBe(0) +}) + +test('a rejected retry preserves the original 401 and cancels the retry body', async () => { + let retryBodyCancelled = false + const original = new Response('original challenge', { + status: 401, + headers: { + 'WWW-Authenticate': + 'Bearer resource_metadata="https://mcp.example.test/oauth"', + }, + }) + const retry = new Response( + new ReadableStream({ + cancel: () => { + retryBodyCancelled = true + }, + }), + { status: 401 }, + ) + let calls = 0 + const provider = { + prepareRequest: async () => ({ access_token: 'rejected-access-secret' }), + refreshAfterUnauthorized: async () => ({ + access_token: 'replacement-access-secret', + }), + markStepUpPending: () => {}, + } + const wrappedFetch = wrapFetchWithStepUpDetection( + async () => (++calls === 1 ? original : retry), + provider as never, + { resourceUrl: MCP_URL, providerOwnsAuthorization: true }, + ) + + const response = await wrappedFetch(MCP_URL, { + headers: { Authorization: 'Bearer rejected-access-secret' }, + }) + + expect(response).toBe(original) + expect(response.headers.get('WWW-Authenticate')).toContain( + 'resource_metadata', + ) + expect(retryBodyCancelled).toBe(true) + expect(calls).toBe(2) +}) + +test('a successful 401 retry cancels the superseded original body', async () => { + let originalBodyCancelled = false + const original = new Response( + new ReadableStream({ + cancel: () => { + originalBodyCancelled = true + }, + }), + { status: 401 }, + ) + const recovered = new Response(null, { status: 200 }) + let calls = 0 + const provider = { + prepareRequest: async () => ({ access_token: 'rejected-access-secret' }), + refreshAfterUnauthorized: async () => ({ + access_token: 'replacement-access-secret', + }), + markStepUpPending: () => {}, + } + const wrappedFetch = wrapFetchWithStepUpDetection( + async () => (++calls === 1 ? original : recovered), + provider as never, + { resourceUrl: MCP_URL, providerOwnsAuthorization: true }, + ) + + expect( + ( + await wrappedFetch(MCP_URL, { + headers: { Authorization: 'Bearer rejected-access-secret' }, + }) + ).status, + ).toBe(200) + expect(originalBodyCancelled).toBe(true) +}) + +test('reactive recovery bypasses a stale null cache and reuses a disk winner', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.accessToken = + 'external-winner-access-secret' + initialData.mcpOAuth![serverKey]!.refreshToken = + 'external-winner-refresh-secret' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, [null]) + const provider = new ClaudeAuthProvider('enterprise', { + type: 'http', + url: MCP_URL, + }) + + const tokens = await provider.refreshAfterUnauthorized( + undefined, + 'rejected-access-secret', + ) + + expect(tokens?.access_token).toBe('external-winner-access-secret') + expect(clearCacheCalls).toBeGreaterThanOrEqual(1) + expect(lockAttempts).toHaveLength(0) +}) + +test('bearer-less reactive recovery also bypasses a stale null cache', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.accessToken = + 'external-winner-access-secret' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, [null]) + const provider = new ClaudeAuthProvider('enterprise', { + type: 'http', + url: MCP_URL, + }) + + const tokens = await provider.refreshAfterUnauthorized(undefined, undefined) + + expect(tokens?.access_token).toBe('external-winner-access-secret') + expect(clearCacheCalls).toBeGreaterThanOrEqual(1) + expect(lockAttempts).toHaveLength(0) +}) + +test('expired or malformed no-refresh records do not suppress explicit fallback auth', async () => { + const { config, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', config) + delete initialData.mcpOAuth![serverKey]!.refreshToken + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() - 60_000 + activeStorage = createSharedStorage(initialData, [initialData, initialData]) + const provider = new ClaudeAuthProvider('enterprise', { + type: 'http', + url: MCP_URL, + }) + + expect(await provider.tokens()).toBeUndefined() + expect(await provider.prepareRequest()).toBeUndefined() + + const malformedData = structuredClone(initialData) + malformedData.mcpOAuth![serverKey]!.expiresAt = Number.NaN + activeStorage = createSharedStorage(malformedData, [malformedData, malformedData]) + const malformedProvider = new ClaudeAuthProvider('enterprise', { + type: 'http', + url: MCP_URL, + }) + expect(await malformedProvider.tokens()).toBeUndefined() + expect(await malformedProvider.prepareRequest()).toBeUndefined() +}) + +test('transport-owned provider redirect handling never opens a browser', async () => { + activeStorage = createSharedStorage({}, []) + const config: McpHTTPServerConfig = { type: 'http', url: MCP_URL } + const transportProvider = new ClaudeAuthProvider('enterprise', config) + + await transportProvider.redirectToAuthorization( + new URL(`${AS_ISSUER}/authorize?scope=mcp%3Aread`), + ) + expect(browserOpenCalls).toBe(0) + + const interactiveProvider = new ClaudeAuthProvider( + 'enterprise', + config, + 'http://127.0.0.1:31337/callback', + true, + ) + await interactiveProvider.redirectToAuthorization( + new URL(`${AS_ISSUER}/authorize?scope=mcp%3Aread`), + ) + expect(browserOpenCalls).toBe(1) +}) + +test('abort during 401 recovery cancels the abandoned original response body', async () => { + let originalBodyCancelled = false + const original = new Response( + new ReadableStream({ + cancel: () => { + originalBodyCancelled = true + }, + }), + { status: 401 }, + ) + const controller = new AbortController() + const provider = { + prepareRequest: async () => ({ access_token: 'rejected-access-secret' }), + refreshAfterUnauthorized: async () => { + controller.abort(new DOMException('cancelled', 'AbortError')) + throw controller.signal.reason + }, + markStepUpPending: () => {}, + } + const wrappedFetch = wrapFetchWithStepUpDetection( + async () => original, + provider as never, + { resourceUrl: MCP_URL, providerOwnsAuthorization: true }, + ) + + await expect( + wrappedFetch(MCP_URL, { + headers: { Authorization: 'Bearer rejected-access-secret' }, + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(originalBodyCancelled).toBe(true) +}) + +test('a failed second reactive refresh remains a soft failure', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'normal-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, [initialData]) + let acquisitionCalls = 0 + lockOverride = async (...args) => { + acquisitionCalls++ + if (acquisitionCalls === 1) return originalLock(...args) + throw Object.assign(new Error('held'), { code: 'ELOCKED' }) + } + sleepOverride = async () => {} + const tokenEndpoint = 'https://normal-as.example.test/token' + globalThis.fetch = mock(async input => { + if (input.toString() === tokenEndpoint) { + return jsonResponse({ + access_token: 'stale-access-secret', + refresh_token: 'normal-rotated-refresh-secret', + token_type: 'Bearer', + expires_in: 3600, + }) + } + throw new Error(`Unexpected fetch: ${input}`) + }) as unknown as typeof globalThis.fetch + const provider = new ClaudeAuthProvider('enterprise', { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + }) + provider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never) + + const tokens = await provider.refreshAfterUnauthorized( + undefined, + 'stale-access-secret', + ) + + expect(tokens).toBeUndefined() + expect(acquisitionCalls).toBe(6) +}) + +test('silent XAA refresh preserves cached protected-resource discovery state', async () => { + const { config, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', config) + initialData.mcpOAuth![serverKey]!.discoveryState = { + authorizationServerUrl: 'https://old-as.example.test', + resourceMetadataUrl: + 'https://mcp.example.test/.well-known/oauth-protected-resource', + } + activeStorage = createSharedStorage(initialData, [initialData]) + installSuccessfulXaaFetch() + const provider = new ClaudeAuthProvider('enterprise', config) + + await provider.prepareRequest() + + expect( + activeStorage.getData().mcpOAuth?.[serverKey]?.discoveryState + ?.resourceMetadataUrl, + ).toBe('https://mcp.example.test/.well-known/oauth-protected-resource') +}) + +test('a reactive waiter never retries the bearer rejected during a shared refresh', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'normal-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + activeStorage = createSharedStorage(initialData, [initialData, initialData]) + const tokenEndpoint = 'https://normal-as.example.test/token' + const refreshStarted = deferred() + const releaseRefresh = deferred() + let refreshCalls = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if (input.toString() === tokenEndpoint && init?.method === 'POST') { + refreshCalls++ + refreshStarted.resolve() + if (refreshCalls === 1) await releaseRefresh.promise + return jsonResponse({ + access_token: + refreshCalls === 1 + ? 'stale-access-secret' + : 'second-generation-access-secret', + refresh_token: `normal-rotated-refresh-secret-${refreshCalls}`, + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:read', + }) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${input}`) + }, + ) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + provider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never) + let resourceCalls = 0 + const wrappedFetch = wrapFetchWithStepUpDetection( + async (_url, init) => { + resourceCalls++ + if (resourceCalls === 1) return new Response(null, { status: 401 }) + const authorization = new Headers(init?.headers).get('Authorization') + return new Response(null, { + status: + authorization === 'Bearer second-generation-access-secret' + ? 200 + : 401, + }) + }, + provider, + { resourceUrl: MCP_URL, providerOwnsAuthorization: true }, + ) + + const proactive = provider.prepareRequest() + await refreshStarted.promise + const reactive = wrappedFetch(MCP_URL, { + headers: { Authorization: 'Bearer stale-access-secret' }, + }) + await waitFor( + () => + ( + provider as unknown as { + _refreshInProgress?: { waiters: number } + } + )._refreshInProgress?.waiters === 2, + ) + releaseRefresh.resolve() + + expect((await proactive)?.access_token).toBe('stale-access-secret') + expect((await reactive).status).toBe(200) + expect(resourceCalls).toBe(2) + expect(refreshCalls).toBe(2) + expect(activeStorage.updateCalls).toBe(2) +}) + +test('concurrent failed reactive refreshes never expose refresh credentials', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'normal-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, [initialData], { + updateOutcomes: [false], + }) + const tokenEndpoint = 'https://normal-as.example.test/token' + let refreshCalls = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if (input.toString() === tokenEndpoint && init?.method === 'POST') { + refreshCalls++ + return jsonResponse({ + access_token: 'unpersisted-access-secret', + refresh_token: 'unpersisted-refresh-secret', + token_type: 'Bearer', + expires_in: 3600, + }) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${input}`) + }, + ) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + provider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never) + const unauthorized = async () => new Response(null, { status: 401 }) + + const responses = await Promise.all([ + wrapFetchWithStepUpDetection(unauthorized, provider)(MCP_URL), + wrapFetchWithStepUpDetection(unauthorized, provider)(MCP_URL), + ]) + const sdkTokens = await Promise.all([ + provider.tokens(), + provider.tokens(), + provider.tokens(), + ]) + + expect(responses.map(response => response.status)).toEqual([401, 401]) + expect(refreshCalls).toBe(1) + expect(sdkTokens.map(tokens => tokens?.access_token)).toEqual([ + 'stale-access-secret', + 'stale-access-secret', + 'stale-access-secret', + ]) + expect(sdkTokens.every(tokens => tokens?.refresh_token === undefined)).toBe( + true, + ) +}) + +test('SDK-facing token reads never expose refresh credentials', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'sdk-must-not-see-secret' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, [initialData]) + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + + const tokens = await provider.tokens() + + expect(tokens?.access_token).toBe('stale-access-secret') + expect(tokens?.refresh_token).toBeUndefined() +}) + +test('a failed recovery does not poison a later request using an external winner', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'normal-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, [initialData], { + updateOutcomes: [false], + }) + const tokenEndpoint = 'https://normal-as.example.test/token' + let refreshCalls = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if (input.toString() === tokenEndpoint && init?.method === 'POST') { + refreshCalls++ + return jsonResponse({ + access_token: 'unpersisted-access-secret', + refresh_token: 'unpersisted-refresh-secret', + token_type: 'Bearer', + expires_in: 3600, + }) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${input}`) + }, + ) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + provider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never) + const failed = await wrapFetchWithStepUpDetection( + async () => new Response(null, { status: 401 }), + provider, + { resourceUrl: MCP_URL, providerOwnsAuthorization: true }, + )(MCP_URL, { + headers: { Authorization: 'Bearer stale-access-secret' }, + }) + expect(failed.status).toBe(401) + + const winnerData = activeStorage.getData() + winnerData.mcpOAuth![serverKey]!.accessToken = + 'external-winner-access-secret' + winnerData.mcpOAuth![serverKey]!.refreshToken = + 'external-winner-refresh-secret' + winnerData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage.setData(winnerData) + let resourceCalls = 0 + const recovered = await wrapFetchWithStepUpDetection( + async (_url, init) => { + resourceCalls++ + const authorization = new Headers(init?.headers).get('Authorization') + return new Response(null, { + status: + authorization === 'Bearer external-winner-access-secret' ? 200 : 401, + }) + }, + provider, + { resourceUrl: MCP_URL, providerOwnsAuthorization: true }, + )(MCP_URL, { + headers: { Authorization: 'Bearer stale-access-secret' }, + }) + + expect(recovered.status).toBe(200) + expect(resourceCalls).toBe(1) + expect(refreshCalls).toBe(1) + expect((await provider.tokens())?.access_token).toBe( + 'external-winner-access-secret', + ) +}) + +test('different MCP servers can run XAA exchanges concurrently', async () => { + const firstFixture = makeXaaFixture( + 'enterprise-one', + 'https://mcp-one.example.test/mcp', + ) + const secondFixture = makeXaaFixture( + 'enterprise-two', + 'https://mcp-two.example.test/mcp', + ) + const sharedData: SecureStorageData = { + mcpOAuth: { + ...firstFixture.initialData.mcpOAuth, + ...secondFixture.initialData.mcpOAuth, + }, + mcpOAuthClientConfig: { + ...firstFixture.initialData.mcpOAuthClientConfig, + ...secondFixture.initialData.mcpOAuthClientConfig, + }, + mcpXaaIdp: firstFixture.initialData.mcpXaaIdp, + } + activeStorage = createSharedStorage(sharedData, [sharedData, sharedData]) + const network = installSuccessfulXaaFetch() + const baseFetch = globalThis.fetch + const bothExchangesStarted = deferred() + let activeExchanges = 0 + let maxActiveExchanges = 0 + let exchangeArrivals = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if ( + input.toString() === `${IDP_ISSUER}/token` && + init?.method === 'POST' + ) { + exchangeArrivals++ + activeExchanges++ + maxActiveExchanges = Math.max(maxActiveExchanges, activeExchanges) + if (exchangeArrivals === 2) bothExchangesStarted.resolve() + await bothExchangesStarted.promise + try { + return await baseFetch(input, init) + } finally { + activeExchanges-- + } + } + return baseFetch(input, init) + }, + ) as unknown as typeof globalThis.fetch + const firstProvider = new ClaudeAuthProvider( + 'enterprise-one', + firstFixture.config, + ) + const secondProvider = new ClaudeAuthProvider( + 'enterprise-two', + secondFixture.config, + ) + + const [first, second] = await Promise.all([ + firstProvider.prepareRequest(), + secondProvider.prepareRequest(), + ]) + + expect(first?.access_token).toBe('winner-access-secret') + expect(second?.access_token).toBe('winner-access-secret') + expect(network.exchangeCalls()).toBe(2) + expect(maxActiveExchanges).toBe(2) + expect(new Set(lockAttempts).size).toBe(2) + const persisted = activeStorage.getData().mcpOAuth + expect( + persisted?.[getServerKey('enterprise-one', firstFixture.config)] + ?.accessToken, + ).toBe('winner-access-secret') + expect( + persisted?.[getServerKey('enterprise-two', secondFixture.config)] + ?.accessToken, + ).toBe('winner-access-secret') +}) + +test('credentials and XAA prerequisites are re-checked after waiting for the lock', async () => { + const { config, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', config) + activeStorage = createSharedStorage(initialData, [initialData]) + const network = installSuccessfulXaaFetch() + const releaseBlocker = await originalLock( + getMcpRefreshLockPath(serverKey, configDir), + { realpath: false }, + ) + const retryDelayStarted = deferred() + const resumeRetry = deferred() + sleepOverride = async () => { + retryDelayStarted.resolve() + await resumeRetry.promise + } + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = provider.prepareRequest() + await retryDelayStarted.promise + const removedCredentials = structuredClone(initialData) + delete removedCredentials.mcpOAuth?.[serverKey] + activeStorage.setData(removedCredentials) + process.env.CLAUDE_CODE_ENABLE_XAA = '0' + await releaseBlocker() + resumeRetry.resolve() + + const result = await tokens + expect(result).toBeUndefined() + expect(network.exchangeCalls()).toBe(0) + expect(activeStorage.updateCalls).toBe(0) +}) + +test('normal OAuth does not return a credential record removed while waiting', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'normal-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + activeStorage = createSharedStorage(initialData, [initialData]) + const releaseBlocker = await originalLock( + getMcpRefreshLockPath(serverKey, configDir), + { realpath: false }, + ) + const retryDelayStarted = deferred() + const resumeRetry = deferred() + sleepOverride = async () => { + retryDelayStarted.resolve() + await resumeRetry.promise + } + let networkCalls = 0 + globalThis.fetch = mock(async () => { + networkCalls++ + throw new Error('Refresh network must not run without current credentials') + }) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + + const tokens = provider.prepareRequest() + await retryDelayStarted.promise + const removedCredentials = structuredClone(initialData) + delete removedCredentials.mcpOAuth?.[serverKey] + activeStorage.setData(removedCredentials) + await releaseBlocker() + resumeRetry.resolve() + + expect(await tokens).toBeUndefined() + expect(networkCalls).toBe(0) + expect(activeStorage.updateCalls).toBe(0) +}) + +test( + 'a failed XAA owner releases the lock so a waiter can exchange and persist', + async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData, initialData]) + const successfulFetch = installSuccessfulXaaFetch() + const baseFetch = globalThis.fetch + let idpAttempts = 0 + const firstAttemptStarted = deferred() + const releaseFirstAttempt = deferred() + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if ( + input.toString() === `${IDP_ISSUER}/token` && + init?.method === 'POST' && + idpAttempts++ === 0 + ) { + firstAttemptStarted.resolve() + await releaseFirstAttempt.promise + return jsonResponse({ error: 'temporarily_unavailable' }, 503) + } + return baseFetch(input, init) + }, + ) as unknown as typeof globalThis.fetch + + const firstProvider = new ClaudeAuthProvider('enterprise', config) + const secondProvider = new ClaudeAuthProvider('enterprise', config) + const firstPromise = firstProvider.prepareRequest() + await firstAttemptStarted.promise + const secondPromise = secondProvider.prepareRequest() + await waitFor(() => lockAttempts.length >= 2) + releaseFirstAttempt.resolve() + const [first, second] = await Promise.all([ + firstPromise, + secondPromise, + ]) + + expect(idpAttempts).toBe(2) + expect(successfulFetch.exchangeCalls()).toBe(1) + expect(activeStorage.updateCalls).toBe(1) + expect(first?.access_token).toBe('stale-access-secret') + expect(second?.access_token).toBe('winner-access-secret') + }, + 10_000, +) + +test('a normal OAuth storage failure is not returned as refresh success', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.refreshToken = 'normal-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + activeStorage = createSharedStorage(initialData, [initialData, initialData], { + updateOutcomes: [false, true], + }) + const tokenEndpoint = 'https://normal-as.example.test/token' + let refreshCalls = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if (input.toString() === tokenEndpoint && init?.method === 'POST') { + refreshCalls++ + return jsonResponse({ + access_token: 'normal-winner-access-secret', + refresh_token: 'normal-rotated-refresh-secret', + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:read', + }) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${input}`) + }, + ) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const metadata = { + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never + const firstProvider = new ClaudeAuthProvider('enterprise', normalConfig) + firstProvider.setMetadata(metadata) + + const failed = await firstProvider.prepareRequest() + + expect(failed?.access_token).toBe('stale-access-secret') + expect(activeStorage.getData().mcpOAuth?.[serverKey]?.accessToken).toBe( + 'stale-access-secret', + ) + + const secondProvider = new ClaudeAuthProvider('enterprise', normalConfig) + secondProvider.setMetadata(metadata) + const succeeded = await secondProvider.prepareRequest() + + expect(succeeded?.access_token).toBe('normal-winner-access-secret') + expect(refreshCalls).toBe(2) + expect(activeStorage.updateCalls).toBe(2) +}) + +test('normal OAuth refresh does not log an echoed refresh token', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + const refreshToken = 'opaque-refresh-value-7Qm2' + initialData.mcpOAuth![serverKey]!.refreshToken = refreshToken + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + activeStorage = createSharedStorage(initialData, [initialData]) + const tokenEndpoint = 'https://normal-as.example.test/token' + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if (input.toString() === tokenEndpoint && init?.method === 'POST') { + return jsonResponse( + { + error: 'invalid_grant', + error_description: `provider echoed ${refreshToken}`, + }, + 400, + ) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${input}`) + }, + ) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + provider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never) + + await provider.prepareRequest() + + expect(mcpDebugMessages.some(message => message.includes('invalid_grant'))) + .toBe(true) + expect(mcpDebugMessages.join('\n')).not.toContain(refreshToken) +}) + +test('reactive invalid_grant does not reuse the rejected access token', async () => { + const { config: xaaConfig, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', xaaConfig) + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + initialData.mcpOAuth![serverKey]!.refreshToken = 'invalid-refresh-secret' + initialData.mcpOAuth![serverKey]!.clientId = 'normal-client' + activeStorage = createSharedStorage(initialData, [initialData]) + const tokenEndpoint = 'https://normal-as.example.test/token' + let refreshCalls = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if (input.toString() === tokenEndpoint && init?.method === 'POST') { + refreshCalls++ + const externallyChanged = activeStorage.getData() + externallyChanged.mcpOAuth![serverKey]!.refreshToken = + 'externally-rotated-refresh-secret' + activeStorage.setData(externallyChanged) + return jsonResponse({ error: 'invalid_grant' }, 400) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${input}`) + }, + ) as unknown as typeof globalThis.fetch + const normalConfig: McpHTTPServerConfig = { + type: 'http', + url: MCP_URL, + oauth: { clientId: 'normal-client' }, + } + const provider = new ClaudeAuthProvider('enterprise', normalConfig) + provider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never) + let resourceCalls = 0 + const wrappedFetch = wrapFetchWithStepUpDetection( + async () => { + resourceCalls++ + return new Response(null, { status: 401 }) + }, + provider, + { resourceUrl: MCP_URL, providerOwnsAuthorization: true }, + ) + + const response = await wrappedFetch(MCP_URL, { + headers: { Authorization: 'Bearer stale-access-secret' }, + }) + + expect(response.status).toBe(401) + expect(refreshCalls).toBe(1) + expect(resourceCalls).toBe(1) + expect(activeStorage.getData().mcpOAuth?.[serverKey]?.accessToken).toBe('') + expect(activeStorage.getData().mcpOAuth?.[serverKey]?.refreshToken).toBeUndefined() +}) + +test('reactive XAA fallback does not reuse the rejected access token', async () => { + const { config, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', config) + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + const latestData = structuredClone(initialData) + latestData.mcpOAuth![serverKey]!.refreshToken = 'appeared-refresh-secret' + activeStorage = createSharedStorage(latestData, [initialData]) + const tokenEndpoint = 'https://normal-as.example.test/token' + let refreshCalls = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if (input.toString() === tokenEndpoint && init?.method === 'POST') { + refreshCalls++ + return jsonResponse({ error: 'invalid_grant' }, 400) + } + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${input}`) + }, + ) as unknown as typeof globalThis.fetch + const provider = new ClaudeAuthProvider('enterprise', config) + provider.setMetadata({ + issuer: 'https://normal-as.example.test', + authorization_endpoint: 'https://normal-as.example.test/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'], + } as never) + let resourceCalls = 0 + const wrappedFetch = wrapFetchWithStepUpDetection( + async () => { + resourceCalls++ + return new Response(null, { status: 401 }) + }, + provider, + { resourceUrl: MCP_URL, providerOwnsAuthorization: true }, + ) + + const response = await wrappedFetch(MCP_URL, { + headers: { Authorization: 'Bearer stale-access-secret' }, + }) + + expect(response.status).toBe(401) + expect(refreshCalls).toBe(1) + expect(resourceCalls).toBe(1) + expect(activeStorage.getData().mcpOAuth?.[serverKey]?.accessToken).toBe('') + expect(activeStorage.getData().mcpOAuth?.[serverKey]?.refreshToken).toBeUndefined() +}) + +test('XAA refresh does not log an echoed identity token', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData]) + installSuccessfulXaaFetch() + const baseFetch = globalThis.fetch + const identityToken = 'id-token-secret' + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if ( + input.toString() === `${IDP_ISSUER}/token` && + init?.method === 'POST' + ) { + return jsonResponse( + { + error: 'invalid_grant', + error_description: `provider echoed ${identityToken}`, + }, + 400, + ) + } + return baseFetch(input, init) + }, + ) as unknown as typeof globalThis.fetch + const provider = new ClaudeAuthProvider('enterprise', config) + + await provider.prepareRequest() + + expect(mcpDebugMessages.some(message => message.includes('XAA'))).toBe(true) + expect(mcpDebugMessages.join('\n')).not.toContain(identityToken) +}) + +test( + 'an XAA storage failure is not shared as success and releases the lock', + async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData, initialData], { + updateOutcomes: [false, true], + }) + const network = installSuccessfulXaaFetch() + const baseFetch = globalThis.fetch + const firstGrantStarted = deferred() + const releaseFirstGrant = deferred() + let grantCalls = 0 + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if ( + input.toString() === `${AS_ISSUER}/token` && + init?.method === 'POST' && + grantCalls++ === 0 + ) { + firstGrantStarted.resolve() + await releaseFirstGrant.promise + } + return baseFetch(input, init) + }, + ) as unknown as typeof globalThis.fetch + const firstProvider = new ClaudeAuthProvider('enterprise', config) + const secondProvider = new ClaudeAuthProvider('enterprise', config) + + const firstPromise = firstProvider.prepareRequest() + await firstGrantStarted.promise + const secondPromise = secondProvider.prepareRequest() + await waitFor(() => lockAttempts.length >= 2) + releaseFirstGrant.resolve() + const [first, second] = await Promise.all([ + firstPromise, + secondPromise, + ]) + + expect(network.exchangeCalls()).toBe(2) + expect(activeStorage.updateCalls).toBe(2) + expect(first?.access_token).toBe('stale-access-secret') + expect(second?.access_token).toBe('winner-access-secret') + expect(activeStorage.getData().mcpOAuth?.[getServerKey('enterprise', config)]) + .toMatchObject({ accessToken: 'winner-access-secret' }) + }, + 10_000, +) + +test('abort while waiting for a lock stops retries and does not exchange', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData]) + const network = installSuccessfulXaaFetch() + lockOverride = async () => { + throw Object.assign(new Error('held'), { code: 'ELOCKED' }) + } + const controller = new AbortController() + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = provider.prepareRequest(controller.signal) + await waitFor(() => lockAttempts.length === 1) + controller.abort(new DOMException('cancelled', 'AbortError')) + + await expect(tokens).rejects.toMatchObject({ name: 'AbortError' }) + expect(lockAttempts).toHaveLength(1) + expect(network.exchangeCalls()).toBe(0) + expect(activeStorage.updateCalls).toBe(0) +}) + +test('abort before joining the in-process refresh stops the exchange', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData]) + const readStarted = deferred() + const resumeRead = deferred() + activeStorage.readAsync = async () => { + readStarted.resolve() + await resumeRead.promise + return cloneData(initialData) + } + const network = installSuccessfulXaaFetch() + const controller = new AbortController() + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = provider.prepareRequest(controller.signal) + await readStarted.promise + controller.abort(new DOMException('cancelled', 'AbortError')) + resumeRead.resolve() + + await expect(tokens).rejects.toMatchObject({ name: 'AbortError' }) + expect(lockAttempts).toHaveLength(0) + expect(network.exchangeCalls()).toBe(0) + expect(activeStorage.updateCalls).toBe(0) +}) + +test('abort during a fresh storage read prevents returning credentials', async () => { + const { config, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', config) + initialData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(initialData, []) + const readStarted = deferred() + const resumeRead = deferred() + activeStorage.readAsync = async () => { + readStarted.resolve() + await resumeRead.promise + return cloneData(initialData) + } + const network = installSuccessfulXaaFetch() + const controller = new AbortController() + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = provider.prepareRequest(controller.signal, undefined, true) + await readStarted.promise + controller.abort(new DOMException('cancelled', 'AbortError')) + resumeRead.resolve() + + await expect(tokens).rejects.toMatchObject({ name: 'AbortError' }) + expect(lockAttempts).toHaveLength(0) + expect(network.exchangeCalls()).toBe(0) + expect(activeStorage.updateCalls).toBe(0) +}) + +test('abort stops a pending lock acquisition and releases a late lock', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData]) + const network = installSuccessfulXaaFetch() + let resolveLock!: (release: () => Promise) => void + let releaseCalls = 0 + lockOverride = () => + new Promise(resolve => { + resolveLock = resolve + }) + const controller = new AbortController() + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = provider.prepareRequest(controller.signal) + await waitFor(() => lockAttempts.length === 1) + controller.abort(new DOMException('cancelled', 'AbortError')) + const promptOutcome = await Promise.race([ + tokens.then( + () => 'resolved', + error => (error as Error).name, + ), + new Promise(resolve => setTimeout(() => resolve('timed-out'), 100)), + ]) + + expect(promptOutcome).toBe('AbortError') + resolveLock(async () => { + releaseCalls++ + }) + await waitFor(() => releaseCalls === 1) + expect(network.exchangeCalls()).toBe(0) + expect(activeStorage.updateCalls).toBe(0) +}) + +test('release remains serialized after persistence even when the caller aborts', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData]) + const network = installSuccessfulXaaFetch() + const releaseStarted = deferred() + const finishRelease = deferred() + lockOverride = async () => async () => { + releaseStarted.resolve() + await finishRelease.promise + } + const controller = new AbortController() + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = provider.prepareRequest(controller.signal) + await releaseStarted.promise + controller.abort(new DOMException('cancelled', 'AbortError')) + const outcome = await Promise.race([ + tokens.then(result => result?.access_token ?? 'no-token'), + new Promise(resolve => setTimeout(() => resolve('timed-out'), 100)), + ]) + + expect(outcome).toBe('timed-out') + expect(network.exchangeCalls()).toBe(1) + expect(activeStorage.updateCalls).toBe(1) + finishRelease.resolve() + expect((await tokens)?.access_token).toBe('winner-access-secret') +}) + +test( + 'abort during the XAA network chain releases the lock without persisting', + async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData, initialData]) + installSuccessfulXaaFetch() + const baseFetch = globalThis.fetch + const exchangeStarted = deferred() + globalThis.fetch = mock( + async (input: string | URL, init?: RequestInit) => { + if ( + input.toString() === `${IDP_ISSUER}/token` && + init?.method === 'POST' + ) { + exchangeStarted.resolve() + return new Promise((_resolve, reject) => { + const signal = init.signal + if (signal?.aborted) { + reject(signal.reason) + return + } + signal?.addEventListener('abort', () => reject(signal.reason), { + once: true, + }) + }) + } + return baseFetch(input, init) + }, + ) as unknown as typeof globalThis.fetch + const controller = new AbortController() + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = provider.prepareRequest(controller.signal) + await exchangeStarted.promise + controller.abort(new DOMException('cancelled', 'AbortError')) + + await expect(tokens).rejects.toMatchObject({ name: 'AbortError' }) + expect(activeStorage.updateCalls).toBe(0) + + const network = installSuccessfulXaaFetch() + const retryProvider = new ClaudeAuthProvider('enterprise', config) + const retry = await retryProvider.prepareRequest() + expect(retry?.access_token).toBe('winner-access-secret') + expect(network.exchangeCalls()).toBe(1) + expect(activeStorage.updateCalls).toBe(1) + }, + 10_000, +) + +test('bounded contention fails closed after one final read with a redacted warning', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData]) + const network = installSuccessfulXaaFetch() + lockOverride = async () => { + throw Object.assign(new Error('held'), { code: 'ELOCKED' }) + } + let sleepCalls = 0 + sleepOverride = async () => { + sleepCalls++ + } + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = await provider.prepareRequest() + + expect(tokens).toBeUndefined() + expect(lockAttempts).toHaveLength(5) + expect(sleepCalls).toBe(4) + expect(network.exchangeCalls()).toBe(0) + expect(activeStorage.updateCalls).toBe(0) + expect(debugMessages.some(message => message.includes('refresh blocked'))).toBe( + true, + ) + const diagnosticSurface = `${lockAttempts.join('\n')}\n${debugMessages.join('\n')}` + expect(lockAttempts.join('\n')).not.toContain('enterprise') + expect(lockAttempts.join('\n')).not.toContain('mcp.example.test') + for (const secret of [ + 'stale-access-secret', + 'id-token-secret', + 'id-jag-secret', + 'as-client-secret', + 'winner-access-secret', + 'Authorization', + ]) { + expect(diagnosticSurface).not.toContain(secret) + } +}) + +test('contention exhaustion performs a final fresh storage read', async () => { + const { config, initialData: staleData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', config) + const freshData = structuredClone(staleData) + freshData.mcpOAuth![serverKey]!.accessToken = 'external-winner-secret' + freshData.mcpOAuth![serverKey]!.expiresAt = Date.now() + 3_600_000 + activeStorage = createSharedStorage(freshData, [staleData]) + const network = installSuccessfulXaaFetch() + lockOverride = async () => { + throw Object.assign(new Error('held'), { code: 'ELOCKED' }) + } + sleepOverride = async () => {} + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = await provider.prepareRequest() + + expect(tokens?.access_token).toBe('external-winner-secret') + expect(lockAttempts).toHaveLength(5) + expect(clearCacheCalls).toBeGreaterThanOrEqual(1) + expect(network.exchangeCalls()).toBe(0) + expect(activeStorage.updateCalls).toBe(0) +}) + +test( + 'the established stale-lock policy recovers and removes the lock', + async () => { + const { config, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', config) + activeStorage = createSharedStorage(initialData, [initialData]) + const network = installSuccessfulXaaFetch() + const staleLockDirectory = `${getMcpRefreshLockPath(serverKey, configDir)}.lock` + await mkdir(staleLockDirectory) + const staleTime = new Date(Date.now() - 60_000) + await utimes(staleLockDirectory, staleTime, staleTime) + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = await provider.prepareRequest() + + expect(tokens?.access_token).toBe('winner-access-secret') + expect(network.exchangeCalls()).toBe(1) + await expect(access(staleLockDirectory)).rejects.toBeDefined() + }, + 10_000, +) + +test('a compromised lock aborts the operation with a controlled diagnostic', async () => { + const { config, initialData } = makeXaaFixture() + activeStorage = createSharedStorage(initialData, [initialData]) + const network = installSuccessfulXaaFetch() + lockOverride = async (_path, options) => { + options?.onCompromised?.(new Error('simulated compromise')) + return async () => {} + } + debugShouldThrow = true + const provider = new ClaudeAuthProvider('enterprise', config) + + const tokens = provider.prepareRequest() + + await expect(tokens).rejects.toMatchObject({ name: 'AbortError' }) + expect(network.exchangeCalls()).toBe(0) + expect(activeStorage.updateCalls).toBe(0) + expect(debugMessages.some(message => message.includes('compromised'))).toBe( + true, + ) +}) + +test('fresh-token threshold skips just above five minutes and refreshes below it', async () => { + const originalDateNow = Date.now + const frozenNow = originalDateNow() + Date.now = () => frozenNow + try { + const { config, initialData } = makeXaaFixture() + const serverKey = getServerKey('enterprise', config) + const aboveThreshold = structuredClone(initialData) + aboveThreshold.mcpOAuth![serverKey]!.expiresAt = frozenNow + 301_000 + activeStorage = createSharedStorage(aboveThreshold, [aboveThreshold]) + const network = installSuccessfulXaaFetch() + const freshProvider = new ClaudeAuthProvider('enterprise', config) + + const fresh = await freshProvider.prepareRequest() + + expect(fresh?.access_token).toBe('stale-access-secret') + expect(network.exchangeCalls()).toBe(0) + expect(lockAttempts).toHaveLength(0) + + const belowThreshold = structuredClone(initialData) + belowThreshold.mcpOAuth![serverKey]!.expiresAt = frozenNow + 299_000 + activeStorage = createSharedStorage(belowThreshold, [belowThreshold]) + const staleProvider = new ClaudeAuthProvider('enterprise', config) + + const refreshed = await staleProvider.prepareRequest() + + expect(refreshed?.access_token).toBe('winner-access-secret') + expect(network.exchangeCalls()).toBe(1) + expect(lockAttempts).toHaveLength(1) + } finally { + Date.now = originalDateNow + } +}) diff --git a/src/services/mcp/auth.ts b/src/services/mcp/auth.ts index 6db5f0b96..f281d0a5e 100644 --- a/src/services/mcp/auth.ts +++ b/src/services/mcp/auth.ts @@ -26,17 +26,14 @@ import { import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import axios from 'axios' import { createHash, randomBytes, randomUUID } from 'crypto' -import { mkdir } from 'fs/promises' import { createServer, type Server } from 'http' -import { join } from 'path' import { parse } from 'url' import xss from 'xss' import { MCP_CLIENT_METADATA_URL } from '../../constants/oauth.js' import { openBrowser } from '../../utils/browser.js' +import { throwIfAborted } from '../../utils/boundedAsync.js' import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js' -import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' -import { errorMessage, getErrnoCode } from '../../utils/errors.js' -import * as lockfile from '../../utils/lockfile.js' +import { errorMessage, isAbortError } from '../../utils/errors.js' import { logMCPDebug } from '../../utils/log.js' import { getPlatform } from '../../utils/platform.js' import { getSecureStorage } from '../../utils/secureStorage/index.js' @@ -51,6 +48,11 @@ import { buildRedirectUri, findAvailablePort } from './oauthPort.js' import type { McpHTTPServerConfig, McpSSEServerConfig } from './types.js' import { getLoggingSafeMcpBaseUrl } from './utils.js' import { performCrossAppAccess, XaaTokenExchangeError } from './xaa.js' +import { + MCP_REFRESH_FRESHNESS_SECONDS, + McpRefreshLockUnavailableError, + withMcpRefreshLock, +} from './refreshLock.js' import { acquireIdpIdToken, clearIdpIdToken, @@ -93,8 +95,6 @@ type MCPOAuthFlowErrorReason = | 'token_exchange_failed' | 'unknown' -const MAX_LOCK_RETRIES = 5 - /** * OAuth query parameters that should be redacted from logs. * These contain sensitive values that could enable CSRF or session fixation attacks. @@ -265,12 +265,16 @@ export async function normalizeOAuthErrorBody( * Used by ClaudeAuthProvider for metadata discovery and token refresh. * Prevents stale timeout signals from affecting auth operations. */ -function createAuthFetch(): FetchLike { +function createAuthFetch(abortSignal?: AbortSignal): FetchLike { return async (url: string | URL, init?: RequestInit) => { const isPost = init?.method?.toUpperCase() === 'POST' - const { signal, cleanup } = createCombinedAbortSignal(init?.signal ?? undefined, { - timeoutMs: AUTH_REQUEST_TIMEOUT_MS, - }) + const { signal, cleanup } = createCombinedAbortSignal( + init?.signal ?? undefined, + { + signalB: abortSignal, + timeoutMs: AUTH_REQUEST_TIMEOUT_MS, + }, + ) try { // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins const response = await fetch(url, { ...init, signal }) @@ -1395,9 +1399,87 @@ export async function performMCPOAuthFlow( export function wrapFetchWithStepUpDetection( baseFetch: FetchLike, provider: ClaudeAuthProvider, + options?: { + allowUnauthorizedRefresh?: boolean + resourceUrl?: string + providerOwnsAuthorization?: boolean + }, ): FetchLike { return async (url, init) => { - const response = await baseFetch(url, init) + const initialAuthorization = new Headers(init?.headers) + .get('Authorization') + ?.trim() + const initialBearer = getBearerAccessToken(init?.headers) + const providerOwnsAuthorization = + initialAuthorization === undefined || + (options?.providerOwnsAuthorization === true && + initialBearer !== undefined) + const allowRefresh = + options?.allowUnauthorizedRefresh !== false && + providerOwnsAuthorization && + isMcpResourceRequest(url, options?.resourceUrl, initialBearer !== undefined) + let requestInit = init + + // The MCP SDK asks tokens() for request headers without passing the + // request's AbortSignal. Perform proactive refresh here instead, where + // cancellation can cover lock waits and the complete network chain. + if (allowRefresh) { + const prepared = await provider.prepareRequest(init?.signal ?? undefined) + if (prepared?.access_token) { + const headers = new Headers(init?.headers) + headers.set('Authorization', `Bearer ${prepared.access_token}`) + requestInit = { ...init, headers } + } + } + + let response = await baseFetch(url, requestInit) + if (response.status === 401 && allowRefresh) { + const rejectedAccessToken = getBearerAccessToken(requestInit?.headers) + let refreshed: OAuthTokens | undefined + try { + refreshed = await provider.refreshAfterUnauthorized( + requestInit?.signal ?? undefined, + rejectedAccessToken, + ) + } catch (error) { + if (requestInit?.signal?.aborted || isAbortError(error)) { + await cancelResponseBody(response) + } + throwIfAborted( + requestInit?.signal ?? undefined, + 'MCP token refresh aborted', + ) + if (isAbortError(error)) throw error + return response + } + if ( + refreshed?.access_token && + refreshed.access_token !== rejectedAccessToken + ) { + const headers = new Headers(requestInit?.headers) + headers.set('Authorization', `Bearer ${refreshed.access_token}`) + let retryResponse: Response + try { + retryResponse = await baseFetch(url, { ...requestInit, headers }) + } catch (error) { + if (requestInit?.signal?.aborted || isAbortError(error)) { + await cancelResponseBody(response) + } + throwIfAborted( + requestInit?.signal ?? undefined, + 'MCP token refresh aborted', + ) + if (isAbortError(error)) throw error + return response + } + if (retryResponse.status === 401) { + await cancelResponseBody(retryResponse) + } else { + await cancelResponseBody(response) + response = retryResponse + } + } + } if (response.status === 403) { const wwwAuth = response.headers.get('WWW-Authenticate') if (wwwAuth?.includes('insufficient_scope')) { @@ -1414,6 +1496,122 @@ export function wrapFetchWithStepUpDetection( } } +function isMcpResourceRequest( + requestUrl: string | URL, + resourceUrl: string | undefined, + hasBearerAuthorization: boolean, +): boolean { + if (!resourceUrl) return true + try { + const request = new URL(requestUrl) + const resource = new URL(resourceUrl) + const normalizedPath = (path: string): string => + path.length > 1 ? path.replace(/\/+$/, '') : path + if ( + request.origin === resource.origin && + normalizedPath(request.pathname) === normalizedPath(resource.pathname) + ) { + return true + } + // Legacy SSE POST endpoints differ from the configured EventSource path, + // but the SDK supplies its provider-managed bearer and enforces same-origin. + return hasBearerAuthorization && request.origin === resource.origin + } catch { + return false + } +} + +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel() + } catch { + // Response cleanup must not replace the authentication result. + } +} + +function getBearerAccessToken( + headers: HeadersInit | undefined, +): string | undefined { + const authorization = new Headers(headers).get('Authorization')?.trim() + const match = authorization?.match(/^Bearer\s+(.+)$/i) + return match?.[1] +} + +type StoredMcpOAuthToken = NonNullable[string] + +type InProcessMcpRefresh = { + controller: AbortController + promise: Promise + settled: boolean + waiters: number +} + +function getFreshStoredOAuthTokens( + tokenData: StoredMcpOAuthToken | undefined, +): OAuthTokens | undefined { + return getStoredOAuthTokensAboveThreshold( + tokenData, + MCP_REFRESH_FRESHNESS_SECONDS, + ) +} + +function getUsableStoredOAuthTokens( + tokenData: StoredMcpOAuthToken | undefined, +): OAuthTokens | undefined { + return getStoredOAuthTokensAboveThreshold(tokenData, 0) +} + +function getStoredOAuthTokensAboveThreshold( + tokenData: StoredMcpOAuthToken | undefined, + thresholdSeconds: number, +): OAuthTokens | undefined { + if ( + !tokenData || + typeof tokenData.accessToken !== 'string' || + tokenData.accessToken.length === 0 || + typeof tokenData.expiresAt !== 'number' || + !Number.isFinite(tokenData.expiresAt) + ) { + return undefined + } + + const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 + if (expiresIn <= thresholdSeconds) { + return undefined + } + + return { + access_token: tokenData.accessToken, + refresh_token: + typeof tokenData.refreshToken === 'string' && + tokenData.refreshToken.length > 0 + ? tokenData.refreshToken + : undefined, + expires_in: expiresIn, + scope: typeof tokenData.scope === 'string' ? tokenData.scope : undefined, + token_type: 'Bearer', + } +} + +function getStoredRefreshToken( + tokenData: StoredMcpOAuthToken | undefined, +): string | undefined { + return typeof tokenData?.refreshToken === 'string' && + tokenData.refreshToken.length > 0 + ? tokenData.refreshToken + : undefined +} + +async function readFreshSecureStorage( + storage: ReturnType, + signal?: AbortSignal, +): Promise { + clearKeychainCache() + const data = await storage.readAsync() + throwIfAborted(signal, 'MCP token refresh aborted') + return data +} + export class ClaudeAuthProvider implements OAuthClientProvider { private serverName: string private serverConfig: McpSSEServerConfig | McpHTTPServerConfig @@ -1426,7 +1624,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider { private _metadata?: Awaited< ReturnType > - private _refreshInProgress?: Promise + private _refreshInProgress?: InProcessMcpRefresh private _pendingStepUpScope?: string private onAuthorizationUrlCallback?: (url: string) => void private skipBrowserOpen: boolean @@ -1511,6 +1709,119 @@ export class ClaudeAuthProvider implements OAuthClientProvider { logMCPDebug(this.serverName, `Marked step-up pending: ${scope}`) } + async refreshAfterUnauthorized( + abortSignal?: AbortSignal, + rejectedAccessToken?: string, + ): Promise { + return this.prepareRequest(abortSignal, rejectedAccessToken, true) + } + + private async refreshRejectedCredential( + tokenData: StoredMcpOAuthToken, + rejectedAccessToken: string | undefined, + abortSignal: AbortSignal | undefined, + ): Promise { + const latestRefreshToken = getStoredRefreshToken(tokenData) + if ( + !latestRefreshToken && + (!isXaaEnabled() || !this.serverConfig.oauth?.xaa) + ) { + return undefined + } + const refreshed = await this.runInProcessRefresh( + abortSignal, + sharedSignal => + latestRefreshToken + ? this.refreshAuthorization(sharedSignal, rejectedAccessToken) + : this.xaaRefresh(sharedSignal, rejectedAccessToken), + ) + return refreshed?.access_token !== rejectedAccessToken + ? refreshed + : undefined + } + + private async runInProcessRefresh( + signal: AbortSignal | undefined, + operation: (sharedSignal: AbortSignal) => Promise, + ): Promise { + let refresh = this._refreshInProgress + if (!refresh) { + const controller = new AbortController() + let refreshState!: InProcessMcpRefresh + const promise = Promise.resolve() + .then(() => operation(controller.signal)) + .finally(() => { + refreshState.settled = true + if (this._refreshInProgress === refreshState) { + this._refreshInProgress = undefined + } + }) + refreshState = { + controller, + promise, + settled: false, + waiters: 0, + } + this._refreshInProgress = refreshState + refresh = refreshState + } + + refresh.waiters++ + try { + if (!signal) return await refresh.promise + + const getAbortReason = (): Error => + signal.reason instanceof Error + ? signal.reason + : new DOMException('MCP token refresh aborted', 'AbortError') + if (signal.aborted) { + const reason = getAbortReason() + if (refresh.waiters > 1) throw reason + refresh.controller.abort(reason) + return await refresh.promise + } + + return await new Promise((resolve, reject) => { + const cleanup = (): void => { + signal.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + cleanup() + const reason = getAbortReason() + if (refresh.waiters > 1) { + // This caller can stop waiting without cancelling the refresh that + // another caller still needs. + reject(reason) + } else { + // The final waiter owns cancellation of the shared operation. Let + // its promise settle so a token persisted before a bounded lock + // release was interrupted can still be returned. + refresh.controller.abort(reason) + } + } + + signal.addEventListener('abort', onAbort, { once: true }) + refresh.promise.then( + value => { + cleanup() + resolve(value) + }, + error => { + cleanup() + reject(error) + }, + ) + }) + } finally { + refresh.waiters-- + if (refresh.waiters === 0 && !refresh.settled) { + refresh.controller.abort( + signal?.reason ?? new DOMException('Refresh abandoned', 'AbortError'), + ) + } + } + } + async state(): Promise { // Generate state if not already generated for this instance if (!this._state) { @@ -1578,173 +1889,183 @@ export class ClaudeAuthProvider implements OAuthClientProvider { storage.update(updatedData) } + /** + * Storage-only SDK view. Refresh credentials deliberately stay private so + * the SDK cannot run its own uncoordinated refresh path. + */ async tokens(): Promise { - // Cross-process token changes (another CC instance refreshed or invalidated) - // are picked up via the keychain cache TTL (see macOsKeychainStorage.ts). - // In-process writes already invalidate the cache via storage.update(). - // We do NOT clearKeychainCache() here — tokens() is called by the MCP SDK's - // _commonHeaders on every request, and forcing a cache miss would trigger - // a blocking spawnSync(`security find-generic-password`) 30-40x/sec. - // See CPU profile: spawnSync was 7.2% of total CPU after PR #19436. const storage = getSecureStorage() const data = await storage.readAsync() const serverKey = getServerKey(this.serverName, this.serverConfig) - const tokenData = data?.mcpOAuth?.[serverKey] - - // XAA: a cached id_token plays the same UX role as a refresh_token — run - // the silent exchange to get a fresh access_token without a browser. The - // id_token does expire (we re-acquire via `xaa login` when it does); the - // point is that while it's valid, re-auth is zero-interaction. - // - // Only fire when we don't have a refresh_token. If the AS returned one, - // the normal refresh path (below) is cheaper — 1 request vs the 4-request - // XAA chain. If that refresh is revoked, refreshAuthorization() clears it - // (invalidateCredentials('tokens')), and the next tokens() falls through - // to here. - // - // Fires on: - // - never authed (!tokenData) → first connect, auto-auth - // - SDK partial write {accessToken:''} → stale from past session - // - expired/expiring, no refresh_token → proactive XAA re-auth - // - // No special-casing of {accessToken:'', expiresAt:0}. Yes, SDK auth() - // writes that mid-flow (saveClientInformation defaults). But with this - // auto-auth branch, the *first* tokens() call — before auth() writes - // anything — fires xaaRefresh. If id_token is cached, SDK short-circuits - // there and never reaches the write. If id_token isn't cached, xaaRefresh - // returns undefined in ~1 keychain read, auth() proceeds, writes the - // marker, calls tokens() again, xaaRefresh fails again identically. - // Harmless redundancy, not a wasted exchange. And guarding on `!==''` - // permanently bricks auto-auth when a *prior* session left that marker - // in keychain — real bug seen with xaa.dev. - // - // xaaRefresh() internally short-circuits to undefined when the id_token - // isn't cached (or settings.xaaIdp is gone) → we fall through to the - // existing needs-auth path → user runs `xaa login`. - // if ( - isXaaEnabled() && - this.serverConfig.oauth?.xaa && - !tokenData?.refreshToken && - (!tokenData?.accessToken || - (tokenData.expiresAt - Date.now()) / 1000 <= 300) + !tokenData || + typeof tokenData.accessToken !== 'string' || + tokenData.accessToken.length === 0 || + typeof tokenData.expiresAt !== 'number' || + !Number.isFinite(tokenData.expiresAt) ) { - if (!this._refreshInProgress) { - logMCPDebug( - this.serverName, - tokenData - ? `XAA: access_token expiring, attempting silent exchange` - : `XAA: no access_token yet, attempting silent exchange`, - ) - this._refreshInProgress = this.xaaRefresh().finally(() => { - this._refreshInProgress = undefined - }) - } - try { - const refreshed = await this._refreshInProgress - if (refreshed) return refreshed - } catch (e) { - logMCPDebug( - this.serverName, - `XAA silent exchange failed: ${errorMessage(e)}`, - ) - } - // Fall through. Either id_token isn't cached (xaaRefresh returned - // undefined) or the exchange errored. Normal path below handles both: - // !tokenData → undefined → 401 → needs-auth; expired → undefined → same. - } - - if (!tokenData) { - logMCPDebug(this.serverName, `No token data found`) return undefined } - // Check if token is expired const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 - - // Step-up check: if a 403 insufficient_scope was detected and the current - // token doesn't have the requested scope, omit refresh_token below so the - // SDK skips refresh and falls through to the PKCE flow. - const currentScopes = tokenData.scope?.split(' ') ?? [] - const needsStepUp = - this._pendingStepUpScope !== undefined && - this._pendingStepUpScope.split(' ').some(s => !currentScopes.includes(s)) - if (needsStepUp) { - logMCPDebug( - this.serverName, - `Step-up pending (${this._pendingStepUpScope}), omitting refresh_token`, - ) - } - - // If token is expired and we don't have a refresh token, return undefined - if (expiresIn <= 0 && !tokenData.refreshToken) { - logMCPDebug(this.serverName, `Token expired without refresh token`) + if (expiresIn <= 0 && !getStoredRefreshToken(tokenData)) { return undefined } - - // If token is expired or about to expire (within 5 minutes) and we have a refresh token, refresh it proactively. - // This proactive refresh is a UX improvement - it avoids the latency of a failed request followed by token refresh. - // While MCP servers should return 401 for expired tokens (which triggers SDK-level refresh), proactively refreshing - // before expiry provides a smoother user experience. - // Skip when step-up is pending — refreshing can't elevate scope (RFC 6749 §6). - if (expiresIn <= 300 && tokenData.refreshToken && !needsStepUp) { - // Reuse existing refresh promise if one is in progress to prevent concurrent refreshes - if (!this._refreshInProgress) { - logMCPDebug( - this.serverName, - `Token expires in ${Math.floor(expiresIn)}s, attempting proactive refresh`, - ) - this._refreshInProgress = this.refreshAuthorization( - tokenData.refreshToken, - ).finally(() => { - this._refreshInProgress = undefined - }) - } else { - logMCPDebug( - this.serverName, - `Token refresh already in progress, reusing existing promise`, - ) - } - - try { - const refreshed = await this._refreshInProgress - if (refreshed) { - logMCPDebug(this.serverName, `Token refreshed successfully`) - return refreshed - } - logMCPDebug( - this.serverName, - `Token refresh failed, returning current tokens`, - ) - } catch (error) { - logMCPDebug( - this.serverName, - `Token refresh error: ${errorMessage(error)}`, - ) - } - } - - // Return current tokens (may be expired if refresh failed or not needed yet) - const tokens = { + return { access_token: tokenData.accessToken, - refresh_token: needsStepUp ? undefined : tokenData.refreshToken, + refresh_token: undefined, expires_in: expiresIn, scope: tokenData.scope, token_type: 'Bearer', } + } - logMCPDebug(this.serverName, `Returning tokens`) - logMCPDebug(this.serverName, `Token length: ${tokens.access_token?.length}`) - logMCPDebug(this.serverName, `Has refresh token: ${!!tokens.refresh_token}`) - logMCPDebug(this.serverName, `Expires in: ${Math.floor(expiresIn)}s`) + /** + * Prepares credentials for one transport request. Unlike tokens(), this is + * called where the request AbortSignal is available, so proactive and + * reactive refresh share the same cancellable coordination path. + */ + async prepareRequest( + abortSignal?: AbortSignal, + rejectedAccessToken?: string, + forceFreshStorage = false, + ): Promise { + throwIfAborted(abortSignal, 'MCP token refresh aborted') + const storage = getSecureStorage() + let data: SecureStorageData | null + if (forceFreshStorage) { + data = await readFreshSecureStorage(storage, abortSignal) + } else { + data = await storage.readAsync() + throwIfAborted(abortSignal, 'MCP token refresh aborted') + } + const serverKey = getServerKey(this.serverName, this.serverConfig) + let tokenData = data?.mcpOAuth?.[serverKey] + let freshTokens = getFreshStoredOAuthTokens(tokenData) + if ( + !forceFreshStorage && + rejectedAccessToken !== undefined && + (!freshTokens || freshTokens.access_token === rejectedAccessToken) + ) { + // A reactive request can arrive with a keychain-cached null/stale record + // after another process has already persisted the winner. + tokenData = (await readFreshSecureStorage(storage, abortSignal)) + ?.mcpOAuth?.[serverKey] + freshTokens = getFreshStoredOAuthTokens(tokenData) + } + if ( + freshTokens && + freshTokens.access_token !== rejectedAccessToken + ) { + return { ...freshTokens, refresh_token: undefined } + } - return tokens + const usableStoredTokens = getUsableStoredOAuthTokens(tokenData) + const storedTokens = usableStoredTokens + ? { ...usableStoredTokens, refresh_token: undefined } + : undefined + const currentScopes = tokenData?.scope?.split(' ') ?? [] + const needsStepUp = + this._pendingStepUpScope !== undefined && + this._pendingStepUpScope + .split(' ') + .some(scope => !currentScopes.includes(scope)) + if (needsStepUp) { + return rejectedAccessToken === undefined ? storedTokens : undefined + } + + const latestRefreshToken = getStoredRefreshToken(tokenData) + const canUseXaa = isXaaEnabled() && this.serverConfig.oauth?.xaa + if (!latestRefreshToken && !canUseXaa) { + return rejectedAccessToken === undefined ? storedTokens : undefined + } + + let sharedReturnedRejectedToken = false + try { + const refreshed = await this.runInProcessRefresh( + abortSignal, + sharedSignal => + latestRefreshToken + ? this.refreshAuthorization(sharedSignal, rejectedAccessToken) + : this.xaaRefresh( + sharedSignal, + rejectedAccessToken, + tokenData !== undefined, + ), + ) + if ( + refreshed && + refreshed.access_token !== rejectedAccessToken + ) { + return { ...refreshed, refresh_token: undefined } + } + sharedReturnedRejectedToken = + refreshed?.access_token === rejectedAccessToken + } catch (error) { + throwIfAborted(abortSignal, 'MCP token refresh aborted') + if (isAbortError(error)) throw error + if (error instanceof McpRefreshLockUnavailableError) { + return undefined + } + // Provider-controlled OAuth bodies can echo submitted credentials. + logMCPDebug(this.serverName, 'MCP credential refresh failed') + } + + // A joined in-process refresh can legitimately return the exact bearer + // rejected by this request. Re-read, then run one new coordinated refresh + // with the latest credential state rather than retrying that bearer. + const latestTokenData = ( + await readFreshSecureStorage(storage, abortSignal) + )?.mcpOAuth?.[serverKey] + const latestFreshTokens = getFreshStoredOAuthTokens(latestTokenData) + if ( + latestFreshTokens && + latestFreshTokens.access_token !== rejectedAccessToken + ) { + return { ...latestFreshTokens, refresh_token: undefined } + } + if ( + rejectedAccessToken !== undefined && + sharedReturnedRejectedToken && + latestTokenData + ) { + throwIfAborted(abortSignal, 'MCP token refresh aborted') + try { + const refreshed = await this.refreshRejectedCredential( + latestTokenData, + rejectedAccessToken, + abortSignal, + ) + return refreshed + ? { ...refreshed, refresh_token: undefined } + : undefined + } catch (error) { + throwIfAborted(abortSignal, 'MCP token refresh aborted') + if (isAbortError(error)) throw error + if (!(error instanceof McpRefreshLockUnavailableError)) { + logMCPDebug(this.serverName, 'MCP credential refresh failed') + } + return undefined + } + } + + if (rejectedAccessToken === undefined) { + const latestUsableTokens = getUsableStoredOAuthTokens(latestTokenData) + if (latestUsableTokens) { + return { ...latestUsableTokens, refresh_token: undefined } + } + } + return undefined } async saveTokens(tokens: OAuthTokens): Promise { this._pendingStepUpScope = undefined const storage = getSecureStorage() + // Keep the final whole-record read and synchronous update adjacent. An + // await here would let another in-process server write between them and + // lose one side of the merge. + clearKeychainCache() const existingData = storage.read() || {} const serverKey = getServerKey(this.serverName, this.serverConfig) @@ -1768,7 +2089,10 @@ export class ClaudeAuthProvider implements OAuthClientProvider { }, } - storage.update(updatedData) + const result = storage.update(updatedData) + if (!result.success) { + throw new Error('Failed to persist MCP OAuth tokens to secure storage') + } } /** @@ -1781,113 +2105,182 @@ export class ClaudeAuthProvider implements OAuthClientProvider { * On exchange failure, clears the id_token cache so the next interactive * auth does a fresh IdP login (the cached id_token is likely stale/revoked). * - * TODO(xaa-ga): add cross-process lockfile before GA. `_refreshInProgress` - * only dedupes within one process — two CC instances with expiring tokens - * both fire the full 4-request XAA chain and race on storage.update(). - * Unlike inc-4829 the id_token is not single-use so both access_tokens - * stay valid (wasted round-trips + keychain write race, not brickage), - * but this is the shape CLAUDE.md flags under "Token/auth caching across - * process boundaries". Mirror refreshAuthorization()'s lockfile pattern. + * `_refreshInProgress` dedupes callers on this provider instance; the shared + * server lock below coordinates independent OpenClaude processes/providers. */ - private async xaaRefresh(): Promise { - const idp = getXaaIdpSettings() - if (!idp) return undefined // config was removed mid-session + private async xaaRefresh( + abortSignal?: AbortSignal, + rejectedAccessToken?: string, + requireExistingRecord = true, + ): Promise { + const serverKey = getServerKey(this.serverName, this.serverConfig) + const result = await withMcpRefreshLock( + this.serverName, + serverKey, + abortSignal, + async ({ acquired, signal }) => { + // Another process may have refreshed while this one waited. Bypass the + // keychain cache before checking the shared credential record. + const storage = getSecureStorage() + const existingData = + (await readFreshSecureStorage(storage, signal)) || {} + const tokenData = existingData.mcpOAuth?.[serverKey] + const freshTokens = getFreshStoredOAuthTokens(tokenData) + if ( + freshTokens && + freshTokens.access_token !== rejectedAccessToken + ) { + logMCPDebug( + this.serverName, + 'Another process already refreshed tokens', + ) + return freshTokens + } - const idToken = getCachedIdpIdToken(idp.issuer) - if (!idToken) { - logMCPDebug( - this.serverName, - 'XAA: id_token not cached, needs interactive re-auth', - ) - return undefined - } + if (!acquired) { + throw new McpRefreshLockUnavailableError() + } + if (requireExistingRecord && !tokenData) { + return undefined + } - const clientId = this.serverConfig.oauth?.clientId - const clientConfig = getMcpClientConfig(this.serverName, this.serverConfig) - if (!clientId || !clientConfig?.clientSecret) { - logMCPDebug( - this.serverName, - 'XAA: missing clientId or clientSecret in config — skipping silent refresh', - ) - return undefined // shouldn't happen if `mcp add` was correct - } + // A normal refresh token may have appeared while XAA waited. Prefer + // the one-request normal flow, under the same already-held lock. + const latestRefreshToken = getStoredRefreshToken(tokenData) + if (latestRefreshToken) { + return this._doRefresh( + latestRefreshToken, + signal, + rejectedAccessToken, + ) + } - const idpClientSecret = getIdpClientSecret(idp.issuer) + // Configuration and credentials can change while the lock is held by + // another process, so re-check every XAA prerequisite only now. + throwIfAborted(signal, 'MCP token refresh aborted') + if (!isXaaEnabled() || !this.serverConfig.oauth?.xaa) { + return undefined + } + const idp = getXaaIdpSettings() + if (!idp) return undefined - // Discover IdP token endpoint. Could cache (fetchCache.ts already - // caches /.well-known/ requests), but OIDC metadata is cheap + idempotent. - // xaaRefresh is the silent tokens() path — soft-fail to undefined so the - // caller falls through to needs-authentication instead of throwing mid-connect. - let oidc - try { - oidc = await discoverOidc(idp.issuer) - } catch (e) { - logMCPDebug( - this.serverName, - `XAA: OIDC discovery failed in silent refresh: ${errorMessage(e)}`, - ) - return undefined - } + const idToken = getCachedIdpIdToken(idp.issuer) + if (!idToken) { + logMCPDebug( + this.serverName, + 'XAA: id_token not cached, needs interactive re-auth', + ) + return undefined + } - try { - const tokens = await performCrossAppAccess( - this.serverConfig.url, - { - clientId, - clientSecret: clientConfig.clientSecret, - idpClientId: idp.clientId, - idpClientSecret, - idpIdToken: idToken, - idpTokenEndpoint: oidc.token_endpoint, - }, - this.serverName, - ) - // Write directly (not via saveTokens) so clientId + clientSecret land in - // storage even when this is the first write for serverKey. saveTokens - // only spreads existing data; if no prior performMCPXaaAuth ran, - // revokeServerTokens would later read tokenData.clientId as undefined - // and send a client_id-less RFC 7009 request that strict ASes reject. - const storage = getSecureStorage() - const existingData = storage.read() || {} - const serverKey = getServerKey(this.serverName, this.serverConfig) - const prev = existingData.mcpOAuth?.[serverKey] - storage.update({ - ...existingData, - mcpOAuth: { - ...existingData.mcpOAuth, - [serverKey]: { - ...prev, - serverName: this.serverName, - serverUrl: this.serverConfig.url, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? prev?.refreshToken, - expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000, - scope: tokens.scope, - clientId, - clientSecret: clientConfig.clientSecret, - discoveryState: { - authorizationServerUrl: tokens.authorizationServerUrl, - }, - }, - }, - }) - return { - access_token: tokens.access_token, - token_type: 'Bearer', - expires_in: tokens.expires_in, - scope: tokens.scope, - refresh_token: tokens.refresh_token, - } - } catch (e) { - if (e instanceof XaaTokenExchangeError && e.shouldClearIdToken) { - clearIdpIdToken(idp.issuer) - logMCPDebug( + const clientId = this.serverConfig.oauth.clientId + const clientConfig = getMcpClientConfig( this.serverName, - 'XAA: cleared id_token after exchange failure', + this.serverConfig, ) - } - throw e - } + if (!clientId || !clientConfig?.clientSecret) { + logMCPDebug( + this.serverName, + 'XAA: missing clientId or clientSecret in config — skipping silent refresh', + ) + return undefined + } + + const idpClientSecret = getIdpClientSecret(idp.issuer) + let oidc + try { + oidc = await discoverOidc(idp.issuer, signal) + } catch (error) { + throwIfAborted(signal, 'MCP token refresh aborted') + if (isAbortError(error)) throw error + logMCPDebug( + this.serverName, + `XAA: OIDC discovery failed in silent refresh: ${errorMessage(error)}`, + ) + return undefined + } + + try { + const tokens = await performCrossAppAccess( + this.serverConfig.url, + { + clientId, + clientSecret: clientConfig.clientSecret, + idpClientId: idp.clientId, + idpClientSecret, + idpIdToken: idToken, + idpTokenEndpoint: oidc.token_endpoint, + }, + this.serverName, + signal, + ) + throwIfAborted(signal, 'MCP token refresh aborted') + + // Persist client credentials with the token so later refresh and + // revocation retain the same final-boundary behavior as XAA login. + // Re-read once more to narrow the merge window for unrelated secure + // storage updates that are intentionally outside this server lock. + throwIfAborted(signal, 'MCP token refresh aborted') + // Keep this final whole-record merge adjacent to update(), as in + // saveTokens(), so concurrent servers in this process cannot both + // write snapshots captured before either update. + clearKeychainCache() + const latestData = storage.read() || {} + const latestTokenData = latestData.mcpOAuth?.[serverKey] + const updatedData: SecureStorageData = { + ...latestData, + mcpOAuth: { + ...latestData.mcpOAuth, + [serverKey]: { + ...latestTokenData, + serverName: this.serverName, + serverUrl: this.serverConfig.url, + accessToken: tokens.access_token, + refreshToken: + tokens.refresh_token ?? latestTokenData?.refreshToken, + expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000, + scope: tokens.scope, + clientId, + clientSecret: clientConfig.clientSecret, + discoveryState: { + ...latestTokenData?.discoveryState, + authorizationServerUrl: tokens.authorizationServerUrl, + }, + }, + }, + } + throwIfAborted(signal, 'MCP token refresh aborted') + const updateResult = storage.update(updatedData) + if (!updateResult.success) { + throw new Error( + 'Failed to persist MCP XAA tokens to secure storage', + ) + } + return { + access_token: tokens.access_token, + token_type: 'Bearer', + expires_in: tokens.expires_in, + scope: tokens.scope, + refresh_token: + tokens.refresh_token ?? latestTokenData?.refreshToken, + } + } catch (error) { + throwIfAborted(signal, 'MCP token refresh aborted') + if ( + error instanceof XaaTokenExchangeError && + error.shouldClearIdToken + ) { + clearIdpIdToken(idp.issuer) + logMCPDebug( + this.serverName, + 'XAA: cleared id_token after exchange failure', + ) + } + throw error + } + }, + ) + return result.value } async redirectToAuthorization(authorizationUrl: URL): Promise { @@ -2075,7 +2468,9 @@ export class ClaudeAuthProvider implements OAuthClientProvider { storage.update(updatedData) } - async discoveryState(): Promise { + async discoveryState( + abortSignal?: AbortSignal, + ): Promise { const storage = getSecureStorage() const data = storage.read() const serverKey = getServerKey(this.serverName, this.serverConfig) @@ -2105,6 +2500,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider { this.serverName, this.serverConfig.url, metadataUrl, + createAuthFetch(abortSignal), ) if (metadata) { return { @@ -2114,6 +2510,8 @@ export class ClaudeAuthProvider implements OAuthClientProvider { } } } catch (error) { + throwIfAborted(abortSignal, 'MCP token refresh aborted') + if (isAbortError(error)) throw error logMCPDebug( this.serverName, `Failed to fetch from configured metadata URL: ${errorMessage(error)}`, @@ -2125,94 +2523,59 @@ export class ClaudeAuthProvider implements OAuthClientProvider { } async refreshAuthorization( - refreshToken: string, + abortSignal?: AbortSignal, + rejectedAccessToken?: string, ): Promise { const serverKey = getServerKey(this.serverName, this.serverConfig) - const claudeDir = getClaudeConfigHomeDir() - await mkdir(claudeDir, { recursive: true }) - const sanitizedKey = serverKey.replace(/[^a-zA-Z0-9]/g, '_') - const lockfilePath = join(claudeDir, `mcp-refresh-${sanitizedKey}.lock`) - - let release: (() => Promise) | undefined - for (let retry = 0; retry < MAX_LOCK_RETRIES; retry++) { - try { - logMCPDebug( - this.serverName, - `Acquiring refresh lock (attempt ${retry + 1})`, - ) - release = await lockfile.lock(lockfilePath, { - realpath: false, - onCompromised: () => { - logMCPDebug(this.serverName, `Refresh lock was compromised`) - }, - }) - logMCPDebug(this.serverName, `Acquired refresh lock`) - break - } catch (e: unknown) { - const code = getErrnoCode(e) - if (code === 'ELOCKED') { + const result = await withMcpRefreshLock( + this.serverName, + serverKey, + abortSignal, + async ({ acquired, signal }) => { + // Re-read after acquisition or bounded fallback. Never use the token + // captured before waiting: logout or another refresh may have rotated + // or removed it in the meantime. + const storage = getSecureStorage() + const data = await readFreshSecureStorage(storage, signal) + const tokenData = data?.mcpOAuth?.[serverKey] + const freshTokens = getFreshStoredOAuthTokens(tokenData) + if ( + freshTokens && + freshTokens.access_token !== rejectedAccessToken + ) { logMCPDebug( this.serverName, - `Refresh lock held by another process, waiting (attempt ${retry + 1}/${MAX_LOCK_RETRIES})`, + `Another process already refreshed tokens (expires in ${Math.floor(freshTokens.expires_in ?? 0)}s)`, ) - await sleep(1000 + Math.random() * 1000) - continue + return freshTokens } - logMCPDebug( - this.serverName, - `Failed to acquire refresh lock: ${code}, proceeding without lock`, - ) - break - } - } - if (!release) { - logMCPDebug( - this.serverName, - `Could not acquire refresh lock after ${MAX_LOCK_RETRIES} retries, proceeding without lock`, - ) - } - try { - // Re-read tokens after acquiring lock — another process may have refreshed - clearKeychainCache() - const storage = getSecureStorage() - const data = storage.read() - const tokenData = data?.mcpOAuth?.[serverKey] - if (tokenData) { - const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 - if (expiresIn > 300) { + if (!acquired) { + throw new McpRefreshLockUnavailableError() + } + + const latestRefreshToken = getStoredRefreshToken(tokenData) + if (!latestRefreshToken) { logMCPDebug( this.serverName, - `Another process already refreshed tokens (expires in ${Math.floor(expiresIn)}s)`, + 'No current refresh token remains after acquiring refresh lock', ) - return { - access_token: tokenData.accessToken, - refresh_token: tokenData.refreshToken, - expires_in: expiresIn, - scope: tokenData.scope, - token_type: 'Bearer', - } + return undefined } - // Use the freshest refresh token from storage - if (tokenData.refreshToken) { - refreshToken = tokenData.refreshToken - } - } - return await this._doRefresh(refreshToken) - } finally { - if (release) { - try { - await release() - logMCPDebug(this.serverName, `Released refresh lock`) - } catch { - logMCPDebug(this.serverName, `Failed to release refresh lock`) - } - } - } + return this._doRefresh( + latestRefreshToken, + signal, + rejectedAccessToken, + ) + }, + ) + return result.value } private async _doRefresh( refreshToken: string, + abortSignal?: AbortSignal, + rejectedAccessToken?: string, ): Promise { const MAX_ATTEMPTS = 3 @@ -2247,7 +2610,8 @@ export class ClaudeAuthProvider implements OAuthClientProvider { for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { logMCPDebug(this.serverName, `Starting token refresh`) - const authFetch = createAuthFetch() + throwIfAborted(abortSignal, 'MCP token refresh aborted') + const authFetch = createAuthFetch(abortSignal) // Reuse cached metadata from the initial OAuth flow if available, // since metadata (token endpoint URL, etc.) is static per auth server. @@ -2258,7 +2622,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider { // 3. Full RFC 9728 → RFC 8414 re-discovery via fetchAuthServerMetadata. let metadata = this._metadata if (!metadata) { - const cached = await this.discoveryState() + const cached = await this.discoveryState(abortSignal) if (cached?.authorizationServerMetadata) { logMCPDebug( this.serverName, @@ -2312,6 +2676,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider { if (newTokens) { logMCPDebug(this.serverName, `Token refresh successful`) + throwIfAborted(abortSignal, 'MCP token refresh aborted') await this.saveTokens(newTokens) emitRefreshEvent('success') return newTokens @@ -2321,36 +2686,32 @@ export class ClaudeAuthProvider implements OAuthClientProvider { emitRefreshEvent('failure', 'no_tokens_returned') return undefined } catch (error) { + throwIfAborted(abortSignal, 'MCP token refresh aborted') + if (isAbortError(error)) throw error // Invalid grant means the refresh token itself is invalid/revoked/expired. // But another process may have already refreshed successfully — check first. if (error instanceof InvalidGrantError) { - logMCPDebug( - this.serverName, - `Token refresh failed with invalid_grant: ${error.message}`, - ) - clearKeychainCache() + // OAuth error descriptions are provider-controlled and may echo the + // submitted refresh token. Never persist them in MCP diagnostics. + logMCPDebug(this.serverName, 'Token refresh failed with invalid_grant') const storage = getSecureStorage() - const data = storage.read() + const data = await readFreshSecureStorage(storage, abortSignal) const serverKey = getServerKey(this.serverName, this.serverConfig) const tokenData = data?.mcpOAuth?.[serverKey] - if (tokenData) { - const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 - if (expiresIn > 300) { - logMCPDebug( - this.serverName, - `Another process refreshed tokens, using those`, - ) - // Not emitted as success: this process did not perform a - // refresh, and the winning process already emitted its own - // success event. Emitting here would double-count. - return { - access_token: tokenData.accessToken, - refresh_token: tokenData.refreshToken, - expires_in: expiresIn, - scope: tokenData.scope, - token_type: 'Bearer', - } - } + const freshTokens = getFreshStoredOAuthTokens(tokenData) + if ( + freshTokens && + (rejectedAccessToken === undefined || + freshTokens.access_token !== rejectedAccessToken) + ) { + logMCPDebug( + this.serverName, + `Another process refreshed tokens, using those`, + ) + // Not emitted as success: this process did not perform a + // refresh, and the winning process already emitted its own + // success event. Emitting here would double-count. + return freshTokens } logMCPDebug( this.serverName, @@ -2372,10 +2733,9 @@ export class ClaudeAuthProvider implements OAuthClientProvider { const isRetryable = isTimeoutError || isTransientServerError if (!isRetryable || attempt >= MAX_ATTEMPTS) { - logMCPDebug( - this.serverName, - `Token refresh failed: ${errorMessage(error)}`, - ) + // The generic SDK/fetch error message may include a token endpoint + // response body, so retain only the stable outcome diagnostic. + logMCPDebug(this.serverName, 'Token refresh failed') emitRefreshEvent( 'failure', isRetryable ? 'transient_retries_exhausted' : 'request_failed', @@ -2388,7 +2748,8 @@ export class ClaudeAuthProvider implements OAuthClientProvider { this.serverName, `Token refresh failed, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_ATTEMPTS})`, ) - await sleep(delayMs) + await sleep(delayMs, abortSignal) + throwIfAborted(abortSignal, 'MCP token refresh aborted') } } diff --git a/src/services/mcp/client.test.ts b/src/services/mcp/client.test.ts index 78827670d..1b58bb2aa 100644 --- a/src/services/mcp/client.test.ts +++ b/src/services/mcp/client.test.ts @@ -1,12 +1,18 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { appendBoundedMcpStderr, + buildMcpSseEventSourceHeaders, + buildMcpSseRequestHeaders, cleanupFailedConnection, buildMcpStdioCommand, logMcpServerStderr, } from './client.js' +import { wrapFetchWithStepUpDetection } from './auth.js' import { _resetErrorLogForTesting, attachErrorLogSink, @@ -36,6 +42,170 @@ function withCapturedMcpLogEvents( } } +test('buildMcpSseEventSourceHeaders preserves a refreshed Headers bearer', () => { + const headers = buildMcpSseEventSourceHeaders( + new Headers({ + Authorization: 'Bearer refreshed-access-secret', + 'User-Agent': 'custom-agent', + Accept: 'application/json', + }), + ) + + assert.equal(headers.get('Authorization'), 'Bearer refreshed-access-secret') + assert.equal(headers.get('User-Agent'), 'custom-agent') + assert.equal(headers.get('Accept'), 'text/event-stream') +}) + +test('buildMcpSseRequestHeaders preserves SDK headers and explicit precedence', () => { + const sdkHeaders = new Headers({ + Authorization: 'Bearer sdk-stale-secret', + 'MCP-Protocol-Version': '2025-06-18', + }) + const providerHeaders = buildMcpSseRequestHeaders(sdkHeaders, { + 'X-Configured': 'configured-value', + }) + const explicitHeaders = buildMcpSseRequestHeaders(sdkHeaders, { + Authorization: 'Bearer configured-secret', + }) + + assert.equal(providerHeaders.get('Authorization'), 'Bearer sdk-stale-secret') + assert.equal(providerHeaders.get('MCP-Protocol-Version'), '2025-06-18') + assert.equal(providerHeaders.get('X-Configured'), 'configured-value') + assert.equal( + explicitHeaders.get('Authorization'), + 'Bearer configured-secret', + ) +}) + +function makeNeedsAuthTransportFixture() { + const resourceUrl = 'https://mcp.example.test/mcp' + const resourceMetadataUrl = + 'https://mcp.example.test/.well-known/oauth-protected-resource' + const authorizationServerUrl = 'https://auth.example.test' + let resourceAuthorization: string | null = null + let metadataAuthorization: string | null = null + let redirectCalls = 0 + const provider = { + redirectUrl: 'http://127.0.0.1:31337/callback', + clientMetadata: { + client_name: 'OpenClaude test', + redirect_uris: ['http://127.0.0.1:31337/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }, + clientInformation: async () => ({ client_id: 'test-client' }), + tokens: async () => undefined, + state: async () => 'test-state', + saveCodeVerifier: async () => {}, + redirectToAuthorization: async () => { + redirectCalls++ + }, + prepareRequest: async () => ({ access_token: 'resource-access-secret' }), + refreshAfterUnauthorized: async () => undefined, + markStepUpPending: () => {}, + } + const baseFetch = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + const url = input.toString() + const authorization = new Headers(init?.headers).get('Authorization') + if (url === resourceUrl) { + resourceAuthorization = authorization + return new Response(null, { + status: 401, + headers: { + 'WWW-Authenticate': + `Bearer resource_metadata="${resourceMetadataUrl}"`, + }, + }) + } + metadataAuthorization = authorization + if (url === resourceMetadataUrl) { + return Response.json({ + resource: resourceUrl, + authorization_servers: [authorizationServerUrl], + }) + } + if ( + url === + `${authorizationServerUrl}/.well-known/oauth-authorization-server` + ) { + return Response.json({ + issuer: authorizationServerUrl, + authorization_endpoint: `${authorizationServerUrl}/authorize`, + token_endpoint: `${authorizationServerUrl}/token`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + code_challenge_methods_supported: ['S256'], + }) + } + return new Response(null, { status: 404 }) + } + const wrappedFetch = wrapFetchWithStepUpDetection( + baseFetch, + provider as never, + { resourceUrl, providerOwnsAuthorization: true }, + ) + return { + provider, + resourceUrl, + wrappedFetch, + getResourceAuthorization: () => resourceAuthorization, + getMetadataAuthorization: () => metadataAuthorization, + getRedirectCalls: () => redirectCalls, + } +} + +test('HTTP failed recovery reaches UnauthorizedError without leaking the resource bearer to OAuth metadata', async () => { + const fixture = makeNeedsAuthTransportFixture() + const transport = new StreamableHTTPClientTransport( + new URL(fixture.resourceUrl), + { + authProvider: fixture.provider as never, + fetch: fixture.wrappedFetch, + }, + ) + await transport.start() + try { + await assert.rejects( + transport.send({ jsonrpc: '2.0', id: 1, method: 'ping' }), + UnauthorizedError, + ) + } finally { + await transport.close() + } + + assert.equal(fixture.getMetadataAuthorization(), null) + assert.equal( + fixture.getResourceAuthorization(), + 'Bearer resource-access-secret', + ) + assert.equal(fixture.getRedirectCalls(), 1) +}) + +test('SSE failed recovery reaches UnauthorizedError without leaking the resource bearer to OAuth metadata', async () => { + const fixture = makeNeedsAuthTransportFixture() + const transport = new SSEClientTransport(new URL(fixture.resourceUrl), { + authProvider: fixture.provider as never, + fetch: fixture.wrappedFetch, + eventSourceInit: { fetch: fixture.wrappedFetch }, + }) + try { + await assert.rejects(transport.start(), UnauthorizedError) + } finally { + await transport.close() + } + + assert.equal(fixture.getMetadataAuthorization(), null) + assert.equal( + fixture.getResourceAuthorization(), + 'Bearer resource-access-secret', + ) + assert.equal(fixture.getRedirectCalls(), 1) +}) + test('cleanupFailedConnection awaits transport close before resolving', async () => { let closed = false let resolveClose: (() => void) | undefined diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index d3f9a4832..e9772c66a 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -574,6 +574,28 @@ type InProcessMcpServer = { const MAX_MCP_STDERR_CHARS = 256 * 1024 const MCP_STDERR_TRUNCATED_MARKER = '\n...[stderr truncated]' +export function buildMcpSseEventSourceHeaders( + initHeaders: HeadersInit | undefined, +): Headers { + const headers = new Headers(initHeaders) + if (!headers.has('User-Agent')) { + headers.set('User-Agent', getMCPUserAgent()) + } + headers.set('Accept', 'text/event-stream') + return headers +} + +export function buildMcpSseRequestHeaders( + initHeaders: HeadersInit | undefined, + combinedHeaders: Record, +): Headers { + const headers = new Headers(initHeaders) + new Headers(combinedHeaders).forEach((value, key) => { + headers.set(key, value) + }) + return headers +} + export function appendBoundedMcpStderr( current: string, chunk: Buffer | string, @@ -680,6 +702,9 @@ export const connectToServer = memoize( // Get combined headers (static + dynamic) const combinedHeaders = await getMcpServerHeaders(name, serverRef) + const allowUnauthorizedRefresh = !new Headers(combinedHeaders).has( + 'Authorization', + ) // Use the auth provider with SSEClientTransport const transportOptions: SSEClientTransportOptions = { @@ -688,7 +713,11 @@ export const connectToServer = memoize( // Step-up detection wraps innermost so the 403 is seen before the // SDK's handler calls auth() → tokens(). fetch: wrapFetchWithTimeout( - wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider), + wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider, { + allowUnauthorizedRefresh, + resourceUrl: serverRef.url, + providerOwnsAuthorization: allowUnauthorizedRefresh, + }), ), requestInit: { headers: { @@ -703,27 +732,28 @@ export const connectToServer = memoize( // to receive server-sent events), so applying a 60-second timeout would kill it. // The timeout is only meant for individual API requests (POST, auth refresh), not // the persistent SSE stream. - transportOptions.eventSourceInit = { - fetch: async (url: string | URL, init?: RequestInit) => { - // Get auth headers from the auth provider - const authHeaders: Record = {} - const tokens = await authProvider.tokens() - if (tokens) { - authHeaders.Authorization = `Bearer ${tokens.access_token}` - } - + const eventSourceFetch = wrapFetchWithStepUpDetection( + async (url: string | URL, init?: RequestInit) => { const proxyOptions = getProxyFetchOptions() // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins return fetch(url, { ...init, ...proxyOptions, - headers: { - 'User-Agent': getMCPUserAgent(), - ...authHeaders, - ...init?.headers, - ...combinedHeaders, - Accept: 'text/event-stream', - }, + headers: buildMcpSseEventSourceHeaders(init?.headers), + }) + }, + authProvider, + { + allowUnauthorizedRefresh, + resourceUrl: serverRef.url, + providerOwnsAuthorization: allowUnauthorizedRefresh, + }, + ) + transportOptions.eventSourceInit = { + fetch: async (url: string | URL, init?: RequestInit) => { + return eventSourceFetch(url, { + ...init, + headers: buildMcpSseRequestHeaders(init?.headers, combinedHeaders), }) }, } @@ -861,13 +891,16 @@ export const connectToServer = memoize( // Get combined headers (static + dynamic) const combinedHeaders = await getMcpServerHeaders(name, serverRef) - // Check if this server has stored OAuth tokens. If so, the SDK's // authProvider will set Authorization — don't override with the // session ingress token (SDK merges requestInit AFTER authProvider). // CCR proxy URLs (ccr_shttp_mcp) have no stored OAuth, so they still // get the ingress token. See PR #24454 discussion. const hasOAuthTokens = !!(await authProvider.tokens()) + const hasExplicitAuthorization = + new Headers(combinedHeaders).has('Authorization') || + Boolean(sessionIngressToken && !hasOAuthTokens) + const allowUnauthorizedRefresh = !hasExplicitAuthorization // Use the auth provider with StreamableHTTPClientTransport const proxyOptions = getProxyFetchOptions() @@ -882,7 +915,11 @@ export const connectToServer = memoize( // Step-up detection wraps innermost so the 403 is seen before the // SDK's handler calls auth() → tokens(). fetch: wrapFetchWithTimeout( - wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider), + wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider, { + allowUnauthorizedRefresh, + resourceUrl: serverRef.url, + providerOwnsAuthorization: allowUnauthorizedRefresh, + }), ), requestInit: { ...proxyOptions, diff --git a/src/services/mcp/refreshLock.ts b/src/services/mcp/refreshLock.ts new file mode 100644 index 000000000..7ce49b2b1 --- /dev/null +++ b/src/services/mcp/refreshLock.ts @@ -0,0 +1,241 @@ +import { createHash } from 'crypto' +import { mkdir } from 'fs/promises' +import { join } from 'path' +import { raceAbort, throwIfAborted } from '../../utils/boundedAsync.js' +import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js' +import { logForDebugging } from '../../utils/debug.js' +import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { getErrnoCode } from '../../utils/errors.js' +import * as lockfile from '../../utils/lockfile.js' +import { logMCPDebug } from '../../utils/log.js' +import { sleep } from '../../utils/sleep.js' + +export const MCP_REFRESH_FRESHNESS_SECONDS = 300 + +const MAX_LOCK_RETRIES = 5 +const LOCK_STALE_MS = 10_000 +const LOCK_UPDATE_MS = LOCK_STALE_MS / 2 +const LOCK_IO_TIMEOUT_MS = 5_000 + +export type McpRefreshLockResult = { + acquired: boolean + value: T +} + +export type McpRefreshLockContext = { + acquired: boolean + signal: AbortSignal +} + +export class McpRefreshLockUnavailableError extends Error { + constructor() { + super('MCP credential refresh lock is unavailable') + this.name = 'McpRefreshLockUnavailableError' + } +} + +function getMcpRefreshLockIdentity(serverKey: string): string { + return createHash('sha256').update(serverKey).digest('hex').substring(0, 32) +} + +function logRefreshWarning(lockIdentity: string, message: string): void { + try { + logForDebugging(`[mcp-refresh:${lockIdentity}] ${message}`, { + level: 'warn', + }) + } catch { + // Refresh coordination and cleanup must not depend on diagnostics. + } +} + +async function runBoundedLockIo( + operation: Promise, + signal: AbortSignal | undefined, +): Promise { + const combined = createCombinedAbortSignal(signal, { + timeoutMs: LOCK_IO_TIMEOUT_MS, + }) + try { + return await raceAbort( + operation, + combined.signal, + 'MCP refresh lock operation aborted', + ) + } finally { + combined.cleanup() + } +} + +async function releaseRefreshLock( + release: () => Promise, +): Promise { + try { + // Never race release against a timeout or caller cancellation. A release + // that continues after this helper returns could remove a successor's + // lock directory and break the serialization boundary. + await release() + return true + } catch { + return false + } +} + +async function acquireRefreshLock( + lockPath: string, + options: Parameters[1], + signal: AbortSignal | undefined, +): Promise<() => Promise> { + const combined = createCombinedAbortSignal(signal, { + timeoutMs: LOCK_IO_TIMEOUT_MS, + }) + const acquisition = lockfile.lock(lockPath, options).then(async release => { + if (combined.signal.aborted) { + // The caller has already regained control. Clean up a late acquisition + // without inheriting the already-aborted request signal. + await releaseRefreshLock(release) + throw combined.signal.reason + } + return release + }) + try { + return await raceAbort( + acquisition, + combined.signal, + 'MCP refresh lock acquisition aborted', + ) + } finally { + combined.cleanup() + } +} + +export function getMcpRefreshLockPath( + serverKey: string, + configDir = getClaudeConfigHomeDir(), +): string { + return join( + configDir, + `mcp-refresh-${getMcpRefreshLockIdentity(serverKey)}.lock`, + ) +} + +/** + * Runs one MCP credential refresh under the canonical server-scoped lock. + * + * After bounded acquisition failure, the operation gets one final + * fresh-storage check but must not perform a network refresh. Callers use the + * `acquired` flag to enforce that fail-closed policy consistently. + * + * This lock protects the provider's proactive normal OAuth refresh and silent + * XAA exchange for one server. It is not a global secure-storage lock: login, + * logout, manual token replacement, and other servers retain their existing + * coordination boundaries. + */ +export async function withMcpRefreshLock( + serverName: string, + serverKey: string, + signal: AbortSignal | undefined, + operation: (context: McpRefreshLockContext) => Promise, +): Promise> { + throwIfAborted(signal, 'MCP token refresh aborted') + + const configDir = getClaudeConfigHomeDir() + const lockPath = getMcpRefreshLockPath(serverKey, configDir) + const lockIdentity = getMcpRefreshLockIdentity(serverKey) + let release: (() => Promise) | undefined + let acquisitionFailure: string | undefined + let canAttemptLock = true + const compromisedController = new AbortController() + + try { + await runBoundedLockIo(mkdir(configDir, { recursive: true }), signal) + } catch (error) { + throwIfAborted(signal, 'MCP token refresh aborted') + acquisitionFailure = getErrnoCode(error) ?? 'directory-setup-timeout' + canAttemptLock = false + } + + for ( + let retry = 0; + canAttemptLock && retry < MAX_LOCK_RETRIES; + retry++ + ) { + throwIfAborted(signal, 'MCP token refresh aborted') + try { + logMCPDebug(serverName, `Acquiring refresh lock (attempt ${retry + 1})`) + release = await acquireRefreshLock( + lockPath, + { + realpath: false, + retries: 0, + stale: LOCK_STALE_MS, + update: LOCK_UPDATE_MS, + onCompromised: () => { + // proper-lockfile invokes this callback from its update timer. Never + // allow a diagnostic failure to escape that timer as an unhandled + // exception; the active operation still owns its normal cleanup. + try { + logMCPDebug(serverName, 'Refresh lock was compromised') + logRefreshWarning(lockIdentity, 'refresh lock was compromised') + } catch { + // Diagnostics are best-effort in a compromised-lock callback. + } + compromisedController.abort( + new DOMException('MCP refresh lock compromised', 'AbortError'), + ) + }, + }, + signal, + ) + logMCPDebug(serverName, 'Acquired refresh lock') + break + } catch (error) { + throwIfAborted(signal, 'MCP token refresh aborted') + const code = getErrnoCode(error) + acquisitionFailure = code ?? 'unknown' + if (code !== 'ELOCKED') { + break + } + logMCPDebug( + serverName, + `Refresh lock held by another process, waiting (attempt ${retry + 1}/${MAX_LOCK_RETRIES})`, + ) + if (retry < MAX_LOCK_RETRIES - 1) { + await sleep(1000 + Math.random() * 1000, signal) + throwIfAborted(signal, 'MCP token refresh aborted') + } + } + } + + const acquired = release !== undefined + if (!acquired) { + logMCPDebug( + serverName, + `Could not acquire refresh lock (${acquisitionFailure ?? 'exhausted'}); refresh blocked`, + ) + logRefreshWarning( + lockIdentity, + `refresh blocked after bounded lock acquisition failure (${acquisitionFailure ?? 'exhausted'})`, + ) + } + + const operationSignal = createCombinedAbortSignal(signal, { + signalB: compromisedController.signal, + }) + + try { + throwIfAborted(operationSignal.signal, 'MCP token refresh aborted') + return { + acquired, + value: await operation({ acquired, signal: operationSignal.signal }), + } + } finally { + if (release) { + if (await releaseRefreshLock(release)) { + logMCPDebug(serverName, 'Released refresh lock') + } else { + logMCPDebug(serverName, 'Failed to release refresh lock') + } + } + operationSignal.cleanup() + } +} diff --git a/src/services/mcp/xaa.test.ts b/src/services/mcp/xaa.test.ts new file mode 100644 index 000000000..090a81d2a --- /dev/null +++ b/src/services/mcp/xaa.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from 'bun:test' + +import { + exchangeJwtAuthGrant, + requestJwtAuthorizationGrant, +} from './xaa.js' + +const ID_JAG_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag' + +function requestTokenExchange(body: unknown) { + return requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.test/token', + audience: 'https://as.example.test', + resource: 'https://mcp.example.test/mcp', + idToken: 'identity-token', + clientId: 'idp-client', + fetchFn: async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + }) +} + +test('XAA token-exchange errors never include provider-controlled secret text', async () => { + const echoedSecret = 'identity-secret-value-7Qm2' + + const request = requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.test/token', + audience: 'https://as.example.test', + resource: 'https://mcp.example.test/mcp', + idToken: echoedSecret, + clientId: 'idp-client', + fetchFn: async () => + new Response( + JSON.stringify({ + error: 'invalid_grant', + error_description: `provider echoed ${echoedSecret}`, + }), + { status: 400 }, + ), + }) + + await expect(request).rejects.not.toThrow(echoedSecret) + await expect(request).rejects.toThrow(/HTTP 400/) +}) + +test('XAA jwt-bearer errors never include provider-controlled secret text', async () => { + const echoedSecret = 'assertion-secret-value-9Vr4' + + const request = exchangeJwtAuthGrant({ + tokenEndpoint: 'https://as.example.test/token', + assertion: echoedSecret, + clientId: 'as-client', + clientSecret: 'client-secret', + fetchFn: async () => + new Response( + JSON.stringify({ + error: 'invalid_grant', + error_description: `provider echoed ${echoedSecret}`, + }), + { status: 400 }, + ), + }) + + await expect(request).rejects.not.toThrow(echoedSecret) + await expect(request).rejects.toThrow(/HTTP 400/) +}) + +test('XAA token-exchange schema errors redact successful response data', async () => { + const echoedSecret = 'schema-secret-value-3Fs8' + const request = requestTokenExchange({ + access_token: 'id-jag', + issued_token_type: ID_JAG_TOKEN_TYPE, + expires_in: { echoedSecret }, + }) + + await expect(request).rejects.not.toThrow(echoedSecret) + await expect(request).rejects.toThrow(/did not match expected shape/) +}) + +test('XAA missing-token errors redact successful response data', async () => { + const echoedSecret = 'missing-token-secret-value-4Gt9' + const request = requestTokenExchange({ + issued_token_type: ID_JAG_TOKEN_TYPE, + scope: echoedSecret, + }) + + await expect(request).rejects.not.toThrow(echoedSecret) + await expect(request).rejects.toThrow(/missing access_token/) +}) + +test('XAA unexpected-token-type errors redact successful response data', async () => { + const echoedSecret = 'token-type-secret-value-5Hu0' + const request = requestTokenExchange({ + access_token: echoedSecret, + issued_token_type: `unexpected-${echoedSecret}`, + }) + + await expect(request).rejects.not.toThrow(echoedSecret) + await expect(request).rejects.toThrow(/unexpected issued_token_type/) +}) + +test('XAA jwt-bearer schema errors redact successful response data', async () => { + const echoedSecret = 'jwt-schema-secret-value-6Iv1' + const request = exchangeJwtAuthGrant({ + tokenEndpoint: 'https://as.example.test/token', + assertion: 'assertion', + clientId: 'as-client', + clientSecret: 'client-secret', + fetchFn: async () => + new Response( + JSON.stringify({ access_token: { echoedSecret } }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + }) + + await expect(request).rejects.not.toThrow(echoedSecret) + await expect(request).rejects.toThrow(/did not match expected shape/) +}) diff --git a/src/services/mcp/xaa.ts b/src/services/mcp/xaa.ts index c2cf52a34..80e8c9de1 100644 --- a/src/services/mcp/xaa.ts +++ b/src/services/mcp/xaa.ts @@ -25,7 +25,6 @@ import { z } from 'zod/v4' import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js' import { lazySchema } from '../../utils/lazySchema.js' import { logMCPDebug } from '../../utils/log.js' -import { jsonStringify } from '../../utils/slowOperations.js' const XAA_REQUEST_TIMEOUT_MS = 30000 @@ -83,19 +82,6 @@ export class XaaTokenExchangeError extends Error { } } -// Matches quoted values for known token-bearing keys regardless of nesting -// depth. Works on both parsed-then-stringified bodies AND raw text() error -// bodies from !res.ok paths — a misbehaving AS that echoes the request's -// subject_token/assertion/client_secret in a 4xx error envelope must not leak -// into debug logs. -const SENSITIVE_TOKEN_RE = - /"(access_token|refresh_token|id_token|assertion|subject_token|client_secret)"\s*:\s*"[^"]*"/g - -function redactTokens(raw: unknown): string { - const s = typeof raw === 'string' ? raw : jsonStringify(raw) - return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`) -} - // ─── Zod Schemas ──────────────────────────────────────────────────────────── const TokenExchangeResponseSchema = lazySchema(() => @@ -263,12 +249,12 @@ export async function requestJwtAuthorizationGrant(opts: { body: params, }) if (!res.ok) { - const body = redactTokens(await res.text()).slice(0, 200) + await res.body?.cancel().catch(() => {}) // 4xx → id_token rejected (invalid_grant etc.), clear cache. // 5xx → IdP outage, id_token may still be valid, preserve it. const shouldClear = res.status < 500 throw new XaaTokenExchangeError( - `XAA: token exchange failed: HTTP ${res.status}: ${body}`, + `XAA: token exchange failed: HTTP ${res.status}`, shouldClear, ) } @@ -285,20 +271,20 @@ export async function requestJwtAuthorizationGrant(opts: { const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange) if (!exchangeParsed.success) { throw new XaaTokenExchangeError( - `XAA: token exchange response did not match expected shape: ${redactTokens(rawExchange)}`, + 'XAA: token exchange response did not match expected shape', true, ) } const result = exchangeParsed.data if (!result.access_token) { throw new XaaTokenExchangeError( - `XAA: token exchange response missing access_token: ${redactTokens(result)}`, + 'XAA: token exchange response missing access_token', true, ) } if (result.issued_token_type !== ID_JAG_TOKEN_TYPE) { throw new XaaTokenExchangeError( - `XAA: token exchange returned unexpected issued_token_type: ${result.issued_token_type}`, + 'XAA: token exchange returned unexpected issued_token_type', true, ) } @@ -373,8 +359,8 @@ export async function exchangeJwtAuthGrant(opts: { body: params, }) if (!res.ok) { - const body = redactTokens(await res.text()).slice(0, 200) - throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`) + await res.body?.cancel().catch(() => {}) + throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}`) } let rawTokens: unknown try { @@ -387,7 +373,7 @@ export async function exchangeJwtAuthGrant(opts: { const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens) if (!tokensParsed.success) { throw new Error( - `XAA: jwt-bearer response did not match expected shape: ${redactTokens(rawTokens)}`, + 'XAA: jwt-bearer response did not match expected shape', ) } return tokensParsed.data diff --git a/src/services/mcp/xaaIdpLogin.test.ts b/src/services/mcp/xaaIdpLogin.test.ts index 2206f10ca..c4fa33b09 100644 --- a/src/services/mcp/xaaIdpLogin.test.ts +++ b/src/services/mcp/xaaIdpLogin.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { discoverOidc } from './xaaIdpLogin.js' import { shouldCompleteXaaIdpCallback, validateXaaIdpCallbackParams, @@ -60,3 +61,38 @@ test('XAA IdP callback accepts authorization codes only when state matches', () { type: 'state_mismatch' }, ) }) + +test('discoverOidc aborts a pending discovery request', async () => { + const originalFetch = globalThis.fetch + const controller = new AbortController() + let markFetchStarted!: () => void + const fetchStarted = new Promise(resolve => { + markFetchStarted = resolve + }) + + globalThis.fetch = ((_input: string | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + markFetchStarted() + const signal = init?.signal + if (signal?.aborted) { + reject(signal.reason) + return + } + signal?.addEventListener('abort', () => reject(signal.reason), { + once: true, + }) + })) as typeof globalThis.fetch + + try { + const discovery = discoverOidc( + 'https://idp.example.test', + controller.signal, + ) + await fetchStarted + controller.abort(new DOMException('cancelled', 'AbortError')) + + await assert.rejects(discovery, { name: 'AbortError' }) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/src/services/mcp/xaaIdpLogin.ts b/src/services/mcp/xaaIdpLogin.ts index 695e77098..8260c2f31 100644 --- a/src/services/mcp/xaaIdpLogin.ts +++ b/src/services/mcp/xaaIdpLogin.ts @@ -205,10 +205,11 @@ export function clearIdpClientSecret(idpIssuer: string): void { // the fix. Exported because auth.ts needs the same discovery. export async function discoverOidc( idpIssuer: string, + abortSignal?: AbortSignal, ): Promise { const base = idpIssuer.endsWith('/') ? idpIssuer : idpIssuer + '/' const url = new URL('.well-known/openid-configuration', base) - const { signal, cleanup } = createCombinedAbortSignal(undefined, { + const { signal, cleanup } = createCombinedAbortSignal(abortSignal, { timeoutMs: IDP_REQUEST_TIMEOUT_MS, }) try {