diff --git a/.env.example b/.env.example index 97c612b..1560832 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,11 @@ ACME_EMAIL=you@example.com # resource server). See README.md for exact provider settings. # ----------------------------------------------------------------------------- OIDC_ISSUER=https://auth.example.com/application/o/shared-memory/ +# Issuer of MCP access tokens, when the MCP endpoint is a separate application +# in your IdP (it usually is). Authentik stamps each token with its own app +# slug, so verifying MCP tokens against OIDC_ISSUER fails with +# "claim invalid: iss". Defaults to OIDC_ISSUER. +OIDC_ISSUER_MCP=https://auth.example.com/application/o/shared-memory-mcp/ OIDC_CLIENT_ID_WEB=replace-me OIDC_CLIENT_SECRET_WEB=replace-me OIDC_CLIENT_ID_MCP=replace-me diff --git a/README.md b/README.md index 668389f..9c6f84a 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ Copy `.env.example` and fill in the values below. | `OIDC_CLIENT_SECRET_WEB` | both | Client secret of the Web-UI client. | | `OIDC_CLIENT_ID_MCP` | both | Client ID of the MCP resource-server client in your IdP. | | `OIDC_AUDIENCE` | both | Audience string the MCP access token must carry in its `aud` claim. Recommended: `shared-memory`. | +| `OIDC_ISSUER_MCP` | optional | Issuer of MCP access tokens when the MCP endpoint is a separate IdP application (Authentik stamps each app's tokens with its own slug). Defaults to `OIDC_ISSUER`. | | `OIDC_AUDIENCE_SCOPE` | optional | Name of the IdP scope whose mapping emits that `aud` claim. Advertised in `scopes_supported` so clients request it. Defaults to `aud-`. | | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | both | Local Postgres credentials. | | `NEXTAUTH_SECRET` | both | Session-cookie signing key. Generate with `openssl rand -base64 32`. | @@ -322,6 +323,18 @@ reliable pattern: > | jq .scopes_supported # must include aud- > ``` > +> **Second trap, same failure surface:** the MCP endpoint is a *separate +> application* from the Web UI, and Authentik's default `per_provider` issuer +> mode stamps each token with its own application slug. So MCP tokens carry +> `iss: .../application/o/shared-memory-mcp/` while `OIDC_ISSUER` points at +> `.../application/o/shared-memory/`, and verification fails with +> `claim invalid: iss` even once `aud` is correct. Set `OIDC_ISSUER_MCP` to the +> MCP application's issuer. Confirm which one your tokens actually carry: +> +> ```bash +> curl -s https://auth.example.com/application/o/shared-memory-mcp/.well-known/openid-configuration | jq .issuer +> ``` +> > Note also that Claude Code sends an RFC 8707 `resource` parameter on the > authorize request; Authentik 2026.5 ignores it, so it cannot be relied on > for audience binding. The scope mapping is what sets `aud`. diff --git a/apps/web/app/.well-known/oauth-protected-resource/route.ts b/apps/web/app/.well-known/oauth-protected-resource/route.ts index d95f9a4..46a8515 100644 --- a/apps/web/app/.well-known/oauth-protected-resource/route.ts +++ b/apps/web/app/.well-known/oauth-protected-resource/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { env } from "@/lib/env"; +import { mcpIssuer } from "@/lib/auth/jwt"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -22,7 +23,10 @@ export function GET() { return NextResponse.json({ resource, - authorization_servers: [env().OIDC_ISSUER], + // The MCP application's issuer, which is not necessarily the Web UI's — + // see mcpIssuer(). Advertising the wrong one sends clients to a discovery + // document whose tokens this endpoint will then reject on `iss`. + authorization_servers: [mcpIssuer()], scopes_supported: ["openid", "profile", "email", audienceScope], bearer_methods_supported: ["header"], resource_documentation: `${resource}/`, diff --git a/apps/web/lib/auth/jwt.ts b/apps/web/lib/auth/jwt.ts index c84c188..b7bde7e 100644 --- a/apps/web/lib/auth/jwt.ts +++ b/apps/web/lib/auth/jwt.ts @@ -23,13 +23,21 @@ type GlobalWithJwks = typeof globalThis & { }; const g = globalThis as GlobalWithJwks; +/** + * Issuer of MCP access tokens. The MCP endpoint is a separate application in + * the IdP from the Web UI, and Authentik stamps each token with its own + * application slug, so this is NOT interchangeable with OIDC_ISSUER. + */ +export function mcpIssuer(): string { + return (env().OIDC_ISSUER_MCP ?? env().OIDC_ISSUER).replace(/\/$/, ""); +} + function jwks() { if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks; // Authentik discovery is at `${issuer}/.well-known/openid-configuration`; // the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`. // Authentik canonically serves `${issuer}/jwks/`. - const issuer = env().OIDC_ISSUER.replace(/\/$/, ""); - const url = new URL(`${issuer}/jwks/`); + const url = new URL(`${mcpIssuer()}/jwks/`); g.__sharedMemoryJwks = createRemoteJWKSet(url, { cacheMaxAge: 10 * 60 * 1000, // 10 min cooldownDuration: 30 * 1000, @@ -118,7 +126,7 @@ export async function authenticateBearer(authHeader: string | null): Promise/` while the MCP provider issues + // `.../application/o/-mcp/`, and verifying MCP tokens against + // OIDC_ISSUER fails with "claim invalid: iss". + // + // Set this to the MCP application's issuer. Defaults to OIDC_ISSUER for + // single-application setups. + OIDC_ISSUER_MCP: z.string().url().optional(), OIDC_CLIENT_ID_WEB: z.string().min(1), OIDC_CLIENT_SECRET_WEB: z.string().min(1), OIDC_CLIENT_ID_MCP: z.string().min(1),