feat: support Microsoft Entra ID as an OIDC provider #22

Merged
jknapp merged 2 commits from feat/entra-id-support into main 2026-08-13 01:46:29 +00:00
Owner

Three defects stood between this codebase and a working Entra ID deployment. All three fail silently, and each masks the next — fixing any two still leaves a broken or dangerous install.

1. JWKS discovery (blocker)

The key set URL was hardcoded to ${issuer}/jwks/, which is Authentik's convention rather than 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.

jwks_uri now comes from the issuer's discovery document, falling back to the old path so Authentik is untouched. The cached promise is installed synchronously before the first await, so concurrent cold requests join one discovery fetch; a discovery failure arms a 60s retry rather than pinning the wrong URL for the life of the container.

2. Identity — Entra's sub splits one person into two accounts

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, through one resolver both surfaces share (lib/auth/identity.ts). Rows created before 0005_user_oid.sql adopt their oid on next sign-in.

Upgrade ordering: adoption matches on sub, and the only sub that can match is the Web UI one. Existing Entra deployments should have users sign into the Web UI once before reconnecting an MCP client. Deployments that never ran Entra are unaffected.

3. Groups overage silently deleted memberships

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 — 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.

Docs

docs/oidc-entra-id.md parallels the README's Authentik A/B structure. Researching it corrected five things, including that the manifest property is api.requestedAccessTokenVersionaccessTokenAcceptedVersion belongs to the retired Azure AD Graph format and isn't findable in the portal.

Verification

66 tests against a real pgvector instance; clean tsc and eslint. Mutation-checked: reverting the JWKS change fails exactly the 4 discovery tests, and stubbing out oid / removing the overage guard fails exactly the 7 tests covering them, with the Authentik-path tests still passing.

🤖 Generated with Claude Code

Three defects stood between this codebase and a working Entra ID deployment. All three fail **silently**, and each masks the next — fixing any two still leaves a broken or dangerous install. ### 1. JWKS discovery (blocker) The key set URL was hardcoded to `${issuer}/jwks/`, which is Authentik's convention rather than 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. `jwks_uri` now comes from the issuer's discovery document, falling back to the old path so Authentik is untouched. The cached promise is installed synchronously before the first `await`, so concurrent cold requests join one discovery fetch; a discovery failure arms a 60s retry rather than pinning the wrong URL for the life of the container. ### 2. Identity — Entra's `sub` splits one person into two accounts 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, through one resolver both surfaces share (`lib/auth/identity.ts`). Rows created before `0005_user_oid.sql` adopt their `oid` on next sign-in. > **Upgrade ordering:** adoption matches on `sub`, and the only `sub` that can match is the Web UI one. Existing Entra deployments should have users sign into the Web UI once before reconnecting an MCP client. Deployments that never ran Entra are unaffected. ### 3. Groups overage silently deleted memberships 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 — 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. ## Docs `docs/oidc-entra-id.md` parallels the README's Authentik A/B structure. Researching it corrected five things, including that the manifest property is `api.requestedAccessTokenVersion` — `accessTokenAcceptedVersion` belongs to the retired Azure AD Graph format and isn't findable in the portal. ## Verification 66 tests against a real pgvector instance; clean `tsc` and `eslint`. Mutation-checked: reverting the JWKS change fails exactly the 4 discovery tests, and stubbing out `oid` / removing the overage guard fails exactly the 7 tests covering them, with the Authentik-path tests still passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp added 2 commits 2026-08-12 23:36:23 +00:00
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>
The README's OIDC section is written against Authentik and stays that
way; Entra differs enough that inlining it would have doubled a file
that is already 32k. The new doc parallels the README's A/B structure so
the two are diffable, and leads with the traps, since every one of them
surfaces as an opaque 401 rather than as anything resembling its cause:
the access token version, the tenant-specific authority, `aud` being the
client-ID GUID while the requested scope is an `api://` URI, redirect-URI
platform types, and group GUIDs.

Sections 7 and 10b document the identity and overage behaviour shipped
in the previous commit, including the one upgrade-ordering caveat: an
existing Entra deployment should sign a user into the Web UI once before
reconnecting their MCP client, or the pre-migration row is stranded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp merged commit a3f28c52a5 into main 2026-08-13 01:46:29 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: cybercove-labs/shared-memory#22