fix: accept the issuer with or without a trailing slash #17

Merged
jknapp merged 1 commits from fix/issuer-trailing-slash into main 2026-07-27 13:42:32 +00:00
2 changed files with 22 additions and 4 deletions
@@ -26,7 +26,7 @@ export function GET() {
// The MCP application's issuer, which is not necessarily the Web UI's — // The MCP application's issuer, which is not necessarily the Web UI's —
// see mcpIssuer(). Advertising the wrong one sends clients to a discovery // see mcpIssuer(). Advertising the wrong one sends clients to a discovery
// document whose tokens this endpoint will then reject on `iss`. // document whose tokens this endpoint will then reject on `iss`.
authorization_servers: [mcpIssuer()], authorization_servers: [mcpIssuer()], // as configured, slash and all
scopes_supported: ["openid", "profile", "email", audienceScope], scopes_supported: ["openid", "profile", "email", audienceScope],
bearer_methods_supported: ["header"], bearer_methods_supported: ["header"],
resource_documentation: `${resource}/`, resource_documentation: `${resource}/`,
+21 -3
View File
@@ -29,7 +29,25 @@ const g = globalThis as GlobalWithJwks;
* application slug, so this is NOT interchangeable with OIDC_ISSUER. * application slug, so this is NOT interchangeable with OIDC_ISSUER.
*/ */
export function mcpIssuer(): string { export function mcpIssuer(): string {
return (env().OIDC_ISSUER_MCP ?? env().OIDC_ISSUER).replace(/\/$/, ""); return env().OIDC_ISSUER_MCP ?? env().OIDC_ISSUER;
}
/**
* Issuer values accepted for the `iss` claim.
*
* jose compares `iss` by exact string, and IdPs are inconsistent about the
* trailing slash: Authentik emits `.../application/o/<slug>/` while the same
* value is routinely configured without it. Normalizing to one form and
* comparing against that fails whenever the two disagree — which is exactly
* how this broke: the URL-safe (stripped) form was reused for the claim check
* against a token whose `iss` ended in a slash.
*
* Accept both spellings rather than making correctness depend on how someone
* typed an env var.
*/
function acceptedIssuers(): [string, string] {
const bare = mcpIssuer().replace(/\/$/, "");
return [bare, `${bare}/`];
} }
function jwks() { function jwks() {
@@ -37,7 +55,7 @@ function jwks() {
// Authentik discovery is at `${issuer}/.well-known/openid-configuration`; // Authentik discovery is at `${issuer}/.well-known/openid-configuration`;
// the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`. // the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`.
// Authentik canonically serves `${issuer}/jwks/`. // Authentik canonically serves `${issuer}/jwks/`.
const url = new URL(`${mcpIssuer()}/jwks/`); const url = new URL(`${mcpIssuer().replace(/\/$/, "")}/jwks/`);
g.__sharedMemoryJwks = createRemoteJWKSet(url, { g.__sharedMemoryJwks = createRemoteJWKSet(url, {
cacheMaxAge: 10 * 60 * 1000, // 10 min cacheMaxAge: 10 * 60 * 1000, // 10 min
cooldownDuration: 30 * 1000, cooldownDuration: 30 * 1000,
@@ -126,7 +144,7 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
} }
const { payload } = await jwtVerify(token, jwks(), { const { payload } = await jwtVerify(token, jwks(), {
issuer: mcpIssuer(), issuer: acceptedIssuers(),
audience: env().OIDC_AUDIENCE, audience: env().OIDC_AUDIENCE,
}); });
if (!payload.sub) { if (!payload.sub) {