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.
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)
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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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_urinow 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 firstawait, 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
subsplits one person into two accountsEntra's
subis pairwise, derived from the token recipient, so the Web UI and MCP app registrations emit differentsubvalues 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 before0005_user_oid.sqladopt theiroidon next sign-in.3. Groups overage silently deleted memberships
Past 200 groups Entra omits
groupsentirely and substitutes a_claim_namespointer.normalizeGroupsClaimread 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.mdparallels the README's Authentik A/B structure. Researching it corrected five things, including that the manifest property isapi.requestedAccessTokenVersion—accessTokenAcceptedVersionbelongs to the retired Azure AD Graph format and isn't findable in the portal.Verification
66 tests against a real pgvector instance; clean
tscandeslint. Mutation-checked: reverting the JWKS change fails exactly the 4 discovery tests, and stubbing outoid/ 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 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>