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:
@@ -0,0 +1,29 @@
|
||||
-- EntraID identity: key users on `oid` when the IdP emits it.
|
||||
--
|
||||
-- EntraID's `sub` is a PAIRWISE identifier — derived from the token recipient,
|
||||
-- so the Web UI app registration and the MCP app registration hand out
|
||||
-- different `sub` values for the same person. Both `auth.ts` and
|
||||
-- `lib/mcp/context.ts` upsert on (oidc_iss, oidc_sub), so on EntraID one human
|
||||
-- resolves to two rows: they sign into the Web UI, connect an MCP client, and
|
||||
-- land in an empty account with their memories nowhere to be seen. Nothing
|
||||
-- errors, which is what makes it dangerous.
|
||||
--
|
||||
-- `oid` is the user's directory object id, which Microsoft documents as
|
||||
-- constant for a user across every application in a tenant. Recording it gives
|
||||
-- us a key that holds across both surfaces.
|
||||
--
|
||||
-- Nullable on purpose: Authentik, Keycloak and Okta emit no `oid`, and there
|
||||
-- `sub` is already application-independent. Those deployments keep using
|
||||
-- (oidc_iss, oidc_sub) and are untouched by this migration.
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "oidc_oid" text;
|
||||
|
||||
-- Partial index. Every non-EntraID row holds NULL here; a plain unique index
|
||||
-- would treat those as colliding and permit exactly one such user.
|
||||
CREATE UNIQUE INDEX "users_iss_oid_uq"
|
||||
ON "users" ("oidc_iss", "oidc_oid")
|
||||
WHERE "oidc_oid" IS NOT NULL;
|
||||
|
||||
-- No backfill. `oid` is only knowable from a token, so existing rows adopt
|
||||
-- theirs on the owner's next sign-in (see `adoptLegacyRow` in
|
||||
-- lib/auth/identity.ts). Backfilling would mean guessing.
|
||||
Reference in New Issue
Block a user