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>
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
import { db } from "@/lib/db/client";
|
|
import { groups, userGroups } from "@/lib/db/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { oidClaim, resolveUserId } from "@/lib/auth/identity";
|
|
import type { AuthenticatedClaims } from "@/lib/auth/jwt";
|
|
|
|
/**
|
|
* Per-request user context for MCP tool handlers.
|
|
*
|
|
* Resolves (or creates) the internal `users` row from the OIDC claims so
|
|
* tools work with stable UUID foreign keys rather than raw `sub` strings.
|
|
*
|
|
* `groups` and `defaultProjectKey` are populated here from the inbound
|
|
* request: groups come from the JWT's `groups` claim (live) with a DB
|
|
* fallback for CLI tokens that carry no claim; defaultProjectKey is the
|
|
* `X-Project-Key` header (already Zod-validated at the route boundary),
|
|
* used as a fallback when a tool call omits `project`.
|
|
*/
|
|
export interface UserContext {
|
|
/** Internal users.id UUID. */
|
|
userId: string;
|
|
/** OIDC sub claim (stable identifier from the IdP). */
|
|
sub: string;
|
|
/** OIDC issuer. */
|
|
iss: string;
|
|
/** Optional profile fields if present in the access token. */
|
|
email: string | null;
|
|
name: string | null;
|
|
/**
|
|
* Group *names* the user is a member of. For OIDC bearer tokens these are
|
|
* the live values from the verified token's `groups` claim. For CLI tokens
|
|
* (which carry no groups claim), this is the DB snapshot from the user's
|
|
* last interactive sign-in — necessarily stale, but the only signal we
|
|
* have without going back to the IdP.
|
|
*/
|
|
groups: string[];
|
|
/**
|
|
* Project key supplied via the `X-Project-Key` request header. Tools that
|
|
* accept an optional `project` argument use this as a fallback when the
|
|
* caller didn't pass one explicitly. Always validated upstream against
|
|
* the same Zod schema as the tool argument.
|
|
*/
|
|
defaultProjectKey?: string;
|
|
}
|
|
|
|
export interface UserContextOverrides {
|
|
/** Project key from the X-Project-Key request header (already validated). */
|
|
defaultProjectKey?: string;
|
|
}
|
|
|
|
export async function userContextFromClaims(
|
|
claims: AuthenticatedClaims,
|
|
overrides: UserContextOverrides = {},
|
|
): Promise<UserContext> {
|
|
const email = (claims.email as string | undefined) ?? null;
|
|
const name = (claims.name as string | undefined) ?? null;
|
|
const picture = (claims.picture as string | undefined) ?? null;
|
|
|
|
// Shared with the Web UI sign-in path (auth.ts). Keeping one resolver is
|
|
// what stops the two surfaces disagreeing about who a user is — on EntraID
|
|
// they see different `sub` values for the same person and would otherwise
|
|
// each create their own account. See lib/auth/identity.ts.
|
|
const userId = await resolveUserId({
|
|
iss: claims.iss,
|
|
sub: claims.sub,
|
|
oid: oidClaim(claims),
|
|
email,
|
|
name,
|
|
picture,
|
|
});
|
|
|
|
// OIDC bearer tokens carry a `groups` claim (when the IdP is configured to
|
|
// emit it). CLI tokens never do — they go through verifyCliToken which
|
|
// doesn't set claims.groups. In that case fall back to the DB snapshot
|
|
// from the user's last interactive sign-in.
|
|
const groupNames = claims.groups ?? (await loadUserGroups(userId));
|
|
|
|
return {
|
|
userId,
|
|
sub: claims.sub,
|
|
iss: claims.iss,
|
|
email,
|
|
name,
|
|
groups: groupNames,
|
|
defaultProjectKey: overrides.defaultProjectKey,
|
|
};
|
|
}
|
|
|
|
async function loadUserGroups(userId: string): Promise<string[]> {
|
|
const rows = await db
|
|
.select({ name: groups.name })
|
|
.from(userGroups)
|
|
.innerJoin(groups, eq(userGroups.groupId, groups.id))
|
|
.where(eq(userGroups.userId, userId));
|
|
return rows.map((r) => r.name);
|
|
}
|