feat: JWT auth middleware + /.well-known/oauth-protected-resource (Dex OIDC) #5

Closed
opened 2026-05-11 17:22:56 +00:00 by mathias · 1 comment
Owner

Context

auth.d-ma.be now runs Dex (OIDC/OAuth 2.0 AS). All MCP servers should accept JWTs issued by Dex or a static bearer token (backward compat). The current BearerMiddleware in internal/auth/bearer.go validates the bearer as a Gitea PAT via the Gitea API — that model breaks when clients authenticate via claude.ai's OAuth flow instead of supplying a PAT.

Goal

Replace the PAT-passthrough auth with a JWT-or-static chain, and expose the RFC 9728 protected resource metadata endpoint.


Changes required

1. internal/auth/bearer.go — replace BearerMiddleware

Current behaviour: extracts bearer token, calls GET /api/v1/user on the Gitea host, rejects if non-200.

New behaviour:

if token is valid JWT signed by Dex → allow
else if token == GITEA_MCP_STATIC_TOKEN env var (constant-time compare) → allow
else → 401 Unauthorized

JWT validation uses JWKS auto-refresh (see §3 below). Do not call the Gitea API on the hot path.

The validated token (JWT subject or static sentinel) can still be stored in context via auth.StoreTokenInContext for any downstream use.

2. internal/gitea/client.go — fix service PAT usage

doOnce and doRaw currently call auth.TokenFromContext(ctx) first and fall back to c.token. Since the caller's JWT is no longer a Gitea PAT, remove the context-token lookup. Always use c.token (the service PAT configured via GITEA_MCP_DEFAULT_TOKEN).

// Before
token := auth.TokenFromContext(ctx)
if token == "" {
    token = c.token
}

// After
token := c.token

Verify no other callers of auth.TokenFromContext rely on per-request PAT injection; if they exist, update them too.

3. JWT library + env vars

Add dependency: github.com/lestrrat-go/jwx/v2

New env vars:

  • DEX_ISSUER_URL — e.g. https://auth.d-ma.be (required when JWT auth is enabled)
  • GITEA_MCP_STATIC_TOKEN — optional static bearer for backward compat / service-to-service

JWKS endpoint is {DEX_ISSUER_URL}/.well-known/openid-configurationjwks_uri. Use jwk.NewCache with a reasonable refresh interval (1 h).

Validation requirements:

  • iss == DEX_ISSUER_URL
  • aud contains gitea-mcp (or the configured client ID)
  • exp not expired
  • Signature valid against JWKS

4. /.well-known/oauth-protected-resource endpoint (RFC 9728)

Register a new HTTP handler (outside the MCP router, on the root mux):

GET /.well-known/oauth-protected-resource
Content-Type: application/json

{
  "resource": "https://git-mcp.d-ma.be",
  "authorization_servers": ["https://auth.d-ma.be"]
}

Values should come from env vars (MCP_RESOURCE_URL, DEX_ISSUER_URL) so they're configurable.


k3s manifest updates (in mathias/infra)

After the code changes are merged and image rebuilt:

  • Add DEX_ISSUER_URL: https://auth.d-ma.be to k3s/apps/gitea-mcp/deployment.yaml env
  • Add GITEA_MCP_STATIC_TOKEN as a reference to the existing bearer secret (or a new one)

Testing

  • Unit test: mock JWKS endpoint, assert valid JWT passes and tampered/expired JWT returns 401
  • Unit test: static token passes, wrong token returns 401
  • Integration: hit /.well-known/oauth-protected-resource, assert JSON shape

  • Dex deployed at auth.d-ma.be via k3s/apps/auth/ in mathias/infra
  • Same pattern to be applied to mathias/hyperguild (supervisor + brain MCP servers)
## Context `auth.d-ma.be` now runs Dex (OIDC/OAuth 2.0 AS). All MCP servers should accept JWTs issued by Dex **or** a static bearer token (backward compat). The current `BearerMiddleware` in `internal/auth/bearer.go` validates the bearer as a Gitea PAT via the Gitea API — that model breaks when clients authenticate via claude.ai's OAuth flow instead of supplying a PAT. ## Goal Replace the PAT-passthrough auth with a JWT-or-static chain, and expose the RFC 9728 protected resource metadata endpoint. --- ## Changes required ### 1. `internal/auth/bearer.go` — replace `BearerMiddleware` Current behaviour: extracts bearer token, calls `GET /api/v1/user` on the Gitea host, rejects if non-200. New behaviour: ``` if token is valid JWT signed by Dex → allow else if token == GITEA_MCP_STATIC_TOKEN env var (constant-time compare) → allow else → 401 Unauthorized ``` JWT validation uses JWKS auto-refresh (see §3 below). Do **not** call the Gitea API on the hot path. The validated token (JWT subject or static sentinel) can still be stored in context via `auth.StoreTokenInContext` for any downstream use. ### 2. `internal/gitea/client.go` — fix service PAT usage `doOnce` and `doRaw` currently call `auth.TokenFromContext(ctx)` first and fall back to `c.token`. Since the caller's JWT is no longer a Gitea PAT, remove the context-token lookup. Always use `c.token` (the service PAT configured via `GITEA_MCP_DEFAULT_TOKEN`). ```go // Before token := auth.TokenFromContext(ctx) if token == "" { token = c.token } // After token := c.token ``` Verify no other callers of `auth.TokenFromContext` rely on per-request PAT injection; if they exist, update them too. ### 3. JWT library + env vars Add dependency: `github.com/lestrrat-go/jwx/v2` New env vars: - `DEX_ISSUER_URL` — e.g. `https://auth.d-ma.be` (required when JWT auth is enabled) - `GITEA_MCP_STATIC_TOKEN` — optional static bearer for backward compat / service-to-service JWKS endpoint is `{DEX_ISSUER_URL}/.well-known/openid-configuration` → `jwks_uri`. Use `jwk.NewCache` with a reasonable refresh interval (1 h). Validation requirements: - `iss` == `DEX_ISSUER_URL` - `aud` contains `gitea-mcp` (or the configured client ID) - `exp` not expired - Signature valid against JWKS ### 4. `/.well-known/oauth-protected-resource` endpoint (RFC 9728) Register a new HTTP handler (outside the MCP router, on the root mux): ``` GET /.well-known/oauth-protected-resource Content-Type: application/json { "resource": "https://git-mcp.d-ma.be", "authorization_servers": ["https://auth.d-ma.be"] } ``` Values should come from env vars (`MCP_RESOURCE_URL`, `DEX_ISSUER_URL`) so they're configurable. --- ## k3s manifest updates (in `mathias/infra`) After the code changes are merged and image rebuilt: - Add `DEX_ISSUER_URL: https://auth.d-ma.be` to `k3s/apps/gitea-mcp/deployment.yaml` env - Add `GITEA_MCP_STATIC_TOKEN` as a reference to the existing bearer secret (or a new one) --- ## Testing - Unit test: mock JWKS endpoint, assert valid JWT passes and tampered/expired JWT returns 401 - Unit test: static token passes, wrong token returns 401 - Integration: hit `/.well-known/oauth-protected-resource`, assert JSON shape --- ## Related - Dex deployed at `auth.d-ma.be` via `k3s/apps/auth/` in `mathias/infra` - Same pattern to be applied to `mathias/hyperguild` (supervisor + brain MCP servers)
Author
Owner

Shipped in v0.1.x. Closing during cleanup pass.

Shipped in v0.1.x. Closing during cleanup pass.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/gitea-mcp#5