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>
292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
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:
|
|
*
|
|
* - 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.
|
|
*
|
|
* Both resolve to the same `AuthenticatedClaims` shape so downstream code
|
|
* (`userContextFromClaims`) doesn't care which path produced them.
|
|
*
|
|
* This is distinct from the NextAuth session cookie path used by the Web UI.
|
|
*/
|
|
|
|
type JwkSet = ReturnType<typeof createRemoteJWKSet>;
|
|
|
|
type GlobalWithJwks = typeof globalThis & {
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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}/`];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export interface AuthenticatedClaims extends JWTPayload {
|
|
sub: string;
|
|
iss: string;
|
|
/**
|
|
* Group names from the OIDC `groups` claim. Authentik / Keycloak / properly-
|
|
* configured EntraID emit `string[]` here. We coerce non-array / non-string
|
|
* entries away and present an empty array if the claim is absent. For CLI
|
|
* (HMAC) tokens this is always undefined — the consumer (userContextFromClaims)
|
|
* falls back to the DB snapshot from the user's last interactive sign-in.
|
|
*/
|
|
groups?: string[];
|
|
}
|
|
|
|
export class UnauthorizedError extends Error {
|
|
constructor(
|
|
public readonly reason: string,
|
|
public readonly wwwAuthenticate: string,
|
|
) {
|
|
super(reason);
|
|
this.name = "UnauthorizedError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pull `groups` off a verified OIDC payload as a clean `string[]`. Non-
|
|
* string entries are dropped silently. Returns undefined when the claim
|
|
* is absent so callers can distinguish "no claim emitted" from "user is
|
|
* in zero groups" (`[]`).
|
|
*/
|
|
function extractGroupsClaim(payload: JWTPayload): string[] | undefined {
|
|
const raw = (payload as { groups?: unknown }).groups;
|
|
if (raw === undefined || raw === null) return undefined;
|
|
if (!Array.isArray(raw)) return [];
|
|
const out: string[] = [];
|
|
for (const v of raw) {
|
|
if (typeof v === "string" && v.trim().length > 0) out.push(v.trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildWwwAuthenticate(error?: string, description?: string): string {
|
|
const parts: string[] = [`Bearer realm="OAuth"`];
|
|
// RFC 9728 — point clients at our protected-resource metadata so they can
|
|
// discover the authorization server.
|
|
parts.push(`resource_metadata="${env().PUBLIC_URL.replace(/\/$/, "")}/.well-known/oauth-protected-resource"`);
|
|
if (error) parts.push(`error="${error}"`);
|
|
if (description) parts.push(`error_description="${description.replace(/"/g, "'")}"`);
|
|
return parts.join(", ");
|
|
}
|
|
|
|
export async function authenticateBearer(authHeader: string | null): Promise<AuthenticatedClaims> {
|
|
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
|
|
throw new UnauthorizedError("missing bearer token", buildWwwAuthenticate());
|
|
}
|
|
|
|
const token = authHeader.slice("bearer ".length).trim();
|
|
if (!token) {
|
|
throw new UnauthorizedError("empty bearer token", buildWwwAuthenticate("invalid_token"));
|
|
}
|
|
|
|
// Dispatch by kid: CLI tokens are verified locally, everything else goes
|
|
// through Authentik JWKS. We never attempt JWKS verification for CLI
|
|
// tokens (or vice versa) so a kid mismatch fails fast.
|
|
const isCliToken = tokenKid(token) === CLI_TOKEN_KID;
|
|
|
|
try {
|
|
if (isCliToken) {
|
|
const claims = await verifyCliToken(token);
|
|
// CLI tokens carry the user's real Authentik identity in oidc_iss /
|
|
// oidc_sub. Surface those on the standard claims shape so user
|
|
// context resolution is identical to the Authentik path. CLI tokens
|
|
// never carry a groups claim — leave `groups` undefined; the user-
|
|
// context resolver falls back to the DB snapshot.
|
|
return {
|
|
...claims,
|
|
iss: claims.oidc_iss,
|
|
sub: claims.oidc_sub,
|
|
} as AuthenticatedClaims;
|
|
}
|
|
|
|
const { payload } = await jwtVerify(token, await jwks(), {
|
|
issuer: acceptedIssuers(),
|
|
audience: env().OIDC_AUDIENCE,
|
|
});
|
|
if (!payload.sub) {
|
|
throw new UnauthorizedError(
|
|
"token missing sub claim",
|
|
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.
|
|
// But identity is keyed on (oidc_iss, oidc_sub), and the Web UI signs
|
|
// people in through a DIFFERENT application whose tokens carry
|
|
// OIDC_ISSUER. Authentik's `sub` is stable across providers (it is
|
|
// `user.uid`, a user-level value), so the only thing that differs is the
|
|
// issuer.
|
|
//
|
|
// Leave it un-normalized and userContextFromClaims — which UPSERTS rather
|
|
// than failing — quietly creates a SECOND user row for the same human:
|
|
// MCP writes would land in an account with none of their memories, and
|
|
// nothing would look broken. Pin identity to the canonical issuer.
|
|
return {
|
|
...payload,
|
|
iss: env().OIDC_ISSUER,
|
|
groups: extractGroupsClaim(payload),
|
|
} as AuthenticatedClaims;
|
|
} catch (err) {
|
|
if (err instanceof UnauthorizedError) throw err;
|
|
const desc =
|
|
err instanceof joseErrors.JWTExpired
|
|
? "token expired"
|
|
: err instanceof joseErrors.JWTInvalid
|
|
? "token invalid"
|
|
: err instanceof joseErrors.JWTClaimValidationFailed
|
|
? `claim invalid: ${err.claim}`
|
|
: "verification failed";
|
|
throw new UnauthorizedError(desc, buildWwwAuthenticate("invalid_token", desc));
|
|
}
|
|
}
|