Skip to content

fix(providers): Microsoft Entra ID multi-tenant issuer validation#13440

Open
guustgoossens wants to merge 1 commit into
nextauthjs:mainfrom
guustgoossens:fix/microsoft-entra-id-multi-tenant-issuer
Open

fix(providers): Microsoft Entra ID multi-tenant issuer validation#13440
guustgoossens wants to merge 1 commit into
nextauthjs:mainfrom
guustgoossens:fix/microsoft-entra-id-multi-tenant-issuer

Conversation

@guustgoossens
Copy link
Copy Markdown

☕️ Reasoning

The Microsoft Entra ID provider has been documented as supporting
multi-tenant sign-in (/common, /organizations, /consumers) since
the v2 endpoint was introduced, but in practice the multi-tenant
configurations fail discovery validation right before the ID token
would be accepted.

Background

Microsoft's OIDC discovery document is templated — for every tenant
URL, the response is:

{
  "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0",
  ...
}

The [customFetch] hook in microsoft-entra-id.ts substitutes the
{tenantid} placeholder so the document can be processed by
oauth4webapi. Today that substitution reads the tenant from
config.issuer only, defaulting to "common":

const tenantRe = /microsoftonline\.com\/(\w+)\/v2\.0/
const tenantId = config.issuer?.match(tenantRe)?.[1] ?? "common"
const issuer = json.issuer.replace("{tenantid}", tenantId)

Why this breaks multi-tenant

handleOAuth() already understands that the issuer on the returned ID
token contains the actual tenant from the user that authenticated,
and re-runs discovery against that tenant
(callback.ts#L188-L224):

const { tid } = decodeJwt(responseJson.id_token)
if (typeof tid === "string") {
  const issuer = new URL(as.issuer.replace(tenantId, tid))
  const discoveryResponse = await o.discoveryRequest(issuer, {
    [o.customFetch]: provider[customFetch],
  })
  as = await o.processDiscoveryResponse(issuer, discoveryResponse)
}

That second discovery is issued against
https://login.microsoftonline.com/<actual-tid>/v2.0/.well-known/openid-configuration,
but the customFetch hook ignores the request URL and rewrites the
response issuer to https://login.microsoftonline.com/common/v2.0
(because config.issuer still points at /common).
processDiscoveryResponse then throws — the returned issuer does not
match the expected issuer URL it was asked to discover, so the ID token
is never validated.

A second, smaller bug: the regex \w+ does not match hyphens, so a
configured single-tenant issuer set to a real tenant GUID (e.g.
8a2a7b4d-1234-5678-9abc-def012345678) fails to match too and silently
falls back to "common". The relaxed [^/]+ covers both shapes.

Fix

Derive the tenant from the request URL first, so each discovery
response identifies the tenant it was actually fetched for. The
single-tenant path is unchanged. Existing re-discovery in
callback.ts now works as intended.

Before / After

Scenario Configuration Before After
Multi-tenant default issuer unset Discovery re-run fails with "response" body "issuer" property does not match the expected value ID token accepted, iss matches per-tenant issuer
/organizations issuer: ".../organizations/v2.0" Same as above Works
/consumers issuer: ".../consumers/v2.0" Same as above Works
Single-tenant GUID issuer: ".../<tenant-guid>/v2.0" Worked (only because the re-discovered URL happened to match; the regex would silently fall back to "common" if the customFetch path is exercised twice) Works, regex now matches GUIDs explicitly

Reproduction

  1. Configure MicrosoftEntraID without an issuer (the documented
    multi-tenant configuration).
  2. Register the Entra application as multi-tenant.
  3. Sign in with a user from any tenant that is not the application's
    home tenant.
  4. The callback fails before the ID token is validated, with an OIDC
    discovery error about the issuer not matching the expected value.

Tests

Added packages/core/test/providers/microsoft-entra-id.test.ts:

  • Default /common discovery rewrites issuer to /common/v2.0.
  • A real tenant GUID URL rewrites issuer to that GUID.
  • /organizations and /consumers URLs rewrite to their respective
    tenant strings.
  • Single-tenant configuration (config.issuer set to a tenant GUID)
    is preserved through discovery.
  • Non-discovery requests pass through to fetch unchanged.

All 152 existing tests in @auth/core still pass:

Test Files  13 passed (13)
     Tests  152 passed (152)

🧢 Checklist

  • Documentation (JSDoc updated to mention /organizations and
    /consumers support and per-tenant issuer validation)
  • Tests
  • Ready to be merged

🎫 Affected issues

References:

📌 Resources

The Microsoft Entra ID discovery document is templated -- it returns
`issuer: "https://login.microsoftonline.com/{tenantid}/v2.0"` regardless
of which tenant URL is queried. The `[customFetch]` hook substitutes the
`{tenantid}` placeholder before handing the response to oauth4webapi.

The substitution was reading the tenant from `config.issuer` only,
defaulting to `"common"`. For the multi-tenant (`/common`,
`/organizations`, `/consumers`) configurations the existing re-discovery
in `handleOAuth()` (issued with the actual tenant from the `id_token`'s
`tid` claim) was then rewritten back to `common/v2.0`, which mismatches
the URL passed to `processDiscoveryResponse` and threw before the token
could be validated.

Read the tenant from the request URL first, so each discovery response
identifies the tenant it was actually fetched for. The single-tenant
path is unchanged. The regex is also relaxed from `\w+` to `[^/]+` so
that tenant GUIDs (which contain hyphens) are matched.

See: https://github.com/MicrosoftDocs/azure-docs/issues/113944
@guustgoossens guustgoossens requested a review from ThangHuuVu as a code owner May 28, 2026 12:21
@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
auth-docs Ready Ready Preview, Comment May 28, 2026 12:25pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
next-auth-docs Ignored Ignored Preview May 28, 2026 12:25pm

Request Review

@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

@guustgoossens is attempting to deploy a commit to the authjs Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added providers core Refers to `@auth/core` labels May 28, 2026
guustgoossens added a commit to guustgoossens/convex-auth that referenced this pull request Jun 2, 2026
The previous re-discovery used `(\w+)` to extract the tenant segment
from `as.issuer`, which doesn't match hyphenated tenant GUIDs like
`8a2a7b4d-1234-5678-9abc-def012345678` — so when a caller configured
the provider with a single-tenant authority, the match silently fell
back to `"common"` and `.replace(tenantId, tid)` was a no-op.

Switch to `[^/]+` so any URL path segment matches (authority names
and GUIDs alike), and rewrite via the segment-anchored regex instead
of a plain substring replace so we can't accidentally clobber an
unrelated portion of the issuer URL. Matches the upstream fix in
nextauthjs/next-auth#13440.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Refers to `@auth/core` providers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant