Skip to content

MCP OAuth Authentication

MCP servers in DaraMex support four authentication types. The two OAuth types allow agents to connect to modern SaaS MCPs (Notion, Linear, Postman) that require browser-based authorization, and to service MCPs that use machine-to-machine credentials.

Auth Types

authTypeDescriptionUse Case
noneNo authenticationPublic MCPs
static_headersFixed headers (API key)Admin pastes a token manually
oauth_auth_codeOAuth 2.0 Authorization Code + PKCEModern SaaS MCPs — user authorizes via browser popup
client_credentialsOAuth Machine-to-MachineService MCPs — no human interaction

Existing servers without authType are treated as static_headers if they have encrypted headers, otherwise none. No behavioral change for pre-existing servers.

Connection Status

Each MCP server tracks a connectionStatus field that drives frontend UX without exposing token details.

stateDiagram-v2
    [*] --> disconnected: Server created (OAuth/CC)
    disconnected --> auth_pending: POST .../oauth/initiate
    auth_pending --> connected: Callback success
    auth_pending --> disconnected: Callback failure
    connected --> needs_reauth: Token refresh failed
    connected --> disconnected: Auth config changed
    needs_reauth --> auth_pending: Admin re-initiates OAuth
    needs_reauth --> disconnected: Auth config changed
StatusMeaning
disconnectedInitial state for OAuth/CC servers, or after auth config change
auth_pendingOAuth initiation done, waiting for provider callback
connectedTokens stored and valid
needs_reauthToken refresh failed — admin must re-authorize

OAuth Authorization Code + PKCE Flow

This is the primary flow for connecting to SaaS MCPs. It happens in three steps.

Step 1 — Create the MCP Server

Admin creates a server with authType: 'oauth_auth_code' and an oauthConfig object containing endpoint URLs and optionally a clientId.

If no clientId is provided, the backend attempts Dynamic Client Registration (DCR) per RFC 7591: it fetches /.well-known/oauth-authorization-server from the MCP server's base URL, discovers endpoints, and registers a client automatically. If DCR fails, an error tells the admin to provide clientId manually.

The server starts in disconnected status — it has config but no tokens yet.

Step 2 — Initiate OAuth

sequenceDiagram
    participant F as Frontend
    participant B as Backend API
    participant P as OAuth Provider

    F->>B: POST /:serverId/oauth/initiate
    B->>B: Generate code_verifier (32 random bytes, base64url)
    B->>B: Generate code_challenge = SHA-256(code_verifier)
    B->>B: Generate state (32 random bytes, hex)
    B->>B: Encrypt code_verifier, store state + 15-min TTL
    B->>B: Set connectionStatus = auth_pending
    B-->>F: { authorizationUrl }
    F->>P: window.open(authorizationUrl)
    Note over F,P: User authorizes in popup

The backend generates a PKCE challenge pair and a random state token (CSRF protection). Both are stored on the server entity — the code_verifier encrypted, the state with a 15-minute TTL. The constructed authorization URL includes response_type=code, code_challenge (S256), state, client_id, redirect_uri, and scope.

The frontend opens this URL in a popup window.

Step 3 — OAuth Callback

sequenceDiagram
    participant P as OAuth Provider
    participant B as Backend API (public endpoint)
    participant DB as Database

    P->>B: GET /:serverId/oauth/callback?code=ABC&state=Y
    B->>B: Load server by (serverId, agentId)
    B->>B: Timing-safe state comparison (crypto.timingSafeEqual)
    B->>B: Check state TTL (15 min)
    B->>B: Decrypt PKCE code_verifier
    B->>P: POST tokenEndpoint (authorization_code grant + code_verifier)
    P-->>B: { access_token, refresh_token, expires_in }
    B->>DB: BEGIN TRANSACTION
    B->>DB: Encrypt + save tokens to agent_mcp_oauth_tokens
    B->>DB: Set connectionStatus = connected, clear PKCE state
    B->>DB: COMMIT
    B-->>P: HTML: postMessage({ type: 'mcp-oauth-callback', status: 'success' }) + window.close()

The callback endpoint is public (@PublicRoute()) because the browser redirect from the OAuth provider cannot carry DaraMex session cookies. The state parameter serves as the CSRF protection mechanism (RFC 6749 section 10.12).

On success, token storage and status update are wrapped in a single DB transaction. The response is an HTML page that uses postMessage with a specific targetOrigin (from APP_FRONTEND_URL) to notify the opener window, then auto-closes the popup.

