the README mentions — users can pick any `--callback-port` without
re-registering.
Three constraints on that convenience:
- **The path is still matched exactly.** Registering bare `http://localhost`
does *not* match `http://localhost:33418/callback`. The `/callback` suffix
must be there, and paths are case-sensitive.
- **Do not register several localhost URIs differing only by port.** Entra
picks one arbitrarily when matching.
- **Port-agnostic matching is documented for `localhost` only**, not for
`127.0.0.1` — and the portal text box refuses the `http://127.0.0.1` form
anyway (it requires a manifest edit). Use `localhost`. `[::1]` is not
supported at all.
You do **not** need to enable **Allow public client flows**
(`allowPublicClient`). That toggle is a *fallback* for flows where Entra can't
infer the client type from a redirect URI — device code, ROPC, Windows
Integrated Auth. Authorization code + PKCE with a registered
mobile-and-desktop redirect URI is inferred correctly without it. (Entra's own
`reply-url` doc says otherwise in one sentence; the manifest reference and the
AADSTS7000218 troubleshooting article agree it is a fallback. Leave it off
unless you hit a problem — Microsoft warns that flipping a confidential client
to public has security implications.)
**Application (client) ID** of whichever registration Claude Code
authenticates as → `.env` as `OIDC_CLIENT_ID_MCP`.
---
## 5. `OIDC_AUDIENCE` vs. `OIDC_AUDIENCE_SCOPE`
On Authentik these two look redundant — the scope mapping is named
`aud-shared-memory` and it emits `aud: shared-memory`, so the values track each
other. On Entra they are **necessarily different strings**, and swapping them is
the easiest mistake to make here.
| Var | What it is | Entra value |
|---|---|---|
| `OIDC_AUDIENCE` | The `aud` claim `jwt.ts` requires on the token | `<client-id-of-B>` — a bare GUID |
| `OIDC_AUDIENCE_SCOPE` | The scope string the *client* asks for, advertised in `/.well-known/oauth-protected-resource` | `api://<client-id-of-B>/access_as_user` |
Why they differ: the client requests a scope by its full URI
(*Application ID URI* + `/` + scope name), but Entra does not put that URI in the
token. For **v2.0** access tokens it splits the request into `aud` (the API's
**client-ID GUID**) and `scp` (the **short** scope name, `access_as_user`).
Three distinct strings for what feels like one concept.
> **Do not trust this document — decode a real token.** Microsoft's own
**What this used to do.** An absent `groups` claim and a claim saying "zero
groups" were indistinguishable to `normalizeGroupsClaim`, which returned `[]`
for both — and the `names.length === 0` branch **deletes every one of that
user's `user_groups` rows**. So a user crossing 200 groups signed into the Web
UI once and silently lost access to every shared project, on both surfaces,
with no error anywhere. (The MCP path never deleted anything, but it then read
the snapshot the Web sign-in had just emptied.)
**What happens now.** `detectGroupsOverage` looks for `_claim_names.groups` and
`hasgroups`, and both surfaces refuse the token rather than acting on group
state they know they don't have:
- **Web sign-in** throws `GroupsOverageError`, which fails the sign-in. Existing
memberships are left completely untouched.
- **MCP** returns 401 with
`error_description="groups overage: IdP did not enumerate group membership …"`.
The user is blocked until an admin fixes the claim configuration — and then
signs in and finds their access exactly as it was. Nothing to restore, because
nothing was destroyed. Granting access from a stale snapshot, or revoking it on
a claim the IdP never made, are both guesses; refusing is the only honest
answer available.
An absent `groups` claim with **no** overage marker still clears memberships.
That is unchanged and deliberate: the IdP has genuinely stopped asserting the
groups, so we stop honouring them.
#### Getting a blocked user back in
1. App registration → **Token configuration** (or the manifest) → set
`groupMembershipClaims` to **`ApplicationGroup`** — the portal labels this
**"Groups assigned to the application"**. It emits only the groups
explicitly assigned to *this* application, which for a memory server is a
handful, so the 200-group ceiling stops being reachable. Microsoft
recommends it for exactly this reason, and it is the same setting
`cloud_displayname` requires — §10a and §10b have one shared fix.
2. Enterprise applications → your app → **Users and groups** → assign the
groups you actually share projects with. `ApplicationGroup` emits **directly
assigned groups only**; nested and transitive membership is excluded, so
assign the real groups rather than a parent.
3. Confirm the `groups` optional claim is configured for the **access token**,
not only the ID token — the MCP path reads the access token.
4. The user signs in again. Their memberships were never deleted, so their
access returns as it was.
Leaving `groupMembershipClaims` at `All` or `SecurityGroup` in a large tenant is
what makes this bite in the first place.
If a group genuinely must exceed the limit, the other way out is **app roles**,
which are app-scoped and never overage — but they arrive in a `roles` claim and
this codebase reads `groups`, so that is a code change, not a config change.
---
## 11. Connecting Claude Code
Everything in README → **Connecting Claude Code** applies unchanged, with one
Entra-specific confirmation: **the pre-registered client-id path is
mandatory.**
Entra does not implement RFC 7591 Dynamic Client Registration. Its discovery
document publishes no `registration_endpoint`, it serves no RFC 8414
authorization-server metadata at all (only OIDC discovery), and it does not
advertise `client_id_metadata_document_supported` — so neither DCR nor the CIMD
mechanism that superseded it in the MCP spec is available. Microsoft states this
plainly in its own MCP guidance ("Microsoft Entra ID doesn't currently support
client registration") and has said it is not on the near-term roadmap.
Practically, this means:
- Use the plugin (`plugin/.mcp.json` ships a pre-registered `clientId`), or
- Pass `--client-id <OIDC_CLIENT_ID_MCP>` explicitly on `claude mcp add`.
A client that expects to self-register will fail. This is the same situation as
Authentik, so the README's guidance needs no adjustment.
---
## 12. Env var reference card
Straight from Entra's UI labels to `.env` keys. `<A>` is app registration A
(Web UI, §3); `<B>` is app registration B (MCP resource server, §4).
| `.env` key | Where it comes from in Entra | Example |
|---|---|---|
| `OIDC_ISSUER` | `issuer` from the **tenant-specific** discovery document (§2). Not the authority you typed — the value the endpoint returns. | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
| `OIDC_ISSUER_MCP` | **Leave unset.** Entra has one issuer per tenant; there is no per-application issuer to point at. `mcpIssuer()` falls back to `OIDC_ISSUER`. | *(unset)* |
| `OIDC_CLIENT_ID_MCP` | Client ID of the **public PKCE** registration Claude Code authenticates as (§4c) | `3333…-cccc` |
| `OIDC_AUDIENCE` | `<B>` → **Overview → Application (client) ID**. The bare GUID, *not* the `api://` URI. Confirm by decoding a token (§8). | `2222…-bbbb` |
| `OIDC_AUDIENCE_SCOPE` | `<B>` → **Expose an API** → the scope's full string: Application ID URI + `/` + scope name. Must be set explicitly; the `aud-…` default is Authentik-only. | `api://2222…-bbbb/access_as_user` |
| `OIDC_OFFLINE_ACCESS` | Nothing to configure in Entra — set it to `true` (§6). | `true` |
`PUBLIC_URL` and the non-OIDC vars are unchanged from the README.
---
## 13. Multitenant is not supported
`acceptedIssuers()` in `apps/web/lib/auth/jwt.ts` compares `iss` against a
fixed pair of strings (with and without a trailing slash). Multitenant Entra
apps require substituting each token's `tid` claim into the `{tenantid}`
placeholder before comparing, and separately validating the signing key's own
issuer. Neither is implemented.
Beyond `iss`, multitenant would also need the identity keying in §7 resolved,
since Microsoft is explicit that `oid` and `sub` differ per tenant by design and
that a guest user authenticating in another tenant *"should be treated as if