chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
/**
* @vitest-environment node
*/
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAuth, mockCreateSsrfGuardedMcpFetch, mockGuardedFetch } = vi.hoisted(() => ({
mockAuth: vi.fn(),
mockCreateSsrfGuardedMcpFetch: vi.fn(),
mockGuardedFetch: vi.fn(),
}))
vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
auth: mockAuth,
}))
vi.mock('@/lib/mcp/pinned-fetch', () => ({
createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
}))
import { mcpAuthGuarded } from '@/lib/mcp/oauth/auth'
describe('mcpAuthGuarded', () => {
const provider = {} as OAuthClientProvider
beforeEach(() => {
vi.clearAllMocks()
mockCreateSsrfGuardedMcpFetch.mockReturnValue(mockGuardedFetch)
mockAuth.mockResolvedValue('AUTHORIZED')
})
it('defaults fetchFn to the SSRF-guarded fetch', async () => {
await mcpAuthGuarded(provider, { serverUrl: 'https://mcp.example.com/mcp' })
expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
expect(mockAuth).toHaveBeenCalledWith(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: mockGuardedFetch,
})
})
it('lets a caller-supplied fetchFn override the default', async () => {
const overrideFetch = vi.fn()
await mcpAuthGuarded(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: overrideFetch,
})
expect(mockAuth).toHaveBeenCalledWith(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: overrideFetch,
})
})
it('falls back to the SSRF-guarded fetch when fetchFn is explicitly undefined', async () => {
await mcpAuthGuarded(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: undefined,
})
expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
expect(mockAuth).toHaveBeenCalledWith(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: mockGuardedFetch,
})
})
})
+19
View File
@@ -0,0 +1,19 @@
import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
type McpAuthOptions = Parameters<typeof auth>[1]
/**
* Wraps the MCP SDK's `auth()` and defaults `fetchFn` to the SSRF-guarded
* fetch. Every URL touched during an MCP OAuth exchange — discovery,
* authorization, token, and revocation endpoints — can come from
* attacker-controllable authorization-server metadata, so callers must not
* be able to omit the guard by forgetting to pass `fetchFn` explicitly.
* Pass `fetchFn` in `options` to override (e.g. in tests).
*/
export function mcpAuthGuarded(
provider: OAuthClientProvider,
options: McpAuthOptions
): ReturnType<typeof auth> {
return auth(provider, { ...options, fetchFn: options.fetchFn ?? createSsrfGuardedMcpFetch() })
}
@@ -0,0 +1,23 @@
/**
* Reasons surfaced from the OAuth callback popup back to the parent window via
* `window.opener.postMessage`. Consumed by the popup hook to render user-facing
* status messages and by the callback route to discriminate failure modes.
*/
export type McpOauthCallbackReason =
| 'authorized'
| 'provider_error'
| 'missing_params'
| 'unauthenticated'
| 'invalid_state'
| 'user_mismatch'
| 'server_gone'
| 'insecure_url'
| 'token_exchange_failed'
| 'unknown'
export interface McpOauthCallbackMessage {
type: 'mcp-oauth'
ok: boolean
serverId?: string
reason?: McpOauthCallbackReason
}
+39
View File
@@ -0,0 +1,39 @@
import { decryptSecret } from '@/lib/core/security/encryption'
interface OauthCredsDiffParams {
incomingClientId: string | null | undefined
incomingClientIdProvided: boolean
incomingClientSecret: string | null | undefined
incomingClientSecretProvided: boolean
currentClientId: string | null | undefined
currentEncryptedClientSecret: string | null | undefined
}
/**
* Detect whether OAuth client credentials on an MCP server row have changed.
* Decrypt failure (corrupted ciphertext, rotated key) is treated as a change so
* admins can overwrite an unusable stored secret instead of getting a 500.
*/
export async function oauthCredsChanged(params: OauthCredsDiffParams): Promise<boolean> {
const clientIdChanged =
params.incomingClientIdProvided &&
(params.incomingClientId || null) !== (params.currentClientId ?? null)
let clientSecretChanged = false
if (params.incomingClientSecretProvided) {
if (!params.incomingClientSecret) {
clientSecretChanged = params.currentEncryptedClientSecret != null
} else if (!params.currentEncryptedClientSecret) {
clientSecretChanged = true
} else {
try {
const { decrypted } = await decryptSecret(params.currentEncryptedClientSecret)
clientSecretChanged = decrypted !== params.incomingClientSecret
} catch {
clientSecretChanged = true
}
}
}
return clientIdChanged || clientSecretChanged
}
+31
View File
@@ -0,0 +1,31 @@
export { mcpAuthGuarded } from './auth'
export type {
McpOauthCallbackMessage,
McpOauthCallbackReason,
} from './callback-reasons'
export { oauthCredsChanged } from './creds-diff'
export { detectMcpAuthType } from './probe'
export {
loadPreregisteredClient,
McpOauthRedirectRequired,
type PreregisteredClient,
SimMcpOauthProvider,
} from './provider'
export { revokeMcpOauthTokens } from './revoke'
export {
clearClient,
clearState,
clearTokens,
clearVerifier,
getOrCreateOauthRow,
loadOauthRow,
loadOauthRowByState,
type McpOauthRow,
saveClientInformation,
saveCodeVerifier,
saveState,
saveTokens,
setOauthRowUser,
withMcpOauthRefreshLock,
} from './storage'
export { assertSafeOauthServerUrl, McpOauthInsecureUrlError } from './url-validation'
+110
View File
@@ -0,0 +1,110 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCreatePinnedFetch, mockCreateSsrfGuardedMcpFetch, mockPinnedFetch, mockGuardedFetch } =
vi.hoisted(() => {
const mockPinnedFetch = vi.fn()
const mockGuardedFetch = vi.fn()
return {
mockPinnedFetch,
mockGuardedFetch,
mockCreatePinnedFetch: vi.fn(() => mockPinnedFetch),
mockCreateSsrfGuardedMcpFetch: vi.fn(() => mockGuardedFetch),
}
})
vi.mock('@/lib/core/security/input-validation.server', () => ({
createPinnedFetch: mockCreatePinnedFetch,
}))
vi.mock('@/lib/mcp/pinned-fetch', () => ({
createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
}))
import { detectMcpAuthType } from '@/lib/mcp/oauth/probe'
function makeResponse(init: { status?: number; headers?: Record<string, string> }): Response {
const status = init.status ?? 200
return {
status,
ok: status >= 200 && status < 300,
headers: new Headers(init.headers ?? {}),
} as unknown as Response
}
describe('detectMcpAuthType — connection pinning (SSRF / DNS-rebinding)', () => {
let globalFetchSpy: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
globalFetchSpy = vi.fn()
vi.stubGlobal('fetch', globalFetchSpy)
})
it('pins the probe to the pre-validated IP when resolvedIP is supplied', async () => {
mockPinnedFetch.mockResolvedValue(makeResponse({ status: 200 }))
const authType = await detectMcpAuthType('https://rebind.example.com/mcp', '203.0.113.10')
expect(authType).toBe('none')
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
expect(mockCreateSsrfGuardedMcpFetch).not.toHaveBeenCalled()
expect(mockPinnedFetch).toHaveBeenCalledTimes(1)
// The unpinned global fetch must never be used — that was the SSRF sink.
expect(globalFetchSpy).not.toHaveBeenCalled()
})
it('falls back to the SSRF-guarded fetch when no resolvedIP is supplied', async () => {
mockGuardedFetch.mockResolvedValue(makeResponse({ status: 200 }))
const authType = await detectMcpAuthType('https://example.com/mcp')
expect(authType).toBe('none')
expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(mockGuardedFetch).toHaveBeenCalledTimes(1)
expect(globalFetchSpy).not.toHaveBeenCalled()
})
it('classifies an RFC 9728 OAuth challenge as oauth via the pinned fetch', async () => {
mockPinnedFetch.mockResolvedValue(
makeResponse({
status: 401,
headers: {
'www-authenticate':
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
},
})
)
const authType = await detectMcpAuthType('https://example.com/mcp', '203.0.113.10')
expect(authType).toBe('oauth')
expect(globalFetchSpy).not.toHaveBeenCalled()
})
it('does not probe (no network call) for non-https, non-loopback URLs', async () => {
const authType = await detectMcpAuthType('http://example.com/mcp', '203.0.113.10')
expect(authType).toBe('headers')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(mockCreateSsrfGuardedMcpFetch).not.toHaveBeenCalled()
expect(globalFetchSpy).not.toHaveBeenCalled()
})
it('reuses the pinned fetch for best-effort session cleanup (DELETE)', async () => {
mockPinnedFetch
.mockResolvedValueOnce(makeResponse({ status: 200, headers: { 'mcp-session-id': 'sess-1' } }))
.mockResolvedValueOnce(makeResponse({ status: 200 }))
const authType = await detectMcpAuthType('https://example.com/mcp', '203.0.113.10')
expect(authType).toBe('none')
// POST probe + DELETE cleanup, both through the pinned fetch.
await vi.waitFor(() => expect(mockPinnedFetch).toHaveBeenCalledTimes(2))
const deleteCall = mockPinnedFetch.mock.calls[1]
expect(deleteCall[1]).toMatchObject({ method: 'DELETE' })
expect(globalFetchSpy).not.toHaveBeenCalled()
})
})
+116
View File
@@ -0,0 +1,116 @@
import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js'
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
import { createLogger } from '@sim/logger'
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
import { isLoopbackHostname } from '@/lib/core/utils/urls'
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
import type { McpAuthType } from '@/lib/mcp/types'
const logger = createLogger('McpOauthProbe')
const PROBE_TIMEOUT_MS = 5000
/**
* Probes an MCP server URL to classify its auth requirement.
*
* The probe must never re-resolve DNS independently of the caller's SSRF
* validation, or it re-opens the DNS-rebinding window. When the caller passes a
* pre-validated `resolvedIP` the connection is pinned to it; otherwise an
* SSRF-guarded fetch validates and pins each request itself.
*/
export async function detectMcpAuthType(
url: string,
resolvedIP?: string | null
): Promise<McpAuthType> {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return 'headers'
}
const isLoopbackHttp = parsed.protocol === 'http:' && isLoopbackHostname(parsed.hostname)
if (parsed.protocol !== 'https:' && !isLoopbackHttp) {
return 'headers'
}
const probeFetch: FetchLike = resolvedIP
? createPinnedFetch(resolvedIP)
: createSsrfGuardedMcpFetch()
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
const res = await probeFetch(url, {
method: 'POST',
redirect: 'manual',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'sim-platform-probe', version: '1.0.0' },
},
}),
signal: controller.signal,
})
const sessionId = res.headers.get('mcp-session-id')
if (sessionId) {
void closeMcpSession(url, sessionId, probeFetch)
}
if (res.status === 401) {
const params = extractWWWAuthenticateParams(res)
// Per RFC 9728, an OAuth-protected resource signals OAuth via
// `resource_metadata=...` in WWW-Authenticate. `scope=...` is also an
// OAuth-specific hint. A bare `error="invalid_token"` is generic Bearer
// and used by plain API-key servers too, so it must not classify as OAuth.
if (params.resourceMetadataUrl || params.scope) {
return 'oauth'
}
return 'headers'
}
if (res.ok) return 'none'
return 'headers'
} catch (e) {
logger.warn(`Probe failed for ${url}`, e)
return 'headers'
} finally {
clearTimeout(timer)
}
}
/**
* Best-effort DELETE to release the streamable-HTTP session the probe just
* allocated. Reuses the probe's pinned fetch so this cleanup hop stays pinned.
* Failures are ignored — the session will expire on the server side.
*/
async function closeMcpSession(
url: string,
sessionId: string,
probeFetch: FetchLike
): Promise<void> {
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
await probeFetch(url, {
method: 'DELETE',
headers: { 'Mcp-Session-Id': sessionId },
signal: controller.signal,
})
} finally {
clearTimeout(timer)
}
} catch {
// Ignore — best-effort cleanup
}
}
+179
View File
@@ -0,0 +1,179 @@
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
clearClient,
clearState,
clearTokens,
clearVerifier,
type McpOauthRow,
saveClientInformation as saveClientInformationDb,
saveCodeVerifier as saveCodeVerifierDb,
saveState,
saveTokens as saveTokensDb,
} from '@/lib/mcp/oauth/storage'
const logger = createLogger('SimMcpOauthProvider')
export class McpOauthRedirectRequired extends Error {
constructor(public readonly authorizationUrl: string) {
super('MCP OAuth redirect required')
this.name = 'McpOauthRedirectRequired'
}
}
export interface PreregisteredClient {
clientId: string
clientSecret?: string
}
interface SimMcpOauthProviderInit {
row: McpOauthRow
scope?: string
/**
* Optional user-supplied client credentials. When provided, the SDK skips
* Dynamic Client Registration and uses these for the auth/token exchange.
*/
preregistered?: PreregisteredClient
}
export class SimMcpOauthProvider implements OAuthClientProvider {
private row: McpOauthRow
private readonly scope?: string
private readonly preregistered?: PreregisteredClient
constructor({ row, scope, preregistered }: SimMcpOauthProviderInit) {
this.row = row
this.scope = scope
this.preregistered = preregistered
}
get redirectUrl(): string {
return `${getBaseUrl().replace(/\/$/, '')}/api/mcp/oauth/callback`
}
get clientMetadata(): OAuthClientMetadata {
const meta: OAuthClientMetadata = {
client_name: 'Sim',
redirect_uris: [this.redirectUrl],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: this.preregistered?.clientSecret ? 'client_secret_post' : 'none',
}
if (this.scope) meta.scope = this.scope
return meta
}
async state(): Promise<string> {
const state = generateId()
await saveState(this.row.id, state)
return state
}
clientInformation(): OAuthClientInformationMixed | undefined {
if (this.row.clientInformation) return this.row.clientInformation
if (this.preregistered) {
return {
client_id: this.preregistered.clientId,
client_secret: this.preregistered.clientSecret,
redirect_uris: [this.redirectUrl],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: this.preregistered.clientSecret ? 'client_secret_post' : 'none',
}
}
return undefined
}
async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
if (this.preregistered) return
await saveClientInformationDb(this.row.id, info)
this.row.clientInformation = info
}
tokens(): OAuthTokens | undefined {
return this.row.tokens ?? undefined
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await saveTokensDb(this.row.id, tokens)
this.row.tokens = tokens
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
throw new McpOauthRedirectRequired(authorizationUrl.toString())
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
await saveCodeVerifierDb(this.row.id, codeVerifier)
this.row.codeVerifier = codeVerifier
}
async codeVerifier(): Promise<string> {
if (!this.row.codeVerifier) {
throw new Error('No PKCE code verifier saved for this MCP OAuth session')
}
return this.row.codeVerifier
}
async invalidateCredentials(
scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'
): Promise<void> {
if (scope === 'all' || scope === 'client') {
await clearClient(this.row.id)
this.row.clientInformation = null
}
if (scope === 'all' || scope === 'tokens') {
await clearTokens(this.row.id)
this.row.tokens = null
}
if (scope === 'all' || scope === 'verifier') {
await clearVerifier(this.row.id)
await clearState(this.row.id)
this.row.codeVerifier = null
}
}
get rowId(): string {
return this.row.id
}
}
export async function loadPreregisteredClient(
serverId: string
): Promise<PreregisteredClient | undefined> {
const [row] = await db
.select({
clientId: mcpServers.oauthClientId,
clientSecret: mcpServers.oauthClientSecret,
})
.from(mcpServers)
.where(eq(mcpServers.id, serverId))
.limit(1)
if (!row?.clientId) return undefined
let clientSecret: string | undefined
if (row.clientSecret) {
try {
const { decrypted } = await decryptSecret(row.clientSecret)
clientSecret = decrypted
} catch (error) {
logger.error('Failed to decrypt preregistered MCP OAuth client secret', {
serverId,
error: toError(error).message,
})
throw new Error('Failed to decrypt preregistered MCP OAuth client secret')
}
}
return { clientId: row.clientId, clientSecret }
}
+149
View File
@@ -0,0 +1,149 @@
/**
* @vitest-environment node
*
* Regression test: `revokeMcpOauthTokens` must route both metadata discovery
* and the RFC 7009 revocation POST through the SSRF-guarded fetch, since
* `revocation_endpoint` comes from attacker-controlled server metadata. Uses
* the real `createSsrfGuardedMcpFetch` so it fails if revoke.ts regresses to a
* raw `fetch`.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const BLOCKED_ENDPOINT = 'http://169.254.170.2/v2/credentials/'
const PUBLIC_SERVER_URL = 'https://mcp.attacker.com'
const PUBLIC_SERVER_IP = '203.0.113.10'
const {
mockUndiciFetch,
mockValidateMcpServerSsrf,
mockDiscoverOAuthServerInfo,
mockLoadOauthRow,
mockDecryptSecret,
mockDbSelect,
} = vi.hoisted(() => ({
mockUndiciFetch: vi.fn(),
mockValidateMcpServerSsrf: vi.fn(),
mockDiscoverOAuthServerInfo: vi.fn(),
mockLoadOauthRow: vi.fn(),
mockDecryptSecret: vi.fn(),
mockDbSelect: vi.fn(),
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
createPinnedFetch: vi.fn(() => mockUndiciFetch),
}))
vi.mock('@/lib/mcp/domain-check', () => ({
validateMcpServerSsrf: mockValidateMcpServerSsrf,
}))
vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
discoverOAuthServerInfo: mockDiscoverOAuthServerInfo,
}))
vi.mock('@/lib/mcp/oauth/storage', () => ({
loadOauthRow: mockLoadOauthRow,
}))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
}))
vi.mock('@sim/db', () => ({
db: { select: mockDbSelect },
}))
import { revokeMcpOauthTokens } from './revoke'
function wireServerRow(row: Record<string, unknown>) {
const builder = {
from: () => builder,
where: () => builder,
limit: () => Promise.resolve([row]),
}
mockDbSelect.mockReturnValue(builder)
}
describe('revokeMcpOauthTokens — SSRF guard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLoadOauthRow.mockResolvedValue({
tokens: { access_token: 'access-secret', refresh_token: 'refresh-secret' },
clientInformation: { client_id: 'client-123' },
})
wireServerRow({
url: PUBLIC_SERVER_URL,
oauthClientId: 'client-123',
oauthClientSecret: null,
})
mockDiscoverOAuthServerInfo.mockResolvedValue({
authorizationServerMetadata: {
issuer: PUBLIC_SERVER_URL,
revocation_endpoint: BLOCKED_ENDPOINT,
},
})
mockUndiciFetch.mockResolvedValue(new Response('ok'))
// Catches a regression to raw globalThis.fetch without hitting the network.
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok'))
// Public server host resolves; the revocation endpoint is blocked.
mockValidateMcpServerSsrf.mockImplementation(async (target: string) => {
if (target.startsWith(BLOCKED_ENDPOINT) || target.includes('169.254.')) {
throw new Error('MCP server URL resolves to a blocked IP address')
}
return PUBLIC_SERVER_IP
})
})
it('routes metadata discovery through the SSRF-guarded fetch', async () => {
await revokeMcpOauthTokens('server-1')
expect(mockDiscoverOAuthServerInfo).toHaveBeenCalledTimes(1)
const [, options] = mockDiscoverOAuthServerInfo.mock.calls[0]
expect(typeof options?.fetchFn).toBe('function')
})
it('validates the attacker-controlled revocation_endpoint before issuing the request', async () => {
await revokeMcpOauthTokens('server-1')
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(BLOCKED_ENDPOINT)
})
it('never issues an outbound request to the blocked revocation endpoint', async () => {
await revokeMcpOauthTokens('server-1')
const allCalls = [
...mockUndiciFetch.mock.calls,
...(globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls,
]
for (const call of allCalls) {
const target = typeof call[0] === 'string' ? call[0] : String(call[0])
expect(target).not.toContain('169.254.170.2')
}
})
it('swallows the SSRF rejection — revocation is best-effort and never throws', async () => {
await expect(revokeMcpOauthTokens('server-1')).resolves.toBeUndefined()
})
it('still issues the revocation POST when the endpoint resolves to a public IP', async () => {
const publicEndpoint = 'https://mcp.attacker.com/oauth/revoke'
mockDiscoverOAuthServerInfo.mockResolvedValue({
authorizationServerMetadata: {
issuer: PUBLIC_SERVER_URL,
revocation_endpoint: publicEndpoint,
},
})
await revokeMcpOauthTokens('server-1')
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(publicEndpoint)
const revokeCalls = mockUndiciFetch.mock.calls.filter((call) => {
const target = typeof call[0] === 'string' ? call[0] : String(call[0])
return target === publicEndpoint
})
expect(revokeCalls.length).toBeGreaterThan(0)
expect(revokeCalls[0][1]).toMatchObject({ method: 'POST' })
})
})
+110
View File
@@ -0,0 +1,110 @@
import { discoverOAuthServerInfo } from '@modelcontextprotocol/sdk/client/auth.js'
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { decryptSecret } from '@/lib/core/security/encryption'
import { loadOauthRow } from '@/lib/mcp/oauth/storage'
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
const logger = createLogger('McpOauthRevoke')
const REVOKE_TIMEOUT_MS = 5000
/**
* Best-effort RFC 7009 revocation of tokens at the authorization server.
* Never throws — revocation is advisory and must not block disconnect/delete flows.
*/
export async function revokeMcpOauthTokens(mcpServerId: string): Promise<void> {
try {
const row = await loadOauthRow({ mcpServerId })
if (!row?.tokens) return
const [server] = await db
.select({
url: mcpServers.url,
oauthClientId: mcpServers.oauthClientId,
oauthClientSecret: mcpServers.oauthClientSecret,
})
.from(mcpServers)
.where(eq(mcpServers.id, mcpServerId))
.limit(1)
if (!server?.url) return
const ssrfGuardedFetch = createSsrfGuardedMcpFetch()
const info = await discoverOAuthServerInfo(server.url, { fetchFn: ssrfGuardedFetch }).catch(
() => undefined
)
const metadata = info?.authorizationServerMetadata as
| (Record<string, unknown> & { revocation_endpoint?: string })
| undefined
const revocationEndpoint = metadata?.revocation_endpoint
if (!revocationEndpoint) return
const clientInfo = row.clientInformation
const clientId = clientInfo?.client_id ?? server.oauthClientId ?? undefined
if (!clientId) return
let clientSecret = clientInfo?.client_secret
if (!clientSecret && server.oauthClientSecret) {
try {
const { decrypted } = await decryptSecret(server.oauthClientSecret)
clientSecret = decrypted
} catch {
clientSecret = undefined
}
}
const tokensToRevoke: Array<{ token: string; hint: 'refresh_token' | 'access_token' }> = []
if (row.tokens.refresh_token) {
tokensToRevoke.push({ token: row.tokens.refresh_token, hint: 'refresh_token' })
}
if (row.tokens.access_token) {
tokensToRevoke.push({ token: row.tokens.access_token, hint: 'access_token' })
}
for (const { token, hint } of tokensToRevoke) {
await postRevoke(revocationEndpoint, token, hint, clientId, clientSecret, ssrfGuardedFetch)
}
} catch (error) {
logger.warn(`Token revocation failed for server ${mcpServerId}`, {
error: toError(error).message,
})
}
}
async function postRevoke(
endpoint: string,
token: string,
hint: 'refresh_token' | 'access_token',
clientId: string,
clientSecret: string | undefined,
fetchFn: FetchLike
): Promise<void> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REVOKE_TIMEOUT_MS)
try {
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
}
const params = new URLSearchParams({ token, token_type_hint: hint })
if (clientSecret) {
headers.Authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`
} else {
params.set('client_id', clientId)
}
const res = await fetchFn(endpoint, {
method: 'POST',
headers,
body: params.toString(),
signal: controller.signal,
})
if (!res.ok) {
logger.info(`Revocation returned ${res.status} for ${hint}; treating as best-effort`)
}
} finally {
clearTimeout(timer)
}
}
+292
View File
@@ -0,0 +1,292 @@
/**
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
encryptionMock,
encryptionMockFns,
resetDbChainMock,
schemaMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAcquireLock, mockReleaseLock, mockExtendLock } = vi.hoisted(() => ({
mockAcquireLock: vi.fn(),
mockReleaseLock: vi.fn(),
mockExtendLock: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/lib/core/config/redis', () => ({
acquireLock: mockAcquireLock,
releaseLock: mockReleaseLock,
extendLock: mockExtendLock,
}))
import {
getOrCreateOauthRow,
loadOauthRow,
setOauthRowUser,
withMcpOauthRefreshLock,
} from './storage'
describe('MCP OAuth storage', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: '{}' })
encryptionMockFns.mockEncryptSecret.mockResolvedValue({
encrypted: 'encrypted',
iv: 'iv',
})
})
it('loads OAuth state by MCP server, independent of the requesting user', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'oauth-row-1',
mcpServerId: 'server-1',
userId: 'authorizer-1',
workspaceId: 'workspace-1',
clientInformation: null,
tokens: null,
codeVerifier: null,
state: null,
updatedAt: new Date(),
},
])
const row = await loadOauthRow({ mcpServerId: 'server-1' })
expect(row).toMatchObject({
id: 'oauth-row-1',
mcpServerId: 'server-1',
userId: 'authorizer-1',
workspaceId: 'workspace-1',
})
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1)
})
it('reuses the existing workspace OAuth row instead of creating a per-user row', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'oauth-row-1',
mcpServerId: 'server-1',
userId: 'authorizer-1',
workspaceId: 'workspace-1',
clientInformation: null,
tokens: null,
codeVerifier: null,
state: null,
updatedAt: new Date(),
},
])
const row = await getOrCreateOauthRow({
mcpServerId: 'server-1',
userId: 'different-user',
workspaceId: 'workspace-1',
})
expect(row.id).toBe('oauth-row-1')
expect(row.userId).toBe('authorizer-1')
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})
it('records the latest authorizing user without changing row ownership', async () => {
await setOauthRowUser('oauth-row-1', 'user-2')
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-2',
updatedAt: expect.any(Date),
})
)
})
})
describe('withMcpOauthRefreshLock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAcquireLock.mockReset()
mockReleaseLock.mockReset()
mockExtendLock.mockReset()
mockReleaseLock.mockResolvedValue(true)
mockExtendLock.mockResolvedValue(true)
})
it('serializes concurrent in-process callers, each running its own fn()', async () => {
mockAcquireLock.mockResolvedValue(true)
let active = 0
let maxActive = 0
const fn = vi.fn(async () => {
active++
maxActive = Math.max(maxActive, active)
await new Promise((r) => setTimeout(r, 1))
active--
return 'tokens'
})
const results = await Promise.all([
withMcpOauthRefreshLock('row-serial', fn),
withMcpOauthRefreshLock('row-serial', fn),
withMcpOauthRefreshLock('row-serial', fn),
])
expect(results).toEqual(['tokens', 'tokens', 'tokens'])
// Each caller gets its own fn() invocation — critical because fn() returns
// a stateful McpClient that can't be shared across consumers.
expect(fn).toHaveBeenCalledTimes(3)
// But never two at the same time within a process.
expect(maxActive).toBe(1)
expect(mockAcquireLock).toHaveBeenCalledTimes(3)
expect(mockReleaseLock).toHaveBeenCalledTimes(3)
})
it('serializes cross-process callers: follower polls until leader releases', async () => {
// First acquire fails (another process holds it), second succeeds.
mockAcquireLock.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const fn = vi.fn(async () => 'fresh')
const result = await withMcpOauthRefreshLock('row-mutex', fn)
expect(result).toBe('fresh')
expect(mockAcquireLock).toHaveBeenCalledTimes(2)
expect(fn).toHaveBeenCalledTimes(1)
})
it('falls open when Redis is unavailable on acquire', async () => {
mockAcquireLock.mockRejectedValueOnce(new Error('Redis connection refused'))
const fn = vi.fn(async () => 'uncoordinated')
const result = await withMcpOauthRefreshLock('row-redis-down', fn)
expect(result).toBe('uncoordinated')
expect(fn).toHaveBeenCalledTimes(1)
expect(mockReleaseLock).not.toHaveBeenCalled()
})
it('releases the lock even when fn throws', async () => {
mockAcquireLock.mockResolvedValue(true)
const fn = vi.fn(async () => {
throw new Error('refresh failed')
})
await expect(withMcpOauthRefreshLock('row-throws', fn)).rejects.toThrow('refresh failed')
expect(mockReleaseLock).toHaveBeenCalledTimes(1)
})
it('does not surface releaseLock failures to the caller', async () => {
mockAcquireLock.mockResolvedValue(true)
mockReleaseLock.mockRejectedValueOnce(new Error('release failed'))
const fn = vi.fn(async () => 'value')
const result = await withMcpOauthRefreshLock('row-release-fail', fn)
expect(result).toBe('value')
})
it('uses per-row lock keys so different rows do not serialize', async () => {
mockAcquireLock.mockResolvedValue(true)
const fn = vi.fn(async () => 'ok')
await Promise.all([withMcpOauthRefreshLock('row-a', fn), withMcpOauthRefreshLock('row-b', fn)])
expect(mockAcquireLock).toHaveBeenCalledTimes(2)
const keys = mockAcquireLock.mock.calls.map((c) => c[0])
expect(keys).toContain('mcp:oauth:refresh:row-a')
expect(keys).toContain('mcp:oauth:refresh:row-b')
})
it('throws when the lock is held longer than the max wait (does not race)', async () => {
vi.useFakeTimers()
try {
// Acquire always fails — another process holds the lock with watchdog extension.
mockAcquireLock.mockResolvedValue(false)
const fn = vi.fn(async () => 'should-not-run')
const pending = withMcpOauthRefreshLock('row-deadline', fn)
// Attach the rejection expectation before draining so Vitest doesn't see
// an unhandled rejection while timers advance.
const assertion = expect(pending).rejects.toThrow(/held longer than/)
await vi.advanceTimersByTimeAsync(31_000)
await assertion
expect(fn).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('bounds the queue wait: callers stalled behind a wedged link reject without running fn', async () => {
vi.useFakeTimers()
try {
mockAcquireLock.mockResolvedValue(true)
let resolveFirst: (value: string) => void
const hungFn = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveFirst = resolve
})
)
const queuedFn = vi.fn(async () => 'second')
const first = withMcpOauthRefreshLock('row-stall', hungFn)
const second = withMcpOauthRefreshLock('row-stall', queuedFn)
const secondAssertion = expect(second).rejects.toThrow(/stalled for/)
await vi.advanceTimersByTimeAsync(90_000)
await secondAssertion
expect(queuedFn).not.toHaveBeenCalled()
// The wedged link is untouched by the queue deadline (its own fn keeps
// the lock, protecting a possibly mid-rotation refresh) and the row
// heals once it settles — including skipping the abandoned link's fn.
resolveFirst!('first')
await expect(first).resolves.toBe('first')
await expect(withMcpOauthRefreshLock('row-stall', async () => 'healed')).resolves.toBe(
'healed'
)
expect(queuedFn).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('extends the lock TTL while fn() is running so long refreshes do not lose the lock', async () => {
vi.useFakeTimers()
try {
mockAcquireLock.mockResolvedValue(true)
let resolveFn: (v: string) => void
const fn = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveFn = resolve
})
)
const pending = withMcpOauthRefreshLock('row-watchdog', fn)
// Advance time past two extend intervals (5s + 5s = 10s).
await vi.advanceTimersByTimeAsync(11_000)
expect(mockExtendLock.mock.calls.length).toBeGreaterThanOrEqual(2)
for (const call of mockExtendLock.mock.calls) {
expect(call[0]).toBe('mcp:oauth:refresh:row-watchdog')
}
resolveFn!('done')
await expect(pending).resolves.toBe('done')
// Watchdog must stop once fn() settles — no more extend calls.
const extendCallsAtFinish = mockExtendLock.mock.calls.length
await vi.advanceTimersByTimeAsync(20_000)
expect(mockExtendLock.mock.calls.length).toBe(extendCallsAtFinish)
} finally {
vi.useRealTimers()
}
})
})
+366
View File
@@ -0,0 +1,366 @@
import { createHash } from 'node:crypto'
import type {
OAuthClientInformationMixed,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js'
import { db } from '@sim/db'
import { mcpServerOauth } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId, generateShortId } from '@sim/utils/id'
import { and, eq, gt } from 'drizzle-orm'
import { acquireLock, extendLock, releaseLock } from '@/lib/core/config/redis'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
const logger = createLogger('McpOauthStorage')
function hashState(state: string): string {
return createHash('sha256').update(state).digest('hex')
}
const STATE_TTL_MS = 10 * 60 * 1000
export interface McpOauthRow {
id: string
mcpServerId: string
userId: string | null
workspaceId: string
clientInformation: OAuthClientInformationMixed | null
tokens: OAuthTokens | null
codeVerifier: string | null
state: string | null
stateCreatedAt: Date | null
updatedAt: Date
}
async function encryptTokens(tokens: OAuthTokens): Promise<string> {
const { encrypted } = await encryptSecret(JSON.stringify(tokens))
return encrypted
}
async function encryptClientInformation(info: OAuthClientInformationMixed): Promise<string> {
const { encrypted } = await encryptSecret(JSON.stringify(info))
return encrypted
}
/**
* Returns `null` and clears the column when decryption fails (e.g. key rotation)
* so the next call triggers a fresh OAuth flow instead of a 500.
*/
async function safeDecrypt<T>(
rowId: string,
column: 'tokens' | 'clientInformation' | 'codeVerifier',
encrypted: string,
decode: (decrypted: string) => T
): Promise<T | null> {
try {
const { decrypted } = await decryptSecret(encrypted)
return decode(decrypted)
} catch (error) {
logger.warn(`Failed to decrypt ${column} for OAuth row ${rowId}; clearing column`, {
error: toError(error).message,
})
await db
.update(mcpServerOauth)
.set({ [column]: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
return null
}
}
export async function getOrCreateOauthRow(params: {
mcpServerId: string
userId: string
workspaceId: string
}): Promise<McpOauthRow> {
const existing = await loadOauthRow(params)
if (existing) return existing
const id = generateId()
try {
await db.insert(mcpServerOauth).values({
id,
mcpServerId: params.mcpServerId,
userId: params.userId,
workspaceId: params.workspaceId,
})
} catch (error) {
const winner = await loadOauthRow(params)
if (winner) return winner
throw error
}
return {
id,
mcpServerId: params.mcpServerId,
userId: params.userId,
workspaceId: params.workspaceId,
clientInformation: null,
tokens: null,
codeVerifier: null,
state: null,
stateCreatedAt: null,
updatedAt: new Date(),
}
}
type RawOauthRow = typeof mcpServerOauth.$inferSelect
async function mapOauthRow(row: RawOauthRow): Promise<McpOauthRow> {
return {
id: row.id,
mcpServerId: row.mcpServerId,
userId: row.userId,
workspaceId: row.workspaceId,
clientInformation: row.clientInformation
? await safeDecrypt(
row.id,
'clientInformation',
row.clientInformation,
(d) => JSON.parse(d) as OAuthClientInformationMixed
)
: null,
tokens: row.tokens
? await safeDecrypt(row.id, 'tokens', row.tokens, (d) => JSON.parse(d) as OAuthTokens)
: null,
codeVerifier: row.codeVerifier
? await safeDecrypt(row.id, 'codeVerifier', row.codeVerifier, (d) => d)
: null,
state: row.state,
stateCreatedAt: row.stateCreatedAt,
updatedAt: row.updatedAt,
}
}
export async function loadOauthRow(params: { mcpServerId: string }): Promise<McpOauthRow | null> {
const [row] = await db
.select()
.from(mcpServerOauth)
.where(eq(mcpServerOauth.mcpServerId, params.mcpServerId))
.limit(1)
if (!row) return null
return mapOauthRow(row)
}
export async function setOauthRowUser(rowId: string, userId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ userId, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function loadOauthRowByState(state: string): Promise<McpOauthRow | null> {
const [row] = await db
.select()
.from(mcpServerOauth)
.where(
and(
eq(mcpServerOauth.state, hashState(state)),
gt(mcpServerOauth.stateCreatedAt, new Date(Date.now() - STATE_TTL_MS))
)
)
.limit(1)
if (!row) return null
return mapOauthRow(row)
}
export async function saveClientInformation(
rowId: string,
info: OAuthClientInformationMixed
): Promise<void> {
const encrypted = await encryptClientInformation(info)
await db
.update(mcpServerOauth)
.set({ clientInformation: encrypted, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function saveTokens(rowId: string, tokens: OAuthTokens): Promise<void> {
const encrypted = await encryptTokens(tokens)
await db
.update(mcpServerOauth)
.set({ tokens: encrypted, lastRefreshedAt: new Date(), updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function saveCodeVerifier(rowId: string, verifier: string): Promise<void> {
const { encrypted } = await encryptSecret(verifier)
await db
.update(mcpServerOauth)
.set({ codeVerifier: encrypted, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function saveState(rowId: string, state: string): Promise<void> {
const now = new Date()
await db
.update(mcpServerOauth)
.set({ state: hashState(state), stateCreatedAt: now, updatedAt: now })
.where(eq(mcpServerOauth.id, rowId))
}
export async function clearTokens(rowId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ tokens: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function clearClient(rowId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ clientInformation: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function clearVerifier(rowId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ codeVerifier: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function clearState(rowId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ state: null, stateCreatedAt: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
/**
* Serialize OAuth row access across all callers, in-process AND across
* processes. Refresh tokens rotate (RFC 6749 §6, MCP §2.3.3), so two concurrent
* refreshes against the same row would race and one would receive
* `invalid_grant`, wiping credentials.
*
* Two-tier serialization (each caller runs its OWN `fn()` — callers consume
* `McpClient` instances that can't be shared, unlike a scalar access token):
* 1) In-process: per-row Promise chain. Concurrent callers queue; each
* runs `fn()` after the previous settles. The queue wait is bounded —
* a caller whose turn does not arrive within
* {@link REFRESH_QUEUE_WAIT_TIMEOUT_MS} rejects without ever running
* its `fn()`, so a wedged link cannot accumulate callers indefinitely.
* 2) Cross-process: Redis mutex (`acquireLock` / `releaseLock`) with a TTL
* watchdog that periodically extends the lock while `fn()` runs, so
* long-running refreshes don't drop the lock and let another process
* race onto the same refresh.
*
* Falls open if Redis is unavailable — `acquireLock` no-ops, but in-process
* serialization still holds within a single Node process.
*/
const REFRESH_LOCK_TTL_SEC = 15
const REFRESH_LOCK_EXTEND_INTERVAL_MS = 5_000
const REFRESH_POLL_INTERVAL_MS = 100
const REFRESH_MAX_WAIT_MS = 30_000
/**
* Deadline on the in-process QUEUE WAIT only — the time a caller spends
* waiting for its turn behind queued predecessors. Without it, one hung
* link wedges every subsequent caller for that row until process restart.
* Sized to survive one legitimately slow predecessor: up to
* REFRESH_MAX_WAIT_MS of cross-process lock contention plus the MCP SDK's
* 60s initialize timeout. Deliberately NOT applied to the caller's own
* `fn()` run — aborting a running `fn()` would orphan a connected
* `McpClient` and abandon a possibly mid-rotation refresh; `fn()` is
* bounded by its own SDK/HTTP/Redis timeouts instead.
*/
const REFRESH_QUEUE_WAIT_TIMEOUT_MS = 90_000
const inflightChains = new Map<string, Promise<unknown>>()
export async function withMcpOauthRefreshLock<T>(rowId: string, fn: () => Promise<T>): Promise<T> {
const lockKey = `mcp:oauth:refresh:${rowId}`
const prev = inflightChains.get(lockKey) ?? Promise.resolve()
const prevSettled = prev.catch(() => undefined)
let queueTimedOut = false
const next = prevSettled.then(() => {
if (queueTimedOut) {
throw new Error(`MCP OAuth refresh queue for ${rowId} abandoned after timeout`)
}
return runWithRedisMutex(lockKey, rowId, fn)
})
inflightChains.set(lockKey, next)
const cleanup = () => {
if (inflightChains.get(lockKey) === next) inflightChains.delete(lockKey)
}
next.then(cleanup, cleanup)
let queueTimer: ReturnType<typeof setTimeout> | undefined
const queueDeadline = new Promise<never>((_, reject) => {
queueTimer = setTimeout(() => {
queueTimedOut = true
reject(
new Error(
`MCP OAuth refresh queue for ${rowId} stalled for ${REFRESH_QUEUE_WAIT_TIMEOUT_MS}ms`
)
)
}, REFRESH_QUEUE_WAIT_TIMEOUT_MS)
queueTimer.unref?.()
})
try {
await Promise.race([prevSettled, queueDeadline])
} finally {
clearTimeout(queueTimer)
}
return next
}
async function runWithRedisMutex<T>(
lockKey: string,
rowId: string,
fn: () => Promise<T>
): Promise<T> {
const ownerToken = generateShortId()
const deadline = Date.now() + REFRESH_MAX_WAIT_MS
while (true) {
let acquired = false
try {
acquired = await acquireLock(lockKey, ownerToken, REFRESH_LOCK_TTL_SEC)
} catch (error) {
logger.warn('Redis unavailable, running OAuth flow uncoordinated', {
rowId,
error: toError(error).message,
})
return fn()
}
if (acquired) {
const watchdog = setInterval(() => {
extendLock(lockKey, ownerToken, REFRESH_LOCK_TTL_SEC).catch((error) => {
logger.warn('Refresh lock extend failed', {
rowId,
error: toError(error).message,
})
})
}, REFRESH_LOCK_EXTEND_INTERVAL_MS)
try {
return await fn()
} finally {
clearInterval(watchdog)
await releaseLock(lockKey, ownerToken).catch((error) => {
logger.warn('Refresh lock release failed (will expire via TTL)', {
rowId,
error: toError(error).message,
})
})
}
}
if (Date.now() >= deadline) {
// Lock still held by another process AND its watchdog is keeping it
// alive — falling open would let us refresh concurrently and race the
// rotating refresh token. Throw and let the caller decide whether to
// retry; the Redis-down path remains the only branch that runs `fn()`
// uncoordinated (no coordination available there).
throw new Error(
`MCP OAuth refresh lock for ${rowId} held longer than ${REFRESH_MAX_WAIT_MS}ms`
)
}
await sleep(REFRESH_POLL_INTERVAL_MS)
}
}
+25
View File
@@ -0,0 +1,25 @@
import { isLoopbackHostname } from '@/lib/core/utils/urls'
export class McpOauthInsecureUrlError extends Error {
constructor(url: string) {
super(`MCP OAuth requires https for non-loopback hosts: ${url}`)
this.name = 'McpOauthInsecureUrlError'
}
}
/**
* MCP spec §2.1 and RFC 8252 §7.3: OAuth flows must run over https, with
* http allowed only for loopback addresses during local development.
*/
export function assertSafeOauthServerUrl(rawUrl: string | null | undefined): URL {
if (!rawUrl) throw new McpOauthInsecureUrlError(String(rawUrl))
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
throw new McpOauthInsecureUrlError(rawUrl)
}
if (parsed.protocol === 'https:') return parsed
if (parsed.protocol === 'http:' && isLoopbackHostname(parsed.hostname)) return parsed
throw new McpOauthInsecureUrlError(rawUrl)
}