fix: verify MCP tokens against the MCP application's issuer

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 06:10:35 -07:00
co-authored by Claude Opus 5
parent 7d4e8daaaf
commit 1fed65a187
5 changed files with 47 additions and 4 deletions
+11 -3
View File
@@ -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<Aut
}
const { payload } = await jwtVerify(token, jwks(), {
issuer: env().OIDC_ISSUER,
issuer: mcpIssuer(),
audience: env().OIDC_AUDIENCE,
});
if (!payload.sub) {
+13
View File
@@ -13,6 +13,19 @@ const envSchema = z.object({
// Authentik OIDC
OIDC_ISSUER: z.string().url(),
// Issuer of MCP *access tokens*, when it differs from OIDC_ISSUER.
//
// The Web UI and the MCP endpoint are two separate applications in the IdP,
// and Authentik's default `per_provider` issuer mode stamps each token with
// its own application slug. So the web app issues
// `.../application/o/<slug>/` while the MCP provider issues
// `.../application/o/<slug>-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),