feat: support Microsoft Entra ID as an OIDC provider
Three defects stood between this codebase and a working Entra deployment.
All three fail silently, which is why they are grouped: each one masks the
next, and fixing any two still leaves a broken or dangerous install.
1. JWKS discovery. The key set URL was hardcoded to `${issuer}/jwks/`,
which is Authentik's convention, not a standard. Entra serves keys at
`/{tenant}/discovery/v2.0/keys`, so every Entra-issued MCP token failed
verification on a 404 — authentication was impossible, not merely
misconfigured. We now read `jwks_uri` from the issuer's discovery
document and fall back to the old path, so Authentik is untouched.
Discovery failure arms a 60s retry rather than pinning the wrong URL
for the life of the container.
2. Identity. Entra's `sub` is pairwise — derived from the token
recipient — so the Web UI and MCP app registrations emit different
`sub` values for the same human. Keyed on (iss, sub), that person got
two rows: sign into the Web UI, connect Claude Code, land in an empty
account. Both paths upsert, so nothing errored. Identity now keys on
`oid`, which Microsoft documents as constant across applications in a
tenant, via one resolver both surfaces share. Rows created before the
0005 migration adopt their `oid` on next sign-in.
3. Groups overage. Past 200 groups Entra omits `groups` entirely and
substitutes a `_claim_names` pointer. `normalizeGroupsClaim` read that
as "zero groups" and the sync deleted every membership the user had,
revoking access to every shared project on both surfaces with no error
raised. Both surfaces now refuse such a token instead — the Web UI
fails the sign-in, MCP returns 401 — leaving memberships intact and
naming the operator fix. An absent claim with no overage marker still
clears memberships, which is unchanged and deliberate.
Verified against a real pgvector instance: 66 tests pass, and reverting
either new behaviour fails exactly the tests that cover it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+119
-14
@@ -2,13 +2,14 @@ import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
||||
import type { JWTPayload } from "jose";
|
||||
import { env } from "@/lib/env";
|
||||
import { CLI_TOKEN_KID, tokenKid, verifyCliToken } from "./cli-token";
|
||||
import { detectGroupsOverage } from "./sync-groups";
|
||||
|
||||
/**
|
||||
* Authenticates a bearer token presented to the MCP endpoint. Two token
|
||||
* kinds are accepted, dispatched by the JWT `kid` header:
|
||||
*
|
||||
* - Authentik-issued OIDC access tokens (any kid) — verified against
|
||||
* Authentik's JWKS over the network.
|
||||
* - IdP-issued OIDC access tokens (any kid) — verified against the
|
||||
* issuer's JWKS over the network, located via OIDC discovery.
|
||||
* - CLI tokens minted at /connect (kid="cli-v1") — verified locally
|
||||
* with the HMAC CLI_TOKEN_SECRET.
|
||||
*
|
||||
@@ -18,8 +19,25 @@ import { CLI_TOKEN_KID, tokenKid, verifyCliToken } from "./cli-token";
|
||||
* This is distinct from the NextAuth session cookie path used by the Web UI.
|
||||
*/
|
||||
|
||||
type JwkSet = ReturnType<typeof createRemoteJWKSet>;
|
||||
|
||||
type GlobalWithJwks = typeof globalThis & {
|
||||
__sharedMemoryJwks?: ReturnType<typeof createRemoteJWKSet>;
|
||||
/**
|
||||
* Resolved key set, cached as a *promise* rather than a value.
|
||||
*
|
||||
* Resolution now involves a network round-trip (OIDC discovery), and the
|
||||
* MCP endpoint verifies a token on essentially every request. Caching the
|
||||
* settled value would leave a window in which N concurrent cold requests
|
||||
* each start their own discovery fetch; caching the in-flight promise means
|
||||
* the first caller does the work and everyone else awaits the same result.
|
||||
*/
|
||||
__sharedMemoryJwks?: Promise<JwkSet>;
|
||||
/**
|
||||
* Epoch ms after which discovery should be re-attempted, set only when we
|
||||
* had to fall back (see `jwks()`). Undefined means the cached set came from
|
||||
* a successful discovery and is good indefinitely.
|
||||
*/
|
||||
__sharedMemoryJwksRetryAt?: number;
|
||||
};
|
||||
const g = globalThis as GlobalWithJwks;
|
||||
|
||||
@@ -27,6 +45,10 @@ 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.
|
||||
*
|
||||
* Not every IdP works that way: EntraID has one issuer per tenant regardless
|
||||
* of how many app registrations you create, so OIDC_ISSUER_MCP is left unset
|
||||
* there and this falls through to OIDC_ISSUER.
|
||||
*/
|
||||
export function mcpIssuer(): string {
|
||||
return env().OIDC_ISSUER_MCP ?? env().OIDC_ISSUER;
|
||||
@@ -50,16 +72,88 @@ function acceptedIssuers(): [string, string] {
|
||||
return [bare, `${bare}/`];
|
||||
}
|
||||
|
||||
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 url = new URL(`${mcpIssuer().replace(/\/$/, "")}/jwks/`);
|
||||
g.__sharedMemoryJwks = createRemoteJWKSet(url, {
|
||||
cacheMaxAge: 10 * 60 * 1000, // 10 min
|
||||
cooldownDuration: 30 * 1000,
|
||||
});
|
||||
const JWKS_OPTIONS = {
|
||||
cacheMaxAge: 10 * 60 * 1000, // 10 min
|
||||
cooldownDuration: 30 * 1000,
|
||||
} as const;
|
||||
|
||||
/** How long to keep serving a fallback key set before retrying discovery. */
|
||||
const DISCOVERY_RETRY_COOLDOWN_MS = 60 * 1000;
|
||||
|
||||
/** Discovery can hang; every MCP request waits on it, so bound it. */
|
||||
const DISCOVERY_TIMEOUT_MS = 5 * 1000;
|
||||
|
||||
/**
|
||||
* The pre-discovery convention: `${issuer}/jwks/`.
|
||||
*
|
||||
* This is Authentik's canonical JWKS path and was hardcoded here. It stays as
|
||||
* the fallback so that a deployment whose discovery document is unreachable
|
||||
* behaves exactly as it did before this change.
|
||||
*/
|
||||
function fallbackJwksUri(): string {
|
||||
return `${mcpIssuer().replace(/\/$/, "")}/jwks/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `jwks_uri` out of the MCP issuer's OIDC discovery document.
|
||||
*
|
||||
* `${issuer}/jwks/` is an Authentik convention, not a standard — RFC 8414
|
||||
* says the key set lives wherever `jwks_uri` points, and providers disagree
|
||||
* wildly. EntraID serves keys at
|
||||
* `https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys`, nowhere
|
||||
* near `${issuer}/jwks/`, so with the path hardcoded every EntraID-issued MCP
|
||||
* token fails verification with a 404 on the key set — authentication is
|
||||
* simply impossible, not merely misconfigured. Ask the issuer where its keys
|
||||
* are instead of guessing.
|
||||
*
|
||||
* Returns null (never throws) on any failure, so the caller can fall back.
|
||||
*/
|
||||
async function discoverJwksUri(): Promise<string | null> {
|
||||
const url = `${mcpIssuer().replace(/\/$/, "")}/.well-known/openid-configuration`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const doc: unknown = await res.json();
|
||||
const uri = (doc as { jwks_uri?: unknown } | null)?.jwks_uri;
|
||||
if (typeof uri !== "string" || uri.trim().length === 0) return null;
|
||||
// A malformed jwks_uri must not blow up the request path.
|
||||
new URL(uri);
|
||||
return uri;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key set MCP access tokens are verified against, resolved once per
|
||||
* process.
|
||||
*
|
||||
* Async because discovery is a network call. The cached promise is installed
|
||||
* synchronously — before the first `await` inside the IIFE runs — so
|
||||
* concurrent callers always join the existing resolution rather than racing
|
||||
* to start their own.
|
||||
*
|
||||
* When discovery fails we serve the legacy fallback but arm a retry: a single
|
||||
* blip at process start would otherwise pin the wrong URL for the lifetime of
|
||||
* the container, which on EntraID means MCP auth stays broken until someone
|
||||
* restarts it. The cooldown keeps a persistently-unreachable discovery
|
||||
* endpoint from being hit on every request.
|
||||
*/
|
||||
function jwks(): Promise<JwkSet> {
|
||||
const retryAt = g.__sharedMemoryJwksRetryAt;
|
||||
const dueForRetry = retryAt !== undefined && Date.now() >= retryAt;
|
||||
if (g.__sharedMemoryJwks && !dueForRetry) return g.__sharedMemoryJwks;
|
||||
|
||||
g.__sharedMemoryJwksRetryAt = undefined;
|
||||
g.__sharedMemoryJwks = (async () => {
|
||||
const discovered = await discoverJwksUri();
|
||||
if (discovered) return createRemoteJWKSet(new URL(discovered), JWKS_OPTIONS);
|
||||
g.__sharedMemoryJwksRetryAt = Date.now() + DISCOVERY_RETRY_COOLDOWN_MS;
|
||||
return createRemoteJWKSet(new URL(fallbackJwksUri()), JWKS_OPTIONS);
|
||||
})();
|
||||
return g.__sharedMemoryJwks;
|
||||
}
|
||||
|
||||
@@ -143,7 +237,7 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
|
||||
} as AuthenticatedClaims;
|
||||
}
|
||||
|
||||
const { payload } = await jwtVerify(token, jwks(), {
|
||||
const { payload } = await jwtVerify(token, await jwks(), {
|
||||
issuer: acceptedIssuers(),
|
||||
audience: env().OIDC_AUDIENCE,
|
||||
});
|
||||
@@ -153,6 +247,17 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
|
||||
buildWwwAuthenticate("invalid_token", "missing sub"),
|
||||
);
|
||||
}
|
||||
// Groups overage: the IdP is telling us it holds memberships it declined
|
||||
// to list. `extractGroupsClaim` would read that as "no claim emitted" and
|
||||
// userContextFromClaims would fall back to the DB snapshot — granting
|
||||
// project access from a stale record while the live state is admittedly
|
||||
// unknown. Refuse; the operator fix is in the description.
|
||||
if (detectGroupsOverage(payload)) {
|
||||
const desc =
|
||||
"groups overage: IdP did not enumerate group membership " +
|
||||
"(set groupMembershipClaims=ApplicationGroup on EntraID)";
|
||||
throw new UnauthorizedError(desc, buildWwwAuthenticate("invalid_token", desc));
|
||||
}
|
||||
// Normalize the issuer for identity purposes.
|
||||
//
|
||||
// The token was just verified against mcpIssuer() — that check is done.
|
||||
|
||||
Reference in New Issue
Block a user