Skip to main content
Was this helpful?

OAuth Authorization Server (MCP)

PrimeCal runs a self-contained OAuth 2.1 authorization server (backend-nestjs/src/oauth-server/) purpose-built for the MCP connector use case: public clients only, PKCE mandatory, RFC 7591 dynamic registration. It exists so any spec-compliant MCP host can add PrimeCal without a developer manually minting an ag_sk_... key.

See Agent API for the full request/response reference and curl walkthrough, and Connecting via OAuth for the end-user-facing flow. This page covers the server-side design for developers maintaining the code.

Module Layout

FileResponsibility
oauth-server/oauth-server.module.tsWires the controllers/services below into the app
oauth-server/oauth-client-registration.controller.ts + oauth-client-registry.service.tsRFC 7591 Dynamic Client Registration (POST /api/oauth/register) and redirect-URI validation
oauth-server/oauth-authorize.controller.tsGET /api/oauth/authorize, GET /api/oauth/consent-details, POST /api/oauth/consent
oauth-server/oauth-agent-provisioning.service.tsBridges an approved consent onto the AgentProfile/AgentApiKey model
oauth-server/oauth-authorization-code.service.tsSingle-use, 60-second PKCE authorization codes
oauth-server/oauth-token.controller.ts + oauth-token.service.tsPOST /api/oauth/token — code exchange and refresh rotation
oauth-server/errors/oauth-grant.error.tsSpec-shaped {error, error_description} error type for the token endpoint
oauth-server/dto/*.tsRegisterClientDto, AuthorizeQueryDto, ConsentApproveDto, TokenRequestDto
entities/oauth-client.entity.ts, oauth-authorization-code.entity.ts, oauth-refresh-token.entity.tsPersistence for clients, codes, and refresh tokens
mcp-server/mcp-oauth-discovery.controller.ts.well-known discovery metadata
agents/oauth-access-token.codec.tsSigns/verifies MCP OAuth access tokens (its own JWT domain)
agents/guards/agent-api-key.guard.tsRuntime credential extraction for /api/mcp — static key or OAuth access token

Why Public Clients Only, PKCE Mandatory

No client_secret is ever issued — OAuthClientRegistrationController always returns token_endpoint_auth_method: "none", and OAuthClient has no secret column. MCP hosts are typically desktop apps, browser extensions, or other clients that cannot keep a confidential secret, so PKCE (S256 only — plain is rejected at the DTO layer via @IsIn(['S256']) on both AuthorizeQueryDto.code_challenge_method and the discovery metadata's code_challenge_methods_supported) is the OAuth 2.1-recommended authentication substitute at the token endpoint.

Grant Flow, Step by Step

  1. POST /api/oauth/register — public. OAuthClientRegistryService.validateRedirectUri requires every redirect_uris entry to be https://, or http://localhost / http://127.0.0.1 for local dev tools, then persists an OAuthClient row and returns a UUID client_id. No secret.
  2. GET /api/oauth/authorize — public, spec-mandated entry point. OAuthAuthorizeController.authorize validates client_id and requires an exact match against one of the client's registered redirect_uris (assertRedirectUriRegistered — open-redirect prevention, no wildcards), then issues a 302 to the SPA's /oauth/consent route with the same query string appended. This handler is deliberately "dumb": it never renders UI or checks the caller's identity itself.
  3. GET /oauth/consent (frontend, OAuthConsentPage.tsx) — authenticates using the app's normal session, showing an inline <Login /> if needed and round-tripping the full OAuth query string through a returnUrl param so a sign-in detour doesn't drop the pending authorization request. Once authenticated, it calls the two endpoints below.
  4. GET /api/oauth/consent-detailsJwtAuthGuard + @RequireFeature('agent'). Re-validates client_id/redirect_uri, then returns client display metadata plus AgentsService.listPermissionGroupsForUser(userId) filtered to canAssign: true — the dropdown a user sees can never include a permission group their own role isn't allowed to grant (full_mcp_admin requires isAdminOrAbove, checked via canAssignAgentPermissionGroup).
  5. User picks a permission group and clicks Approve, which calls POST /api/oauth/consent (also JwtAuthGuard-protected). On approve:
    • canAssignAgentPermissionGroup is re-checked server-side — the UI filter in step 4 is a convenience, not the security boundary.
    • OAuthAgentProvisioningService.provisionForConsent finds-or-creates the AgentProfile for (userId, client.id) — see "Agent Model Mapping" below.
    • OAuthAuthorizationCodeService.create stores a 60-second, single-use authorization code, hashed at rest (sha256 codeHash, never the raw code), tied to the client, user, agent, exact redirect_uri, and PKCE code_challenge.
    • The response is { redirectUrl } with the code (or error=access_denied) appended to the client's own redirect_uri; the frontend does window.location.href = redirectUrl to hand control back to the client.
  6. POST /api/oauth/token, grant_type=authorization_code — public. OAuthAuthorizationCodeService.consume re-validates: client match, single-use (usedAt is null), 60-second expiry, exact redirect_uri match, and PKCE (base64url(sha256(code_verifier)) === code_challenge) — then marks the code used before returning, so even a retry with the correct verifier fails once a code has been consumed once. OAuthTokenService.mintTokenPair then signs an access token and issues a refresh token.
  7. POST /api/oauth/token, grant_type=refresh_token — rotates: the presented token is hashed and looked up by tokenHash; if valid and unrevoked, it's immediately marked revoked: true, and a fresh pair is minted. If it's already revoked (reuse — likely theft), every refresh token for that (clientId, agentId) pair is revoked and the caller receives invalid_grant, forcing a full re-authorization from step 2.

Errors from steps 6-7 are thrown as OAuthGrantError, not a NestJS HttpException, specifically so OAuthTokenController can catch it and write the RFC 6749 §5.2-shaped {error, error_description} body instead of PrimeCal's normal {success:false, error:{code,message}} envelope — the global AllExceptionsFilter never sees these.

Agent Model Mapping

The core design decision: an OAuth grant is not a parallel authorization model bolted onto MCP — it's an ordinary AgentProfile, provisioned automatically.

  • AgentProfile.oauthClientId (nullable uuid column) is set when an agent was created via OAuth consent rather than manually in Agent Settings; null for manually created agents. It's exposed on GET /api/agents so the frontend can render the "Connected via OAuth" badge.
  • OAuthAgentProvisioningService.provisionForConsent looks up an existing agent by (userId, oauthClientId) before creating one. Re-approving the same client after a revoke reactivates that agent (flips it back to ACTIVE, updates its permission group if it changed) instead of creating a duplicate. New-agent name collisions against the AgentProfile unique (userId, name) constraint are resolved by appending (2), (3), … up to 5 attempts.
  • Every OAuth-backed agent has exactly one active AgentApiKey row with source: 'oauth' (vs. 'static' for a user-generated ag_sk_... key). Its plaintext secret is generated and immediately discarded — OAuth clients authenticate with the signed access token, never this row's secret. The row exists only so revocation, lastUsedAt, and audit trails work identically for OAuth and static-key agents; AgentAuthorizationService.buildAgentContext doesn't care which source produced the AgentApiKey it loaded.
  • Disabling an agent (AgentsService.disableAgent, DELETE /api/agents/:id) sets status = DISABLED, deactivates all its AgentApiKey rows, and explicitly revokes every non-revoked OAuthRefreshToken row for that agent. This is belt-and-suspenders with the reuse-detection check in OAuthTokenService — it makes the revoked state accurate immediately, rather than only "effectively blocked" the next time a refresh happens to be attempted.
  • Because provisioning reuses AgentsService.createAgent / replacePermissions, an OAuth-provisioned agent is subject to the exact same permission-group catalogue, scoping rules, and admin gating as a manually created one (agents/agent-permission-groups.ts) — there is no separate permission system for OAuth-sourced agents.

Two JWT Domains

PrimeCal already issues a login-session JWT (auth/token.service.ts, configured by JWT_SECRET / JWT_ISSUER / JWT_AUDIENCE). MCP OAuth access tokens are a second, independent signed-JWT domain:

  • OAuthAccessTokenCodec uses its own JwtModule registration (see AgentsModule) configured with OAUTH_JWT_SECRET, and signs claims { agentId, apiKeyId, userId, clientId, scope, purpose: 'mcp-oauth' }.
  • verifyAccessToken additionally rejects any token whose purpose claim isn't 'mcp-oauth'. Since the signing secret is already entirely different from the login JWT's secret, this is defense-in-depth against future key-configuration mistakes, not the primary security boundary.
  • This separation means a leaked MCP access token — which is scoped to a specific permission group and held by a third-party AI host — can never be replayed against PrimeCal's normal /api/* endpoints as a login session, and a leaked login JWT can never be used to call /api/mcp.
  • Default lifetimes (backend-nestjs/.env.example): OAUTH_ACCESS_TOKEN_TTL=3600 (1 hour) and OAUTH_REFRESH_TOKEN_TTL=7776000 (90 days), both in seconds and both configurable per deployment. OAUTH_JWT_SECRET must be generated the same way as JWT_SECRET (openssl rand -base64 32) and must never be set to the same value — that would collapse the two domains back into one.

Runtime Token Acceptance (AgentApiKeyGuard)

AgentApiKeyGuard.extractToken checks, in order:

  1. x-agent-key / x-agent-token headers → static key
  2. Authorization: Agent ag_sk_... → static key
  3. Authorization: Bearer ag_sk_... → static key (bearer-prefixed, still a static secret — some MCP client libraries only support the Bearer scheme)
  4. Authorization: Bearer <anything else> → treated as an OAuth access token and verified via OAuthAccessTokenCodec.verifyAccessToken

Whichever path succeeds, AgentAuthorizationService.validateApiKey / validateOAuthAccessToken both resolve to the same AgentContext shape (agent, apiKey, user, permissions), so every downstream MCP tool handler is agnostic to which credential type authenticated the request.

On any UnauthorizedException from either path, the guard sets:

WWW-Authenticate: Bearer realm="PrimeCal MCP API", resource_metadata="<origin>/.well-known/oauth-protected-resource"

so spec-compliant MCP clients (RFC 9728) can auto-discover the OAuth flow from a bare 401 instead of failing with no next step. <origin> is derived from x-forwarded-proto / x-forwarded-host when present (falling back to the raw request), so this works correctly behind a reverse proxy.

Discovery Metadata

McpOAuthDiscoveryController (mcp-server/mcp-oauth-discovery.controller.ts), public and unauthenticated:

  • GET /.well-known/oauth-protected-resource (+ /*path catch-all) → { resource, authorization_servers, bearer_methods_supported, resource_documentation }
  • GET /.well-known/oauth-authorization-server (+ /*path catch-all) → { issuer, authorization_endpoint, token_endpoint, registration_endpoint, response_types_supported, grant_types_supported, code_challenge_methods_supported, token_endpoint_auth_methods_supported, scopes_supported }

Both derive their origin the same way as the guard above, falling back to api.primecal.eu if no host header is present at all. The /*path catch-all variants exist because some MCP clients probe suffixed discovery paths (e.g. /.well-known/oauth-authorization-server/api/mcp) per RFC 8414 §3.1.

Testing

Unit coverage lives alongside the services:

  • oauth-server/oauth-client-registry.service.spec.ts
  • oauth-server/oauth-authorization-code.service.spec.ts
  • oauth-server/oauth-agent-provisioning.service.spec.ts
  • oauth-server/oauth-token.service.spec.ts
  • agents/guards/agent-api-key.guard.spec.ts (covers both static-key and OAuth-bearer extraction, plus the WWW-Authenticate header on 401)

When extending the flow, prefer adding cases to these specs over new end-to-end scripts — the PKCE/rotation/reuse-detection logic is entirely unit-testable without a running server.