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
| File | Responsibility |
|---|---|
oauth-server/oauth-server.module.ts | Wires the controllers/services below into the app |
oauth-server/oauth-client-registration.controller.ts + oauth-client-registry.service.ts | RFC 7591 Dynamic Client Registration (POST /api/oauth/register) and redirect-URI validation |
oauth-server/oauth-authorize.controller.ts | GET /api/oauth/authorize, GET /api/oauth/consent-details, POST /api/oauth/consent |
oauth-server/oauth-agent-provisioning.service.ts | Bridges an approved consent onto the AgentProfile/AgentApiKey model |
oauth-server/oauth-authorization-code.service.ts | Single-use, 60-second PKCE authorization codes |
oauth-server/oauth-token.controller.ts + oauth-token.service.ts | POST /api/oauth/token — code exchange and refresh rotation |
oauth-server/errors/oauth-grant.error.ts | Spec-shaped {error, error_description} error type for the token endpoint |
oauth-server/dto/*.ts | RegisterClientDto, AuthorizeQueryDto, ConsentApproveDto, TokenRequestDto |
entities/oauth-client.entity.ts, oauth-authorization-code.entity.ts, oauth-refresh-token.entity.ts | Persistence for clients, codes, and refresh tokens |
mcp-server/mcp-oauth-discovery.controller.ts | .well-known discovery metadata |
agents/oauth-access-token.codec.ts | Signs/verifies MCP OAuth access tokens (its own JWT domain) |
agents/guards/agent-api-key.guard.ts | Runtime 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
POST /api/oauth/register— public.OAuthClientRegistryService.validateRedirectUrirequires everyredirect_urisentry to behttps://, orhttp://localhost/http://127.0.0.1for local dev tools, then persists anOAuthClientrow and returns a UUIDclient_id. No secret.GET /api/oauth/authorize— public, spec-mandated entry point.OAuthAuthorizeController.authorizevalidatesclient_idand requires an exact match against one of the client's registeredredirect_uris(assertRedirectUriRegistered— open-redirect prevention, no wildcards), then issues a302to the SPA's/oauth/consentroute with the same query string appended. This handler is deliberately "dumb": it never renders UI or checks the caller's identity itself.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 areturnUrlparam so a sign-in detour doesn't drop the pending authorization request. Once authenticated, it calls the two endpoints below.GET /api/oauth/consent-details—JwtAuthGuard+@RequireFeature('agent'). Re-validatesclient_id/redirect_uri, then returns client display metadata plusAgentsService.listPermissionGroupsForUser(userId)filtered tocanAssign: true— the dropdown a user sees can never include a permission group their own role isn't allowed to grant (full_mcp_adminrequiresisAdminOrAbove, checked viacanAssignAgentPermissionGroup).- User picks a permission group and clicks Approve, which calls
POST /api/oauth/consent(alsoJwtAuthGuard-protected). On approve:canAssignAgentPermissionGroupis re-checked server-side — the UI filter in step 4 is a convenience, not the security boundary.OAuthAgentProvisioningService.provisionForConsentfinds-or-creates theAgentProfilefor(userId, client.id)— see "Agent Model Mapping" below.OAuthAuthorizationCodeService.createstores a 60-second, single-use authorization code, hashed at rest (sha256codeHash, never the raw code), tied to the client, user, agent, exactredirect_uri, and PKCEcode_challenge.- The response is
{ redirectUrl }with the code (orerror=access_denied) appended to the client's ownredirect_uri; the frontend doeswindow.location.href = redirectUrlto hand control back to the client.
POST /api/oauth/token,grant_type=authorization_code— public.OAuthAuthorizationCodeService.consumere-validates: client match, single-use (usedAtis null), 60-second expiry, exactredirect_urimatch, 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.mintTokenPairthen signs an access token and issues a refresh token.POST /api/oauth/token,grant_type=refresh_token— rotates: the presented token is hashed and looked up bytokenHash; if valid and unrevoked, it's immediately markedrevoked: 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 receivesinvalid_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(nullableuuidcolumn) is set when an agent was created via OAuth consent rather than manually in Agent Settings;nullfor manually created agents. It's exposed onGET /api/agentsso the frontend can render the "Connected via OAuth" badge.OAuthAgentProvisioningService.provisionForConsentlooks up an existing agent by(userId, oauthClientId)before creating one. Re-approving the same client after a revoke reactivates that agent (flips it back toACTIVE, updates its permission group if it changed) instead of creating a duplicate. New-agent name collisions against theAgentProfileunique(userId, name)constraint are resolved by appending(2),(3), … up to 5 attempts.- Every OAuth-backed agent has exactly one active
AgentApiKeyrow withsource: 'oauth'(vs.'static'for a user-generatedag_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.buildAgentContextdoesn't care whichsourceproduced theAgentApiKeyit loaded. - Disabling an agent (
AgentsService.disableAgent,DELETE /api/agents/:id) setsstatus = DISABLED, deactivates all itsAgentApiKeyrows, and explicitly revokes every non-revokedOAuthRefreshTokenrow for that agent. This is belt-and-suspenders with the reuse-detection check inOAuthTokenService— 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:
OAuthAccessTokenCodecuses its ownJwtModuleregistration (seeAgentsModule) configured withOAUTH_JWT_SECRET, and signs claims{ agentId, apiKeyId, userId, clientId, scope, purpose: 'mcp-oauth' }.verifyAccessTokenadditionally rejects any token whosepurposeclaim 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) andOAUTH_REFRESH_TOKEN_TTL=7776000(90 days), both in seconds and both configurable per deployment.OAUTH_JWT_SECRETmust be generated the same way asJWT_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:
x-agent-key/x-agent-tokenheaders → static keyAuthorization: Agent ag_sk_...→ static keyAuthorization: Bearer ag_sk_...→ static key (bearer-prefixed, still a static secret — some MCP client libraries only support theBearerscheme)Authorization: Bearer <anything else>→ treated as an OAuth access token and verified viaOAuthAccessTokenCodec.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(+/*pathcatch-all) →{ resource, authorization_servers, bearer_methods_supported, resource_documentation }GET /.well-known/oauth-authorization-server(+/*pathcatch-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.tsoauth-server/oauth-authorization-code.service.spec.tsoauth-server/oauth-agent-provisioning.service.spec.tsoauth-server/oauth-token.service.spec.tsagents/guards/agent-api-key.guard.spec.ts(covers both static-key and OAuth-bearer extraction, plus theWWW-Authenticateheader 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.