From 1fed65a187f172a1c41c966bac0207c5fe5f65cd Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 27 Jul 2026 06:10:35 -0700 Subject: [PATCH] fix: verify MCP tokens against the MCP application's issuer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second failure on the same path. With the aud fix in place, tokens now carry `aud: shared-memory` correctly but are still rejected — this time on `iss`. The MCP endpoint is a separate application in the IdP from the Web UI, and Authentik's default per_provider issuer mode stamps each token with its own application slug. MCP tokens therefore carry `.../application/o/shared-memory-mcp/` while OIDC_ISSUER points at `.../application/o/shared-memory/`, so jwtVerify throws "claim invalid: iss". Introduce OIDC_ISSUER_MCP (defaults to OIDC_ISSUER) and use it for both the issuer check and the JWKS URL. The protected-resource metadata now advertises that same issuer — previously it pointed clients at the Web UI's discovery document while the tokens came from the MCP provider. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 5 +++++ README.md | 13 +++++++++++++ .../.well-known/oauth-protected-resource/route.ts | 6 +++++- apps/web/lib/auth/jwt.ts | 14 +++++++++++--- apps/web/lib/env.ts | 13 +++++++++++++ 5 files changed, 47 insertions(+), 4 deletions(-) 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),