Skip to content

Accept external OIDC tokens (Auth0/Keycloak/Okta/...) in the API & MCP WithAuth middleware #84

Description

@nparley

Problem

Marmot currently accepts three credential types on its API (and therefore on the MCP endpoint at /api/v1/mcp, which uses the same WithAuth middleware in internal/api/v1/common/middleware.go):

  1. X-API-Key: <key> — a Marmot-issued API key
  2. Authorization: Bearer <jwt> where the JWT is Marmot-issued (HS256, validated against the internal signing key in
    internal/core/auth/service.go)
  3. Authorization: Bearer <jwt> where the JWT is a Kubernetes ServiceAccount token (only when the operator pattern is configured)

What's missing: a way for non-browser clients to obtain a Marmot session on behalf of a user already authenticated by an upstream IdP that Marmot is configured to trust for browser SSO (Auth0, Keycloak, Okta, GenericOIDC, Google).

Why this matters now (MCP angle)

External MCP clients — AI agents, Marimo notebooks, CI tools — typically already hold a per-user OIDC access token. Today they have to:

  • Fall back to a static API key, which means manually-minted, long-lived secrets per client/user, OR
  • Use a single shared API key across users, which silently collapses identity at the catalog boundary (audit logs all show one user, RBAC can't distinguish callers).

For an AI-agent / MCP scenario in particular this is painful: every other system in our stack (e.g. Trino, Superset, Grafana, S3 via STS) accepts the user's OIDC token directly, and only Marmot interrupts the chain.

Proposal: OAuth 2.0 Token Exchange endpoint

Add a new endpoint that trades an upstream OIDC bearer token for a Marmot-issued JWT, then let clients use that Marmot JWT against the existing WithAuth middleware unchanged. This follows RFC 8693 (OAuth 2.0 Token Exchange).

POST /auth/token-exchange
Headers:
  Authorization: Bearer <upstream_oidc_jwt>     # or RFC 8693 subject_token in body
Body (optional):
  {
    "provider": "auth0",                         # optional; inferred from `iss` if absent
    "subject_token": "<jwt>",                    # RFC 8693 alternative to header
    "subject_token_type": "urn:ietf:params:oauth:token-type:access_token"
  }
Response 200:
  {
    "access_token": "<marmot_hs256_jwt>",
    "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
    "token_type": "Bearer",
    "expires_in": 86400
  }

Handler logic:

  1. Read bearer token from header (or subject_token from body).
  2. Resolve provider — explicit provider param, or by matching the JWT's iss claim against enabled providers in cfg.Auth.{Auth0,Keycloak,Okta,GenericOIDC,Google}.
  3. Validate the token using the provider's existing *oidc.IDTokenVerifier (already constructed at startup — see NewAuth0Provider in
    internal/core/auth/auth0.go
    ).
  4. Extract sub, find-or-create the Marmot user via userService.GetUserByProviderID(ctx, "<provider>", sub) — exactly the call already used in Auth0Provider.HandleCallback.
  5. Run extractGroups for team-sync (existing helper in internal/core/auth/provider.go).
  6. Mint a Marmot JWT via the existing authService.GenerateToken(ctx, user, ...) (internal/core/auth/service.go) and return.

Clients then use the returned Marmot JWT on subsequent requests, including MCP. They re-exchange on 401 or before expiry. No middleware changes; WithAuth keeps its existing fast HMAC verify on every request.

Why this is small

Every step of the handler reuses code that already exists:

  • *oidc.IDTokenVerifier per provider — already built at startup
  • userService.GetUserByProviderID find-or-create — already used by the OAuth callback
  • extractGroups for team-sync — shared helper
  • authService.GenerateToken — already issues 24h Marmot JWTs
  • oidc.Provider with cached JWKS — already cached per provider

Estimated patch: a single new handler (~50 LOC of glue) plus one route registration in internal/api/auth/handler.go. No middleware modifications, no new validators, no changes to the hot path.

Alternative considered: per-request OIDC validation in WithAuth

A more invasive option would be to extend WithAuth to validate inbound OIDC bearer tokens directly on every request (against each enabled provider's JWKS), bypassing the exchange step.

Tradeoffs vs. token-exchange:

Token-exchange endpoint Per-request OIDC in WithAuth
Patch size small, isolated touches every authenticated route
Per-request cost existing HMAC verify JWKS verify (cached, but slower than HMAC)
Identity freshness "as of exchange" — stale until JWT expires live — every request re-evaluates
Client complexity exchange once, refresh on 401 stateless

The exchange path seems clearly better as a first step: smaller diff, no hot-path changes, RFC-blessed pattern, well-understood client mechanics. Per-request validation could be a follow-up for use cases that need live revocation/group-change semantics.

Open questions for maintainers

  1. Endpoint shape: strict RFC 8693 (form-encoded body, grant_type=urn:ietf:params:oauth:grant-type:token-exchange, etc.) or pragmatic JSON shape as sketched above? RFC 8693 conformance is friendlier to off-the-shelf token-exchange clients but adds a bit of boilerplate.
  2. Provider resolution: infer from iss claim, require explicit provider param, or both?
  3. Audience checks: Auth0 access tokens often have a different aud than the ID-token verifier expects. Should there be a separate api_audience config per provider e.g. MARMOT_AUTH_AUTH0_API_AUDIENCE for inbound exchange validation, or reuse the existing verifier?
  4. Auto-create: should exchange auto-provision Marmot users on first hit (current OAuth-callback behaviour), or fail closed unless the user has been pre-created? The latter is safer for production tenancy but adds friction.
  5. Refresh / re-exchange semantics: any rate limiting needed, or rely on standard OIDC token TTL on the upstream side?
  6. Returned token TTL: re-use the global 24h Marmot JWT TTL, or shorter-lived for exchanged tokens (e.g. capped to the upstream token's exp)?

Use cases unblocked

  • AI agents (MCP) operating on behalf of an authenticated user without per-user API key provisioning
  • CI/CD jobs using marmot ingest with a workload-identity-issued token (GitHub Actions OIDC, GitLab CI ID tokens, etc.) instead of long-lived API keys
  • Service-to-service calls from other apps in an OIDC-federated estate using their own access tokens

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions