Files
shadowdaoandClaude Opus 5 4d6694620a docs: add the Entra ID provider walkthrough
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>
2026-08-12 10:51:39 -07:00

31 KiB

OIDC provider setup: Microsoft Entra ID

Companion to the OIDC provider setup section in README.md, which walks through Authentik. The structure here deliberately mirrors it — app registration A (Web UI), app registration B (MCP resource server), env var mapping, verification — so the two are diffable. Where Entra genuinely differs from Authentik, the difference is called out rather than smoothed over.

Everything below assumes a single-tenant deployment (signInAudience = "Accounts in this organizational directory only"). Multitenant is possible but the iss verification in apps/web/lib/auth/jwt.ts compares against a fixed string, so it would need code changes — see Multitenant at the end.


0. Prerequisite: your build must have JWKS discovery

Check this first. Nothing else in this document works without it.

Until recently apps/web/lib/auth/jwt.ts hardcoded the JWKS location as ${issuer}/jwks/. That is an Authentik convention, not a standard — RFC 8414 says the key set lives wherever the discovery document's jwks_uri points, and Entra puts it somewhere else entirely:

IdP JWKS URL
Authentik https://auth.example.com/application/o/<slug>/jwks/
Entra ID https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys

With the path hardcoded, every Entra-issued MCP access token fails verification because the key set fetch 404s. There is no configuration that works around it; MCP authentication is simply impossible.

The current code resolves jwks_uri from ${OIDC_ISSUER_MCP or OIDC_ISSUER}/.well-known/openid-configuration, caches the result for the process lifetime, and falls back to ${issuer}/jwks/ only if discovery is unreachable (so existing Authentik deployments are untouched).

Confirm your deployment has it before debugging anything else:

# Should return the Entra keys endpoint, not a 404.
curl -s "https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration" \
  | jq -r .jwks_uri
# → https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys

If MCP calls 401 with error_description="verification failed" and your app logs show a fetch to .../v2.0/jwks/, you are on an older build.


1. Concepts, Authentik → Entra

Concept here Authentik Entra ID
OAuth2 client Provider + Application App registration
Issuer Per-application (.../application/o/<slug>/) Per-tenant only — one issuer for the whole directory
Audience claim Scope mapping returning {"aud": …} "Expose an API" scope on the resource app; aud is set automatically
Redirect URI matching Regex allowed (any port) Exact string match, with one loopback exception
Dynamic client registration Not implemented Not implemented

The issuer row is the one that reshapes the setup. On Authentik, the Web UI and MCP endpoint are separate applications with separate issuers, which is why OIDC_ISSUER_MCP exists. Entra has exactly one issuer per tenant no matter how many app registrations you create, so OIDC_ISSUER_MCP is left unset on Entra and mcpIssuer() falls through to OIDC_ISSUER.

You still create two app registrations, for the same reason as on Authentik: one confidential client for the browser sign-in, one resource server that owns the audience the MCP endpoint validates. (A third participant — the public PKCE client Claude Code uses — is covered in §4; you can fold it into registration B or split it out.)


2. Find your tenant ID and use it explicitly

Everywhere below, <tenant-id> is your directory (tenant) GUID, from Entra admin center → Overview → Tenant ID.

Do not use the common or organizations authority. Their discovery documents return a templated issuer — the literal string, verified live:

curl -s https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration | jq -r .issuer
# → https://login.microsoftonline.com/{tenantid}/v2.0

That {tenantid} is not a formatting artifact; it is what the endpoint really returns. jwt.ts compares iss by exact string (via acceptedIssuers()), so against a templated issuer no token can ever match and every MCP call fails with claim invalid: iss.

Microsoft's documented pattern for multitenant apps is to substitute the token's tid claim into the placeholder and then compare — this app does not do that (see Multitenant). For single-tenant, the fix is simply to use the tenant-specific authority, whose discovery document returns a concrete issuer:

curl -s "https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration" | jq -r .issuer
# → https://login.microsoftonline.com/<tenant-id>/v2.0     (no trailing slash)

A verified domain (contoso.onmicrosoft.com) also works as the authority segment — Entra resolves it server-side and returns the GUID form in both issuer and jwks_uri. Since the returned issuer is what tokens carry, OIDC_ISSUER must be the GUID form regardless of which you typed.


3. App registration A — Web UI (confidential client)

Entra admin center → Entra ID → App registrations → New registration

  • Name: shared-memory-web
  • Supported account types: Accounts in this organizational directory only
  • Redirect URI: platform Web, value:
    https://memory.example.com/api/auth/callback/oidc
    
    (replace with your PUBLIC_URL; the /oidc suffix comes from the provider id in apps/web/auth.ts and is not configurable without a code change)

