From 80120ae67f589d1d5736d227447446e11ffeef73 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Thu, 5 Feb 2026 01:38:22 +0900 Subject: [PATCH] fix(microsoft): add `verifyIdToken` support for Microsoft Entra ID provider (#7795) --- packages/better-auth/src/social.test.ts | 377 ++++++++++++++++++ .../social-providers/microsoft-entra-id.ts | 85 +++- 2 files changed, 461 insertions(+), 1 deletion(-) diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts index a4487663dd..709facc897 100644 --- a/packages/better-auth/src/social.test.ts +++ b/packages/better-auth/src/social.test.ts @@ -3,10 +3,12 @@ import { runWithEndpointContext } from "@better-auth/core/context"; import { refreshAccessToken } from "@better-auth/core/oauth2"; import type { GoogleProfile, + MicrosoftEntraIDProfile, VercelProfile, } from "@better-auth/core/social-providers"; import { betterFetch } from "@better-fetch/fetch"; import Database from "better-sqlite3"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { OAuth2Server } from "oauth2-mock-server"; @@ -1502,3 +1504,378 @@ describe("Vercel Provider", async () => { }); }); }); + +describe("Microsoft Provider", async () => { + const rsaKeyPair = await generateKeyPair("RS256"); + const rsaJwk = await exportJWK(rsaKeyPair.publicKey); + const msKid = "test-microsoft-kid"; + rsaJwk.kid = msKid; + rsaJwk.alg = "RS256"; + rsaJwk.use = "sig"; + + it("should support verifyIdToken with custom function", async () => { + const microsoftProfile: Partial = { + sub: "ms-user-123", + email: "msuser@outlook.com", + name: "Microsoft User", + oid: "ms-oid-123", + tid: "ms-tenant-123", + }; + + mswServer.use( + http.post( + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + async () => { + const idToken = await signJWT(microsoftProfile, DEFAULT_SECRET); + return HttpResponse.json({ + access_token: "ms_access_token", + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + }); + }, + ), + http.get("https://graph.microsoft.com/v1.0/me/photos/*", async () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + const { client, cookieSetter } = await getTestInstance( + { + socialProviders: { + microsoft: { + clientId: "test-ms-client", + clientSecret: "test-ms-secret", + verifyIdToken: async () => true, + }, + }, + }, + { + disableTestUser: true, + }, + ); + + const headers = new Headers(); + const signInRes = await client.signIn.social({ + provider: "microsoft", + callbackURL: "/callback", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + + await client.$fetch("/callback/microsoft", { + query: { + state, + code: "ms_test_code", + }, + headers, + method: "GET", + onError(context) { + cookieSetter(headers)(context as any); + }, + }); + + const session = await client.getSession({ + fetchOptions: { + headers, + }, + }); + + expect(session.data?.user.email).toBe("msuser@outlook.com"); + expect(session.data?.user.name).toBe("Microsoft User"); + }); + + it("should verify id token using JWKS endpoint", async () => { + const microsoftProfile: Partial = { + sub: "ms-jwks-user-456", + email: "jwksuser@outlook.com", + name: "JWKS User", + oid: "ms-oid-456", + tid: "ms-tenant-456", + }; + + const idToken = await new SignJWT( + microsoftProfile as unknown as Record, + ) + .setProtectedHeader({ alg: "RS256", kid: msKid }) + .setIssuedAt() + .setAudience("test-ms-client-jwks") + .setExpirationTime("1h") + .sign(rsaKeyPair.privateKey); + + mswServer.use( + http.get( + "https://login.microsoftonline.com/common/discovery/v2.0/keys", + async () => { + return HttpResponse.json({ + keys: [rsaJwk], + }); + }, + ), + http.post( + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + async () => { + return HttpResponse.json({ + access_token: "ms_access_token_jwks", + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + }); + }, + ), + http.get("https://graph.microsoft.com/v1.0/me/photos/*", async () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + const { client, cookieSetter } = await getTestInstance( + { + socialProviders: { + microsoft: { + clientId: "test-ms-client-jwks", + clientSecret: "test-ms-secret-jwks", + }, + }, + }, + { + disableTestUser: true, + }, + ); + + const headers = new Headers(); + const signInRes = await client.signIn.social({ + provider: "microsoft", + callbackURL: "/callback", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + + await client.$fetch("/callback/microsoft", { + query: { + state, + code: "ms_test_code_jwks", + }, + headers, + method: "GET", + onError(context) { + cookieSetter(headers)(context as any); + }, + }); + + const session = await client.getSession({ + fetchOptions: { + headers, + }, + }); + + expect(session.data?.user.email).toBe("jwksuser@outlook.com"); + expect(session.data?.user.name).toBe("JWKS User"); + }); + + it("should support id token sign in", async () => { + const microsoftProfile: Partial = { + sub: "ms-id-token-user-789", + email: "id-tokenuser@outlook.com", + name: "IdToken User", + oid: "ms-oid-789", + tid: "ms-tenant-789", + }; + + const idToken = await new SignJWT( + microsoftProfile as unknown as Record, + ) + .setProtectedHeader({ alg: "RS256", kid: msKid }) + .setIssuedAt() + .setAudience("test-ms-client-id-token") + .setExpirationTime("1h") + .sign(rsaKeyPair.privateKey); + + mswServer.use( + http.get( + "https://login.microsoftonline.com/common/discovery/v2.0/keys", + async () => { + return HttpResponse.json({ + keys: [rsaJwk], + }); + }, + ), + http.get("https://graph.microsoft.com/v1.0/me/photos/*", async () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + const { client } = await getTestInstance( + { + socialProviders: { + microsoft: { + clientId: "test-ms-client-id-token", + clientSecret: "test-ms-secret-id-token", + }, + }, + }, + { + disableTestUser: true, + }, + ); + + const res = await client.signIn.social({ + provider: "microsoft", + callbackURL: "/callback", + idToken: { + token: idToken, + }, + }); + + expect(res.data).toBeDefined(); + expect(res.data!.redirect).toBe(false); + const data = res.data as { + token: string; + user: { email: string; name: string }; + }; + expect(data.token).toBeDefined(); + expect(data.user.email).toBe("id-tokenuser@outlook.com"); + expect(data.user.name).toBe("IdToken User"); + }); + + it("should return false when disableIdTokenSignIn is true", async () => { + const microsoftProfile: Partial = { + sub: "ms-disabled-user", + email: "disabled@outlook.com", + name: "Disabled User", + }; + + const idToken = await new SignJWT( + microsoftProfile as unknown as Record, + ) + .setProtectedHeader({ alg: "RS256", kid: msKid }) + .setIssuedAt() + .setAudience("test-ms-client-disabled") + .setExpirationTime("1h") + .sign(rsaKeyPair.privateKey); + + mswServer.use( + http.get( + "https://login.microsoftonline.com/common/discovery/v2.0/keys", + async () => { + return HttpResponse.json({ + keys: [rsaJwk], + }); + }, + ), + ); + + const { client } = await getTestInstance( + { + socialProviders: { + microsoft: { + clientId: "test-ms-client-disabled", + clientSecret: "test-ms-secret-disabled", + disableIdTokenSignIn: true, + }, + }, + }, + { + disableTestUser: true, + }, + ); + + const res = await client.signIn.social({ + provider: "microsoft", + callbackURL: "/callback", + idToken: { + token: idToken, + }, + }); + + expect(res.error?.status).toBe(401); + }); + + it("should verify issuer when specific tenant is configured", async () => { + const tenantId = "my-specific-tenant-id"; + const microsoftProfile: Partial = { + sub: "ms-tenant-user", + email: "tenant@outlook.com", + name: "Tenant User", + }; + + const validToken = await new SignJWT( + microsoftProfile as unknown as Record, + ) + .setProtectedHeader({ alg: "RS256", kid: msKid }) + .setIssuedAt() + .setIssuer(`https://login.microsoftonline.com/${tenantId}/v2.0`) + .setAudience("test-ms-client-tenant") + .setExpirationTime("1h") + .sign(rsaKeyPair.privateKey); + + const wrongIssuerToken = await new SignJWT( + microsoftProfile as unknown as Record, + ) + .setProtectedHeader({ alg: "RS256", kid: msKid }) + .setIssuedAt() + .setIssuer("https://login.microsoftonline.com/wrong-tenant/v2.0") + .setAudience("test-ms-client-tenant") + .setExpirationTime("1h") + .sign(rsaKeyPair.privateKey); + + mswServer.use( + http.get( + `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`, + async () => { + return HttpResponse.json({ keys: [rsaJwk] }); + }, + ), + http.get( + "https://graph.microsoft.com/v1.0/me/photos/*", + async () => new HttpResponse(null, { status: 404 }), + ), + ); + + const { client } = await getTestInstance( + { + socialProviders: { + microsoft: { + clientId: "test-ms-client-tenant", + clientSecret: "test-ms-secret-tenant", + tenantId, + }, + }, + }, + { disableTestUser: true }, + ); + + const validRes = await client.signIn.social({ + provider: "microsoft", + callbackURL: "/callback", + idToken: { token: validToken }, + }); + expect(validRes.data).toBeDefined(); + expect(validRes.data!.redirect).toBe(false); + + const { client: client2 } = await getTestInstance( + { + socialProviders: { + microsoft: { + clientId: "test-ms-client-tenant", + clientSecret: "test-ms-secret-tenant", + tenantId, + }, + }, + }, + { disableTestUser: true }, + ); + + const invalidRes = await client2.signIn.social({ + provider: "microsoft", + callbackURL: "/callback", + idToken: { token: wrongIssuerToken }, + }); + expect(invalidRes.error?.status).toBe(401); + }); +}); diff --git a/packages/core/src/social-providers/microsoft-entra-id.ts b/packages/core/src/social-providers/microsoft-entra-id.ts index 40b8df4e49..baf44716c1 100644 --- a/packages/core/src/social-providers/microsoft-entra-id.ts +++ b/packages/core/src/social-providers/microsoft-entra-id.ts @@ -1,6 +1,7 @@ import { base64 } from "@better-auth/utils/base64"; import { betterFetch } from "@better-fetch/fetch"; -import { decodeJwt } from "jose"; +import { APIError } from "better-call"; +import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; import { logger } from "../env"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; import { @@ -174,6 +175,56 @@ export const microsoft = (options: MicrosoftOptions) => { tokenEndpoint, }); }, + async verifyIdToken(token, nonce) { + if (options.disableIdTokenSignIn) { + return false; + } + if (options.verifyIdToken) { + return options.verifyIdToken(token, nonce); + } + + try { + const { kid, alg: jwtAlg } = decodeProtectedHeader(token); + if (!kid || !jwtAlg) return false; + + const publicKey = await getMicrosoftPublicKey(kid, tenant, authority); + const verifyOptions: { + algorithms: [string]; + audience: string; + maxTokenAge: string; + issuer?: string; + } = { + algorithms: [jwtAlg], + audience: options.clientId, + maxTokenAge: "1h", + }; + /** + * Issuer varies per user's tenant for multi-tenant endpoints, so only validate for specific tenants. + * @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints + */ + if ( + tenant !== "common" && + tenant !== "organizations" && + tenant !== "consumers" + ) { + verifyOptions.issuer = `${authority}/${tenant}/v2.0`; + } + const { payload: jwtClaims } = await jwtVerify( + token, + publicKey, + verifyOptions, + ); + + if (nonce && jwtClaims.nonce !== nonce) { + return false; + } + + return true; + } catch (error) { + logger.error("Failed to verify ID token:", error); + return false; + } + }, async getUserInfo(token) { if (options.getUserInfo) { return options.getUserInfo(token); @@ -257,3 +308,35 @@ export const microsoft = (options: MicrosoftOptions) => { options, } satisfies OAuthProvider; }; + +export const getMicrosoftPublicKey = async ( + kid: string, + tenant: string, + authority: string, +) => { + const { data } = await betterFetch<{ + keys: Array<{ + kid: string; + alg: string; + kty: string; + use: string; + n: string; + e: string; + x5c?: string[]; + x5t?: string; + }>; + }>(`${authority}/${tenant}/discovery/v2.0/keys`); + + if (!data?.keys) { + throw new APIError("BAD_REQUEST", { + message: "Keys not found", + }); + } + + const jwk = data.keys.find((key) => key.kid === kid); + if (!jwk) { + throw new Error(`JWK with kid ${kid} not found`); + } + + return await importJWK(jwk, jwk.alg); +};