On failure (state mismatch, expired, token exchange error), PKCE state is cleared to prevent replay and connectionStatus resets to disconnected.

Client Credentials Flow

No popup or user interaction required. The admin provides clientId, clientSecret, and tokenEndpoint at server creation time.

Tokens are acquired on-demand during tool resolution: when McpAuthProvider.tokens() is called and no valid token exists, it POSTs grant_type=client_credentials to the token endpoint, encrypts and stores the result, and sets connectionStatus = connected.

Token Refresh

Token refresh is transparent and happens during tool resolution (McpToolProvider.build() calls McpAuthProvider.tokens()):

ConditionAction
Token validReturn it
Token expired + has refresh_tokenPOST grant_type=refresh_token to token endpoint, save new tokens
Token expired + no refresh_tokenSet needs_reauth, skip server (graceful degradation)
Client credentials expiredRe-acquire with grant_type=client_credentials

No background refresh jobs — the SDK calls tokens() before every MCP request, so idle servers don't waste refreshes.

Tool Resolution with Auth

McpToolProvider.build() determines how to create MCP clients per server:

  • none / static_headers: createMCPClient({ headers }) — existing behavior, unchanged.
  • oauth_auth_code / client_credentials: McpAuthProviderFactory.create(server) produces a per-server McpAuthProvider instance → createMCPClient({ authProvider }) — the SDK handles the Authorization header automatically.
  • connectionStatus === 'needs_reauth': Server is skipped with a warning log. Other servers continue normally.

Key Architecture Decisions

DecisionChoiceRationale
McpAuthProvider designPer-server instance, NOT NestJS singletonSDK expects one OAuthClientProvider per transport. Factory (McpAuthProviderFactory) is injectable.
Token storageSeparate agent_mcp_oauth_tokens table (1:1 with server)Tokens change hourly on refresh. Separate table avoids rewriting server config row.
PKCE state storageColumns on agent_mcp_servers entityOne active flow at a time per server. No cache infrastructure needed.
Callback authPublic endpoint, state param as CSRFBrowser redirect from external OAuth provider cannot carry DaraMex auth headers.
DCR discoveryStandalone McpDcrServiceReusable across create/update. Fetches /.well-known/oauth-authorization-server, registers client.

API Contract

New Endpoints

POST /agents/:agentId/mcp-servers/:serverId/oauth/initiate

  • Auth: @Auth() + @CheckPolicies(can('update', 'ai.mcp'))
  • Rate limit: 5 requests / 60 seconds per user
  • Request: empty body
  • Response 200: { authorizationUrl: string }
  • Errors: 404 server not found, 422 AI.MCP_AUTH_NOT_CONFIGURED (non-OAuth type)

GET /agents/:agentId/mcp-servers/:serverId/oauth/callback

  • Auth: none (public — browser redirect from OAuth provider)
  • Query params: code: string, state: string
  • Response 200: HTML page with postMessage to opener + window.close()
  • Errors: 422 state mismatch/expired, 502 token exchange failed

Modified Endpoints

EndpointChange
POST /agents/:agentId/mcp-serversAccepts authType + oauthConfig. Triggers DCR if oauth_auth_code without clientId.
PATCH /agents/:agentId/mcp-servers/:serverIdAccepts auth config changes. If oauthConfig changes: resets connectionStatus + deletes tokens.
GET /agents/:agentId/mcp-servers (list)Response now includes authType and connectionStatus.
GET /agents/:agentId/mcp-servers/:serverId (detail)Response includes oauthConfig with clientSecret redacted as ••••••••.

Data Model Impact

Extended ai.agent_mcp_servers Columns

ColumnTypeDefaultNotes
auth_typetext NOT NULL'none'Discriminator
oauth_config_encryptedtext nullablenullAES-256-GCM encrypted JSON
connection_statustext NOT NULL'disconnected'Frontend-visible state
oauth_statetext nullablenullActive PKCE state token
oauth_state_expires_attimestamptz nullablenull15-min TTL
code_verifier_encryptedtext nullablenullPKCE verifier, encrypted
dcr_client_idtext nullablenullFrom Dynamic Client Registration
dcr_client_secret_encryptedtext nullablenullDCR secret, encrypted

New Table: ai.agent_mcp_oauth_tokens

ColumnTypeConstraintsNotes
iduuid PKuuidv7Base entity
server_iduuid FKNOT NULL, UNIQUE, CASCADE delete1:1 with server
access_token_encryptedtextNOT NULLAES-256-GCM
refresh_token_encryptedtextnullableNot all grants issue refresh tokens
token_typetextNOT NULL, default 'Bearer'
scopetextnullableSpace-separated granted scopes
expires_attimestamptznullableUnencrypted for query efficiency

