Skip to content

Re-run OIDC discovery after token exchange for Microsoft Entra ID#335

Open
guustgoossens wants to merge 2 commits into
get-convex:mainfrom
guustgoossens:fix/microsoft-entra-id-multi-tenant-issuer
Open

Re-run OIDC discovery after token exchange for Microsoft Entra ID#335
guustgoossens wants to merge 2 commits into
get-convex:mainfrom
guustgoossens:fix/microsoft-entra-id-multi-tenant-issuer

Conversation

@guustgoossens
Copy link
Copy Markdown

Fix Microsoft Entra ID multi-tenant issuer validation

Summary

@convex-dev/auth rejects every Microsoft Entra ID sign-in whenever the Entra
app is registered against a multi-tenant authority (/common,
/organizations, or /consumers). The callback fails inside
processAuthorizationCodeResponse with an unexpected JWT "iss" (issuer) claim value error, because the authorization-server metadata still claims
https://login.microsoftonline.com/common/v2.0 as the issuer while the
id_token Microsoft returns is signed with the per-tenant issuer (e.g.
https://login.microsoftonline.com/<real-tenant-guid>/v2.0).

This PR ports the per-tenant re-discovery step that @auth/core runs after
the token exchange (its conformInternal block in
packages/core/src/lib/actions/callback/oauth/callback.ts) into convex-auth's
own handleOAuth, so the bug is fixed in convex-auth's callback flow
independently of next-auth's release schedule.

Root cause

Microsoft's discovery endpoint at
https://login.microsoftonline.com/{authority}/v2.0/.well-known/openid-configuration
returns the literal string {tenantid} in the issuer field for the shared
multi-tenant authorities (see
MicrosoftDocs/azure-docs#113944).
The @auth/core Microsoft Entra ID provider rewrites that placeholder via a
customFetch hook so that the initial discovery succeeds, but the real
per-tenant issuer that ends up inside the signed id_token is only knowable
after the token exchange, when we can read the tid claim.

@auth/core's handleOAuth handles this by re-running OIDC discovery for the
per-tenant authority once it has the tid, before calling
processAuthorizationCodeResponse. @convex-dev/auth's handleOAuth was
ported from a snapshot of @auth/core that pre-dates this block (the comment
at the top of the file pins commit 5af1f30a), so it skipped that step
entirely — which is why issuer validation tripped here.

Changes

src/server/oauth/callback.ts

  • Allow as (the authorization server metadata used by oauth4webapi) to be
    reassigned later in handleOAuth.
  • After the token-exchange request, if the provider opted into core
    conformance (i.e. it set the @auth/core internal conformInternal symbol),
    decode the returned id_token, derive the per-tenant authority from tid,
    re-fetch the discovery document for that authority, and replace as
    before processAuthorizationCodeResponse runs.
  • Because @auth/core's conformInternal symbol is marked @internal and
    is not listed in the package's exports map (so importing it from
    @auth/core/lib/symbols.js breaks under bundlers that enforce package
    exports — including Vite/vitest), the new hasConformInternalSymbol helper
    detects the symbol structurally by description (Symbol("conform-internal"))
    on the provider object instead of importing it. This keeps the gate
    functionally identical to upstream's symbol-based check without taking on
    a fragile deep import.
  • The re-discovery uses a small inline customFetch wrapper that rewrites the
    {tenantid} placeholder in the discovery document's issuer field with the
    real tid from the ID token. Bypassing provider[customFetch] here is
    deliberate: the upstream provider's hook captures config.issuer at
    construction time (/common/v2.0), so it would rewrite {tenantid} to
    common and we'd still hit an issuer mismatch. By doing the rewrite
    locally with the real tid, this fix works against the currently shipped
    @auth/core@0.37.x and does not require waiting for the parallel
    nextauthjs/next-auth fix at fix(providers): Microsoft Entra ID multi-tenant issuer validation nextauthjs/next-auth#13440 to release.

test/convex/auth.ts

  • Register the Microsoft Entra ID provider with a no-op profile callback so
    the tests don't try to hit https://graph.microsoft.com/... for the user's
    profile photo.

test/convex/microsoftEntra.test.ts (new)

Two tests exercise the new code path through the real Convex HTTP callback
flow, with fetch stubbed to simulate Microsoft's discovery + token endpoints
and a signed id_token produced from the same RSA key pair already used by
the test suite:

  • accepts an id_token whose issuer matches the tid claim — drives a full
    multi-tenant sign-in through /api/auth/signin/microsoft-entra-id and the
    callback handler; asserts the re-discovery endpoint for the real tenant
    GUID is hit and the callback redirects back to the app with an auth code.
  • rejects an id_token whose issuer does not match the tid claim
    same flow, but signs the id_token with a different (spoofed) issuer than
    the tid claim; asserts the callback does not succeed (oauth4webapi's
    issuer validation rejects the token, the catch in signIn redirects without
    a code).

The existing GitHub oauth.test.ts suite continues to pass, exercising the
non-Microsoft path unchanged.

Before / after

Before:

[microsoft-entra-id callback] OperationProcessingError:
  unexpected JWT "iss" (issuer) claim value
  expected: "https://login.microsoftonline.com/common/v2.0"
  actual:   "https://login.microsoftonline.com/<tenant-guid>/v2.0"

After: multi-tenant sign-ins complete successfully; spoofed-issuer tokens
remain rejected by processAuthorizationCodeResponse.

Related

Test plan

  • cd test && npm run test:once — 14 files / 42 tests pass (including 2
    new Microsoft Entra ID tests).
  • npx tsc --project tsconfig.server.json --noEmit — clean.
  • npx eslint src — clean.
  • cd test && npm run lint — clean.
  • Existing GitHub oauth.test.ts continues to pass (non-Microsoft
    regression coverage).

@convex-dev/auth rejected every Microsoft Entra ID sign-in whenever the
Entra app was registered against a multi-tenant authority (/common,
/organizations, /consumers). The callback failed inside
processAuthorizationCodeResponse with an "unexpected JWT iss claim
value" error because the authorization-server metadata still claimed
.../common/v2.0 as the issuer while the id_token was signed with the
per-tenant issuer (.../<real-tenant-guid>/v2.0).

Port the post-token-exchange re-discovery that @auth/core's handleOAuth
runs for providers carrying the conformInternal symbol
(packages/core/src/lib/actions/callback/oauth/callback.ts). After the
token exchange, decode the id_token, derive the per-tenant authority
from its tid claim, re-fetch the discovery document for that authority
and replace the AuthorizationServer metadata before
processAuthorizationCodeResponse runs.

Because @auth/core's conformInternal symbol is marked @internal and is
not listed in the package's exports map, importing it from
@auth/core/lib/symbols.js breaks under bundlers that enforce package
exports (Vite/vitest). Detect the symbol structurally by description on
the provider object instead, mirroring upstream's gate without a
fragile deep import.

The re-discovery uses an inline customFetch wrapper that rewrites the
{tenantid} placeholder in the discovery document's issuer field with
the real tid. We deliberately bypass provider[customFetch] here because
the upstream Microsoft Entra ID provider's hook captures config.issuer
at construction time (/common/v2.0) and would rewrite {tenantid} to
"common", causing the same mismatch this PR fixes. Doing the rewrite
locally makes the fix work against the @auth/core version currently
pinned and independent of any upstream release.

Add a test/convex/microsoftEntra.test.ts suite covering the
re-discovery happy path and a spoofed-issuer rejection, and register
the Microsoft Entra ID provider in test/convex/auth.ts with a no-op
profile callback so tests don't hit graph.microsoft.com.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

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

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: get-convex/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bf2c28a1-3c10-4262-876f-0e9452176f56

📥 Commits

Reviewing files that changed from the base of the PR and between 82ba14a and c695dbe.

📒 Files selected for processing (1)
  • src/server/oauth/callback.ts

📝 Walkthrough

Walkthrough

This PR implements Microsoft Entra ID multi-tenant support by adding tenant-specific issuer re-discovery to the OAuth callback handler. After token exchange, the callback detects providers with an internal conformance marker, decodes the id_token to extract the tenant ID (tid), rewrites discovery metadata placeholders, and re-processes the authorization server configuration with tenant-specific issuer values. The test suite configures the provider and validates that callbacks succeed when the id_token issuer matches the decoded tenant ID and fail otherwise.

Sequence Diagram

sequenceDiagram
  participant Client
  participant OAuth Callback Handler
  participant Token Service
  participant Discovery Service
  participant JWKS Service
  
  Client->>OAuth Callback Handler: POST callback with code
  OAuth Callback Handler->>Token Service: exchange code for id_token
  Token Service-->>OAuth Callback Handler: id_token response
  OAuth Callback Handler->>OAuth Callback Handler: decode id_token to extract tid
  OAuth Callback Handler->>Discovery Service: fetch discovery for tenant authority
  Discovery Service-->>OAuth Callback Handler: discovery metadata
  OAuth Callback Handler->>OAuth Callback Handler: rewrite issuer with tid
  OAuth Callback Handler->>OAuth Callback Handler: re-process discovery
  OAuth Callback Handler->>JWKS Service: fetch JWKS with tenant issuer
  JWKS Service-->>OAuth Callback Handler: JWKS
  OAuth Callback Handler-->>Client: redirect with code or error
Loading

Possibly related issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: re-running OIDC discovery after token exchange for Microsoft Entra ID to fix the multi-tenant issuer validation issue.
Description check ✅ Passed The description comprehensively explains the bug, root cause, implementation details, and testing approach, demonstrating full alignment with the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/convex/microsoftEntra.test.ts (1)

288-347: ⚡ Quick win

Add a single-tenant authority regression test.

Current coverage is centered on /common. Add one case where initial discovery is already /{tenant-guid}/v2.0 and callback still succeeds; this guards against tenant-segment replacement regressions in re-discovery logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/convex/microsoftEntra.test.ts` around lines 288 - 347, Add a new test in
the "microsoft entra id multi-tenant issuer re-discovery" suite that covers the
single-tenant authority flow: call setupEnv(), create t via convexTest(schema)
and ctx via driveSignInUpTo(t), then invoke driveCallback(t, ctx, ...) with
idTokenIssuerTenantId and idTokenTidClaim both set to TENANT_ID but ensure the
initial discovery URL used by the mocked fetch is already the tenant-specific
authority (i.e., `/${TENANT_ID}/v2.0/.well-known/openid-configuration`); assert
the fetchMock requestedUrls contains that tenant-specific discovery URL and that
the returned callbackResponse.status is 302 and the Location header contains a
non-null code query param (similar assertions to the existing first test but
with initial discovery set to the tenant GUID instead of /common).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/oauth/callback.ts`:
- Around line 223-226: The regex and replace logic for extracting and swapping
the tenant id can break GUIDs with hyphens and corrupt the issuer; update
tenantRe to match any non-slash segment (e.g.
/microsoftonline\.com\/([^\/]+)\/v2\.0/) and then build the new issuer
deterministically instead of blind string replacement—either use
as.issuer.replace(tenantRe, `microsoftonline.com/${tid}/v2.0`) or parse new
URL(as.issuer) and set its pathname to `/[tid]/v2.0`; adjust references to
tenantRe, tenantId, issuer, as.issuer and tid accordingly.

---

Nitpick comments:
In `@test/convex/microsoftEntra.test.ts`:
- Around line 288-347: Add a new test in the "microsoft entra id multi-tenant
issuer re-discovery" suite that covers the single-tenant authority flow: call
setupEnv(), create t via convexTest(schema) and ctx via driveSignInUpTo(t), then
invoke driveCallback(t, ctx, ...) with idTokenIssuerTenantId and idTokenTidClaim
both set to TENANT_ID but ensure the initial discovery URL used by the mocked
fetch is already the tenant-specific authority (i.e.,
`/${TENANT_ID}/v2.0/.well-known/openid-configuration`); assert the fetchMock
requestedUrls contains that tenant-specific discovery URL and that the returned
callbackResponse.status is 302 and the Location header contains a non-null code
query param (similar assertions to the existing first test but with initial
discovery set to the tenant GUID instead of /common).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: get-convex/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ed3bf8fc-805a-44e6-b597-35385af59a99

📥 Commits

Reviewing files that changed from the base of the PR and between 94fcfd9 and 82ba14a.

📒 Files selected for processing (3)
  • src/server/oauth/callback.ts
  • test/convex/auth.ts
  • test/convex/microsoftEntra.test.ts

Comment thread src/server/oauth/callback.ts Outdated
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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant