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:
2026-08-12 10:51:29 -07:00
co-authored by Claude Opus 5
parent 80341c558d
commit bbea0f74f3
10 changed files with 920 additions and 82 deletions
+125
View File
@@ -0,0 +1,125 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
/**
* Resolving OIDC claims to the internal `users.id`.
*
* Both surfaces come through here — the Web UI (`auth.ts`) and the MCP
* endpoint (`lib/mcp/context.ts`) — and that is the point. They each used to
* carry their own copy of this upsert, which is how the two drifted into
* disagreeing about who a user is.
*
* ## Why `oid` exists here
*
* Authentik's `sub` is `user.uid`, identical across every provider, so
* (iss, sub) identifies a person. EntraID's `sub` is PAIRWISE: Microsoft
* derives it from the token recipient, so the Web UI app registration and the
* MCP app registration emit different `sub` values for the same human. Keyed
* on `sub`, that person gets two rows — they sign into the Web UI, connect
* Claude Code, and find an empty account. Both paths upsert, so nothing
* errors; the split is completely silent.
*
* `oid` is the directory object id, which Microsoft documents as constant for
* a user across every application in a tenant. When it's present it wins.
* When it's absent (Authentik, Keycloak, Okta) behaviour is exactly as before.
*/
/** Extract a usable EntraID `oid` claim, or null on IdPs that don't emit one. */
export function oidClaim(claims: unknown): string | null {
const raw = (claims as { oid?: unknown } | null | undefined)?.oid;
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : null;
}
export interface IdentityInput {
iss: string;
sub: string;
/** EntraID object id, or null. */
oid: string | null;
email: string | null;
name: string | null;
picture: string | null;
}
/** Postgres unique-violation. */
function isUniqueViolation(err: unknown): boolean {
return (err as { code?: unknown } | null)?.code === "23505";
}
/**
* Resolve (creating if needed) the `users` row for a set of verified claims.
*
* Resolution order when `oid` is present:
*
* 1. a row already keyed on this `oid` — the steady state
* 2. a pre-`oid` row for the same (iss, sub), which gets its `oid`
* backfilled in place. This is how a deployment that ran before the
* 0005 migration keeps its accounts instead of stranding them.
* 3. insert
*
* Without `oid` this collapses to the original (iss, sub) upsert.
*/
export async function resolveUserId(input: IdentityInput): Promise<string> {
const { iss, sub, oid, email, name, picture } = input;
const profile = { email, name, picture, lastSeenAt: new Date() };
if (oid) {
// 1. Steady state.
//
// Deliberately does NOT touch `oidc_sub`. The stored value is whichever
// app registration this person first arrived through; rewriting it on
// every request would flip it back and forth between the Web and MCP
// values and could collide with the (iss, sub) unique index.
const byOid = await db
.update(users)
.set(profile)
.where(and(eq(users.oidcIss, iss), eq(users.oidcOid, oid)))
.returning({ id: users.id });
if (byOid[0]) return byOid[0].id;
// 2. Adopt a row created before `oid` was recorded.
const adopted = await db
.update(users)
.set({ ...profile, oidcOid: oid })
.where(
and(eq(users.oidcIss, iss), eq(users.oidcSub, sub), isNull(users.oidcOid)),
)
.returning({ id: users.id });
if (adopted[0]) return adopted[0].id;
}
// 3. Insert. The conflict target stays (iss, sub) because that is the index
// every row has; a concurrent writer racing us on `oid` instead is caught
// below.
try {
const inserted = await db
.insert(users)
.values({ oidcIss: iss, oidcSub: sub, oidcOid: oid, email, name, picture })
.onConflictDoUpdate({
target: [users.oidcIss, users.oidcSub],
set: profile,
})
.returning({ id: users.id });
if (inserted[0]) return inserted[0].id;
} catch (err) {
// Two requests for the same person arriving together through DIFFERENT
// app registrations: same `oid`, different `sub`, so the (iss, sub)
// conflict target doesn't fire and the partial (iss, oid) index rejects
// the loser. Fall through and read the winner's row.
if (!isUniqueViolation(err)) throw err;
}
const existing = await db
.select({ id: users.id })
.from(users)
.where(
oid
? and(eq(users.oidcIss, iss), eq(users.oidcOid, oid))
: and(eq(users.oidcIss, iss), eq(users.oidcSub, sub)),
)
.limit(1);
if (!existing[0]) throw new Error("user upsert failed and not found on re-read");
return existing[0].id;
}