Register, then collect:

  • Overview → Application (client) ID.env as OIDC_CLIENT_ID_WEB
  • Certificates & secrets → New client secret.env as OIDC_CLIENT_SECRET_WEB (copy the Value, not the Secret ID; it is shown once)

API permissions: openid, profile, email are Microsoft Graph delegated permissions and are present by default via User.Read. Add profile explicitly if it is missing — it gates the oid and tid claims, which matter for §7.

No "Expose an API" configuration is needed on this registration. Auth.js only consumes the ID token here.


4. App registration B — MCP resource server

This registration is what OIDC_AUDIENCE refers to. It owns the API scope that Claude Code requests, and the MCP endpoint validates that tokens were minted for it.

App registrations → New registration

  • Name: shared-memory-mcp
  • Supported account types: same as A

4a. Set the access token version — the single most common failure

Manage → Manifest, find and set:

"api": {
    "requestedAccessTokenVersion": 2
}

There is no checkbox for this; it is a manifest edit.

Note on the property name. Older guides (and older versions of this project's notes) call this accessTokenAcceptedVersion at the top level of the manifest. That is the retired Azure AD Graph manifest format — Microsoft removed it from the portal's manifest editor on 2025-01-07, so you will not find that property. The current Microsoft Graph app manifest nests it as api.requestedAccessTokenVersion. The semantics are identical: null or 1 → v1.0 tokens, 2 → v2.0 tokens.

Leave it at the default null and Entra issues v1.0 access tokens, whose issuer is:

https://sts.windows.net/<tenant-id>/          ← note the trailing slash

not https://login.microsoftonline.com/<tenant-id>/v2.0. Verification then fails with claim invalid: iss, and — because everything else in the OAuth handshake succeeded — it looks like a mysterious 401 rather than a configuration error.

The setting lives on the resource app and wins over whichever endpoint the client used: with requestedAccessTokenVersion: 2, a client hitting the v1.0 endpoint still receives a v2.0 access token.

4b. Expose an API

Manage → Expose an API

  1. Application ID URIAdd → accept the default api://<client-id-of-B>.
  2. Add a scope:
    • Scope name: access_as_user
    • Who can consent: Admins and users (see §9)
    • Fill in the admin/user consent display strings; they appear on the consent prompt.

The resulting full scope string is api://<client-id-of-B>/access_as_user.

4c. The public PKCE client (Claude Code)

Claude Code is a public client using PKCE. Add a platform to registration B (or to a third registration if you prefer them separated — then that registration's client ID is OIDC_CLIENT_ID_MCP, and it needs api://<client-id-of-B>/access_as_user under API permissions):

Manage → Authentication → Add a platform → Mobile and desktop applications

The platform type is not cosmetic. A redirect URI registered under the Web platform classifies the app as a confidential client, and the token exchange then demands a client_secret or client_assertion — Claude Code has neither, so the flow dies with AADSTS7000218: The request body must contain the following parameter: 'client_assertion' or 'client_secret'. The portal will happily accept http://localhost:33418/callback as a Web redirect URI, which is what makes this trap easy to fall into. Mobile and desktop applications (publicClient in the manifest) is the correct platform. SPA is not an option either — Entra rejects SPA redirect URIs for non-SPA flows.

Under Custom redirect URIs, register:

http://localhost/callback
https://memory.example.com/auth/cli-callback

The first covers the loopback listener from README → B. OAuth flow; the second is the manual-paste fallback from C. "Mobile and desktop applications" permits arbitrary https:// URIs alongside the loopback one, so both live on the same platform.

Note the missing port. Entra ignores the port component when matching http://localhost redirect URIs, so the single registration http://localhost/callback matches http://localhost:33418/callback, http://localhost:9999/callback, and any other port. This is Entra's equivalent of the Authentik regex (^http://(127\.0\.0\.1|localhost):\d+/.*$) 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 access-tokens page says web APIs "must only accept tokens containing one of their AppId URIs as the aud claim", which contradicts the authoritative access token claims reference ("In v2.0 tokens, this value is always the client ID of the API"). The claims reference is correct for v2.0, but given that Microsoft's docs disagree with each other, verify empirically — see §8.

OIDC_AUDIENCE_SCOPE must be set explicitly on Entra. Left unset, the code defaults to aud-${OIDC_AUDIENCE}, which is an Authentik naming convention and means nothing to Entra — the client would request a nonexistent scope and the authorize request fails outright.


6. offline_access

Set OIDC_OFFLINE_ACCESS=true from the start. Unlike Authentik — where you must first attach an offline_access scope mapping to the provider — Entra treats offline_access as one of its well-defined platform scopes (openid, email, profile, offline_access). Nothing to create, and it is implicitly granted: if any delegated permission is consented, offline_access is too.

Two caveats:

  • It must still be requested at runtime, which is exactly what OIDC_OFFLINE_ACCESS=true achieves — the flag adds it to scopes_supported in /.well-known/oauth-protected-resource, and MCP clients only request scopes they see advertised there.
  • A refresh token comes back only on authorization-code-style flows. That is what Claude Code uses, so this is satisfied; implicit flow would not be.

The .env comment on this var warns that advertising a scope the IdP doesn't offer risks invalid_scope. On Entra that risk doesn't apply.


7. Identity: sub splits accounts across app registrations

Handled as of migration 0005_user_oid.sql. Read this anyway — it explains why oid is in your database, and what happens if you deploy the migration late.

The app keys the users row on (oidc_iss, oidc_sub) — see the upsert in apps/web/lib/mcp/context.ts and the one in apps/web/auth.ts. On Authentik that is safe, because Authentik's sub is user.uid, a user-level value that is identical across providers.

Entra's sub is a pairwise identifier. Microsoft documents it as "based on a combination of the token recipient, tenant, and user" — so the value is scoped to the app registration in the aud position of that particular token:

  • Web UI sign-in → ID token with aud = registration Asub = X
  • MCP access token → aud = registration Bsub = Y

X ≠ Y, by design, for privacy. iss is identical for both (one tenant, one issuer), so (iss, sub) yields two different keys for the same human. Both code paths upsert rather than fail, so nothing looks broken: the person signs into the Web UI, sees their memories, connects Claude Code, and finds an empty account. Writes land in the second row.

There is no configuration fix. sub is in Entra's restricted claim set (no claims-mapping policy can alter it), subject_types_supported advertises only pairwise, and Microsoft has stated that sector_identifier_uri is not used to generate it.

How it's handled. Identity is keyed on oid — the directory object id, which Microsoft documents as constant for a user across every application in a tenant ("all apps get the same oid and tid claims for a user acting in a tenant"). It is emitted by default in v2.0 ID and access tokens as long as the profile scope is requested, which it is.

apps/web/lib/auth/identity.ts holds the single resolver both surfaces call. Resolution order when oid is present:

  1. an existing row keyed on (oidc_iss, oidc_oid) — the steady state
  2. a pre-migration row matching (oidc_iss, oidc_sub) with no oid yet, which gets its oid backfilled in place
  3. insert

IdPs that emit no oid (Authentik, Keycloak, Okta) skip straight to the original (iss, sub) behaviour, unchanged.

One upgrade-ordering caveat. Step 2 adopts a legacy row by matching sub, and the only sub that can match is the one that created it — the Web UI one, since MCP auth against Entra was impossible before the JWKS fix in §0. So if you already had Entra users signing into the Web UI, have them sign into the Web UI once after deploying this migration, before connecting an MCP client. Connecting MCP first creates a fresh row keyed on oid and leaves the original stranded, with the memories in it invisible. Deployments that have never run Entra are unaffected.

The single-registration layout (making registration A the resource server too) also works and needs no migration, but you lose audience separation between the Web UI and MCP.


8. Verification

Run these in order; each one isolates a different failure.

1. The issuer is concrete, not templated.

curl -s "https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration" \
  | jq '{issuer, jwks_uri}'

issuer must be a GUID URL, not {tenantid}. Copy it verbatim into OIDC_ISSUER.

2. Our metadata advertises the right scopes.

curl -s https://memory.example.com/.well-known/oauth-protected-resource | jq

scopes_supported must contain api://<client-id-of-B>/access_as_user (not aud-…), plus offline_access if you enabled it. authorization_servers[0] must be the tenant-specific v2.0 issuer.

3. Decode a real access token. This is the only step that proves the aud/iss/version questions. Get a token (from Claude Code's stored credentials, or by running the flow manually) and inspect the payload:

TOKEN='eyJ...'
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq '{ver, iss, aud, sub, oid, tid, scp, groups}'

Expected:

Field Expected value If wrong
ver "2.0" api.requestedAccessTokenVersion is not 2 (§4a)
iss https://login.microsoftonline.com/<tenant-id>/v2.0 v1 token, or common authority (§2, §4a)
aud <client-id-of-B>, a bare GUID set OIDC_AUDIENCE to whatever is actually here (§5)
scp access_as_user the scope wasn't requested or consented (§9)
groups array of GUIDs, or absent see §10

4. Confirm the 401 reason when something is still wrong — the MCP endpoint names the failing claim:

curl -s -i -H "Authorization: Bearer $TOKEN" https://memory.example.com/api/mcp | head -20

Look at WWW-Authenticate: error_description="claim invalid: iss" → §2/§4a. "claim invalid: aud" → §5. "verification failed" → JWKS could not be fetched, §0.


A custom scope is not inherently admin-only. Two levers decide it:

  • The scope's own setting. "Who can consent?" on the scope — Admins and users lets users self-consent; Admins only always requires an admin. Select "Admins and users" (§4b). Microsoft's docs don't state which radio the portal preselects, so set it deliberately rather than assuming.
  • The tenant's user-consent policy. The default is "users are allowed to consent to applications for permissions that don't require administrator consent", but many tenants tighten this to "verified publishers only" or disable user consent entirely, in which case an admin must consent regardless of the scope setting.

Admin consent becomes mandatory if: the scope is "Admins only"; the tenant policy restricts user consent; or — the one that catches people — the enterprise application is set to require user assignment, which forces admin consent even when tenant policy would otherwise permit self-consent.

To grant it: App registrations → the client app (the one Claude Code uses, not the API) → API permissionsGrant admin consent for <tenant>. The button is disabled if you aren't an admin or no permissions are configured.

Alternatively, suppress the prompt entirely with pre-authorization: on registration B, Expose an API → Authorized client applications → Add a client application, select the MCP client ID and tick access_as_user. Consent is then implicit. Reasonable here, since you control both registrations.

If you prefer the URL form of admin consent, note it needs the /v2.0/ segment and must not use common:

https://login.microsoftonline.com/<tenant-id>/v2.0/adminconsent
  ?client_id=<OIDC_CLIENT_ID_MCP>
  &scope=api://<client-id-of-B>/access_as_user
  &redirect_uri=https://memory.example.com/auth/cli-callback
  &state=12345

10. Groups

Group memberships gate access to shared projects (readableProjectIds / canWriteProject in apps/web/lib/mcp/tools.ts and apps/web/lib/memory-mutations.ts). Entra's groups claim needs care on two independent axes: what the values look like, and what happens when the claim goes missing.

10a. By default you get GUIDs, not names

Entra emits groups as a JSON array of group object-ID GUIDs. Not display names. apps/web/lib/auth/sync-groups.ts stores whatever strings arrive verbatim and makes no attempt to resolve them, so the Web UI will list memberships like 8f4c…-b21a and your project ACLs must be written against those GUIDs.

There is a supported way to get display names for cloud-only groups — contrary to the older note in sync-groups.ts, which says names are available only for AD-synced groups. That was true of the sam_account_name family (those attributes genuinely exist only on groups synced from on-premises AD via Entra Connect 1.2.70+), but Entra also has cloud_displayname:

App registrations → <B> → Token configuration → Add groups claim, select Groups assigned to the application, then tick the cloud-only display name option. In the manifest:

"groupMembershipClaims": "ApplicationGroup",
"optionalClaims": {
  "accessToken": [
    { "name": "groups",
      "additionalProperties": ["cloud_displayname"] }
  ],
  "idToken": [
    { "name": "groups",
      "additionalProperties": ["cloud_displayname"] }
  ]
}

Both collections matter: idToken feeds the Web UI sign-in path (auth.ts), accessToken feeds the MCP path (jwt.ts). Configure only one and the two surfaces disagree about your group names.

Constraints, all of them load-bearing:

  • cloud_displayname only works with groupMembershipClaims: "ApplicationGroup". Microsoft's stated reason is that group display names aren't unique, so they only emit them for groups explicitly assigned to the application.
  • Only directly assigned groups appear. Nested groups are excluded.
  • Assign the groups under Enterprise applications → <B> → Users and groups, or they simply won't be emitted.
  • Microsoft's published cloud_displayname examples cover idToken and saml2Token; we found no official example pairing it with accessToken. It is a documented-valid collection, but decode a real access token (§8) and confirm groups contains names before relying on it rather than assuming symmetry.

A claims-mapping policy cannot fix this instead: groups is a restricted claim, so its data source can't be changed and no transformation applies.

If none of this appeals, Microsoft's own recommendation is to use app roles rather than groups for authorization — but this app reads groups, so that would need a code change.

10b. Groups overage — now refused rather than obeyed

This was the sharpest edge in this document. It is now a hard failure with a readable message, which is a much better outcome than what it used to do.

Past a limit, Entra stops emitting groups altogether and substitutes an overage indicator:

Token Limit What you get past it
JWT (access + ID) 200 groups groups absent; _claim_names / _claim_sources present
SAML 150 groups same
Implicit flow 5 groups "hasgroups": true

The indicator looks like this — note it is not a truncated list, it is no list at all:

{
  "_claim_names":   { "groups": "src1" },
  "_claim_sources": { "src1": { "endpoint": "https://graph.windows.net/…" } }
}

(That endpoint is an Azure AD Graph URL, not Microsoft Graph. Don't follow it; Microsoft says to construct https://graph.microsoft.com/v1.0/users/{id}/getMemberObjects yourself. Limits are inclusive of nested groups.)

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_WEB <A>Overview → Application (client) ID 1111…-aaaa
OIDC_CLIENT_SECRET_WEB <A>Certificates & secrets → Client secrets → Value (not Secret ID; shown once) abc8Q~…
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 they're a brand new user to the service."

Single-tenant is the supported configuration.