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):
X-API-Key: <key> — a Marmot-issued API key
Authorization: Bearer <jwt> where the JWT is Marmot-issued (HS256, validated against the internal signing key in
internal/core/auth/service.go)
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:
- Read bearer token from header (or
subject_token from body).
- Resolve provider — explicit
provider param, or by matching the JWT's iss claim against enabled providers in cfg.Auth.{Auth0,Keycloak,Okta,GenericOIDC,Google}.
- Validate the token using the provider's existing
*oidc.IDTokenVerifier (already constructed at startup — see NewAuth0Provider in
internal/core/auth/auth0.go).
- Extract
sub, find-or-create the Marmot user via userService.GetUserByProviderID(ctx, "<provider>", sub) — exactly the call already used in Auth0Provider.HandleCallback.
- Run
extractGroups for team-sync (existing helper in internal/core/auth/provider.go).
- 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
- 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.
- Provider resolution: infer from
iss claim, require explicit provider param, or both?
- 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?
- 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.
- Refresh / re-exchange semantics: any rate limiting needed, or rely on standard OIDC token TTL on the upstream side?
- 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
Problem
Marmot currently accepts three credential types on its API (and therefore on the MCP endpoint at
/api/v1/mcp, which uses the sameWithAuthmiddleware ininternal/api/v1/common/middleware.go):X-API-Key: <key>— a Marmot-issued API keyAuthorization: Bearer <jwt>where the JWT is Marmot-issued (HS256, validated against the internal signing key ininternal/core/auth/service.go)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:
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
WithAuthmiddleware unchanged. This follows RFC 8693 (OAuth 2.0 Token Exchange).Handler logic:
subject_tokenfrom body).providerparam, or by matching the JWT'sissclaim against enabled providers incfg.Auth.{Auth0,Keycloak,Okta,GenericOIDC,Google}.*oidc.IDTokenVerifier(already constructed at startup — seeNewAuth0Providerininternal/core/auth/auth0.go).sub, find-or-create the Marmot user viauserService.GetUserByProviderID(ctx, "<provider>", sub)— exactly the call already used inAuth0Provider.HandleCallback.extractGroupsfor team-sync (existing helper ininternal/core/auth/provider.go).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;
WithAuthkeeps its existing fast HMAC verify on every request.Why this is small
Every step of the handler reuses code that already exists:
*oidc.IDTokenVerifierper provider — already built at startupuserService.GetUserByProviderIDfind-or-create — already used by the OAuth callbackextractGroupsfor team-sync — shared helperauthService.GenerateToken— already issues 24h Marmot JWTsoidc.Providerwith cached JWKS — already cached per providerEstimated 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
WithAuthA more invasive option would be to extend
WithAuthto validate inbound OIDC bearer tokens directly on every request (against each enabled provider's JWKS), bypassing the exchange step.Tradeoffs vs. token-exchange:
WithAuthThe 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
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.issclaim, require explicitproviderparam, or both?audthan the ID-token verifier expects. Should there be a separateapi_audienceconfig per provider e.g.MARMOT_AUTH_AUTH0_API_AUDIENCEfor inbound exchange validation, or reuse the existing verifier?exp)?Use cases unblocked
marmot ingestwith a workload-identity-issued token (GitHub Actions OIDC, GitLab CI ID tokens, etc.) instead of long-lived API keys