diff --git a/packages/core/src/providers/microsoft-entra-id.ts b/packages/core/src/providers/microsoft-entra-id.ts index 692364c7fc..a11790d3ea 100644 --- a/packages/core/src/providers/microsoft-entra-id.ts +++ b/packages/core/src/providers/microsoft-entra-id.ts @@ -356,6 +356,9 @@ export interface MicrosoftEntraIDProfile { * When the `issuer` parameter is omitted it will default to * `"https://login.microsoftonline.com/common/v2.0/"`. * This allows any Microsoft account (Personal, School or Work) to log in. + * The `/organizations/` and `/consumers/` endpoints are also supported and + * the issuer of the returned ID token is validated against the tenant that + * actually authenticated the user. * * ```typescript * import MicrosoftEntraID from "@auth/core/providers/microsoft-entra-id" @@ -484,8 +487,11 @@ export default function MicrosoftEntraID( if (url.pathname.endsWith(".well-known/openid-configuration")) { const response = await fetch(...args) const json = await response.clone().json() - const tenantRe = /microsoftonline\.com\/(\w+)\/v2\.0/ - const tenantId = config.issuer?.match(tenantRe)?.[1] ?? "common" + const tenantRe = /microsoftonline\.com\/([^/]+)\/v2\.0/ + const tenantId = + url.href.match(tenantRe)?.[1] ?? + config.issuer?.match(tenantRe)?.[1] ?? + "common" const issuer = json.issuer.replace("{tenantid}", tenantId) return Response.json({ ...json, issuer }) } diff --git a/packages/core/test/providers/microsoft-entra-id.test.ts b/packages/core/test/providers/microsoft-entra-id.test.ts new file mode 100644 index 0000000000..a515098e76 --- /dev/null +++ b/packages/core/test/providers/microsoft-entra-id.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import MicrosoftEntraID from "../../src/providers/microsoft-entra-id" +import { customFetch } from "../../src/lib/symbols" + +const DISCOVERY_BODY = { + issuer: "https://login.microsoftonline.com/{tenantid}/v2.0", + authorization_endpoint: + "https://login.microsoftonline.com/{tenantid}/oauth2/v2.0/authorize", + token_endpoint: + "https://login.microsoftonline.com/{tenantid}/oauth2/v2.0/token", + userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo", + jwks_uri: "https://login.microsoftonline.com/{tenantid}/discovery/v2.0/keys", +} + +function mockDiscovery() { + return vi.fn(async () => Response.json(DISCOVERY_BODY)) +} + +describe("MicrosoftEntraID provider", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockDiscovery()) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("defaults to the /common multi-tenant issuer", () => { + const provider = MicrosoftEntraID({ + clientId: "id", + clientSecret: "secret", + }) + expect(provider.id).toBe("microsoft-entra-id") + expect(provider.type).toBe("oidc") + expect(provider.options?.issuer).toBe( + "https://login.microsoftonline.com/common/v2.0" + ) + }) + + it("rewrites the discovery issuer using the tenant from the request URL", async () => { + const provider = MicrosoftEntraID({ + clientId: "id", + clientSecret: "secret", + }) + + const tid = "8a2a7b4d-1234-5678-9abc-def012345678" + const url = `https://login.microsoftonline.com/${tid}/v2.0/.well-known/openid-configuration` + const response = await provider[customFetch]!(url) + const json = await response.json() + + expect(json.issuer).toBe(`https://login.microsoftonline.com/${tid}/v2.0`) + }) + + it("rewrites the discovery issuer for the /common endpoint", async () => { + const provider = MicrosoftEntraID({ + clientId: "id", + clientSecret: "secret", + }) + + const url = + "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" + const response = await provider[customFetch]!(url) + const json = await response.json() + + expect(json.issuer).toBe("https://login.microsoftonline.com/common/v2.0") + }) + + it("rewrites the discovery issuer for /organizations and /consumers endpoints", async () => { + const provider = MicrosoftEntraID({ + clientId: "id", + clientSecret: "secret", + }) + + for (const tenant of ["organizations", "consumers"]) { + const url = `https://login.microsoftonline.com/${tenant}/v2.0/.well-known/openid-configuration` + const response = await provider[customFetch]!(url) + const json = await response.json() + expect(json.issuer).toBe( + `https://login.microsoftonline.com/${tenant}/v2.0` + ) + } + }) + + it("preserves a configured single-tenant issuer through discovery", async () => { + const tid = "8a2a7b4d-1234-5678-9abc-def012345678" + const provider = MicrosoftEntraID({ + clientId: "id", + clientSecret: "secret", + issuer: `https://login.microsoftonline.com/${tid}/v2.0`, + }) + + const url = `https://login.microsoftonline.com/${tid}/v2.0/.well-known/openid-configuration` + const response = await provider[customFetch]!(url) + const json = await response.json() + + expect(json.issuer).toBe(`https://login.microsoftonline.com/${tid}/v2.0`) + }) + + it("passes non-discovery requests through to fetch unchanged", async () => { + const provider = MicrosoftEntraID({ + clientId: "id", + clientSecret: "secret", + }) + + const fetchMock = vi.fn(async () => new Response("token")) + vi.stubGlobal("fetch", fetchMock) + + const url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + await provider[customFetch]!(url) + + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock.mock.calls[0][0]).toBe(url) + }) +})