Failure Modes

CodeHTTPTrigger
AI.MCP_AUTH_NOT_CONFIGURED422Initiate OAuth on none/static_headers server
AI.MCP_OAUTH_STATE_MISMATCH422Callback state does not match stored value
AI.MCP_OAUTH_STATE_EXPIRED422Callback arrives after 15-minute window
AI.MCP_OAUTH_TOKEN_EXCHANGE_FAILED502Token endpoint returns error
AI.MCP_DCR_FAILED502DCR discovery or registration fails
AI.MCP_OAUTH_CALLBACK_FAILED400Generic callback failure
Token refresh failedN/A (internal)Logged as warning, sets needs_reauth, server skipped

Graceful degradation: if one server fails during tool resolution, other servers continue normally. The chat does not crash.

Security

  1. Encryption at rest: All secrets (oauthConfig.clientSecret, access_token, refresh_token, code_verifier, dcr_client_secret) encrypted with AES-256-GCM via EncryptionService.
  2. PKCE S256: Prevents authorization code interception.
  3. Timing-safe state comparison: crypto.timingSafeEqual() prevents timing attacks on the state parameter.
  4. State TTL: 15-minute expiry on PKCE state prevents stale authorization flows.
  5. SSRF guard: All URLs (server URL + OAuth endpoints) validated against private IPs, loopback, and link-local addresses.
  6. Tokens never in responses: Only connectionStatus is visible. clientSecret redacted to •••••••• in detail DTO.
  7. Transactional token save: Token storage + status update wrapped in DB transaction.
  8. postMessage targetOrigin: Uses APP_FRONTEND_URL, not '*'.
  9. Rate limiting: OAuth initiate endpoint limited to 5 requests / 60 seconds per user.
  10. CASCADE delete: Deleting a server automatically deletes its tokens via FK constraint.

Frontend Status

The backend is fully implemented and tested. The frontend (agent-mcp-workspace.tsx) currently only supports basic CRUD (name, url, transport type, headers). The following frontend work is pending:

  • Auth type selector in create/edit form
  • Conditional OAuth config fields based on selected authType
  • initiateOAuth() service method + endpoint definition
  • Popup window + postMessage listener for callback
  • connectionStatus display (replacing the current isEnabled + lastConnectionTestAt heuristic)
  • needs_reauth indicator with re-authorization button

Source Paths

  • apps/api/src/modules/ai/domain/entities/agent-mcp-server.entity.ts
  • apps/api/src/modules/ai/domain/entities/agent-mcp-oauth-token.entity.ts
  • apps/api/src/modules/ai/domain/repositories/agent-mcp-oauth-token.repository.interface.ts
  • apps/api/src/modules/ai/application/commands/mcp-server/initiate-mcp-oauth.command.ts
  • apps/api/src/modules/ai/application/commands/mcp-server/handle-mcp-oauth-callback.command.ts
  • apps/api/src/modules/ai/application/commands/mcp-server/create-mcp-server.command.ts
  • apps/api/src/modules/ai/application/commands/mcp-server/update-mcp-server.command.ts
  • apps/api/src/modules/ai/application/services/mcp-auth.provider.ts
  • apps/api/src/modules/ai/application/services/mcp-auth-provider.factory.ts
  • apps/api/src/modules/ai/application/services/mcp-dcr.service.ts
  • apps/api/src/modules/ai/application/tools/mcp/mcp-tool.provider.ts
  • apps/api/src/modules/ai/application/tools/mcp/mcp-ssrf-guard.ts
  • apps/api/src/modules/ai/infrastructure/persistence/agent-mcp-oauth-token.persistence.ts
  • apps/api/src/modules/ai/infrastructure/mappers/agent-mcp-oauth-token.mapper.ts
  • apps/api/src/modules/ai/infrastructure/repositories/agent-mcp-oauth-token.repository.impl.ts
  • apps/api/src/modules/ai/presentation/controllers/mcp-server.controller.ts
  • packages/schemas/src/ai/agents/agent-mcp-oauth.schema.ts
  • packages/schemas/src/ai/agents/agent-mcp-server.schema.ts
  • apps/panel/src/features/dashboard/components/ai-agents/mcp/agent-mcp-workspace.tsx

Change Log

  • 2026-04-08: Initial documentation. Backend fully implemented (SDD cycle complete). Frontend pending.