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
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:
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user