diff --git a/.changeset/microsoft-jwks-alg-omission.md b/.changeset/microsoft-jwks-alg-omission.md new file mode 100644 index 0000000000..1e4b9bc5b0 --- /dev/null +++ b/.changeset/microsoft-jwks-alg-omission.md @@ -0,0 +1,5 @@ +--- +"@better-auth/core": patch +--- + +Fixed Microsoft Entra ID sign-in with an ID token (`signIn.social` with `idToken`, and linking an account with an ID token) rejecting every token. Microsoft publishes its signing keys without an `alg` field, which made verification fail. Those keys are now treated as RS256, and Microsoft ID tokens signed with any other algorithm are rejected. The redirect sign-in flow was not affected. diff --git a/packages/core/src/social-providers/microsoft-entra-id.test.ts b/packages/core/src/social-providers/microsoft-entra-id.test.ts index 55b3be6fac..6f5406b6e6 100644 --- a/packages/core/src/social-providers/microsoft-entra-id.test.ts +++ b/packages/core/src/social-providers/microsoft-entra-id.test.ts @@ -14,6 +14,7 @@ import { microsoft } from "./microsoft-entra-id"; const mockedBetterFetch = vi.mocked(betterFetch); const CLIENT_ID = "ms-app"; +const CLIENT_SECRET = "ms-secret"; const AUTHORITY = "https://login.microsoftonline.com"; const CONSUMER_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad"; const WORK_TENANT_ID = "11111111-2222-3333-4444-555555555555"; @@ -27,7 +28,6 @@ beforeEach(async () => { privateKey = keyPair.privateKey; publicJWK = await exportJWK(keyPair.publicKey); publicJWK.kid = KID; - publicJWK.alg = "RS256"; publicJWK.use = "sig"; mockedBetterFetch.mockReset(); @@ -278,3 +278,64 @@ describe("microsoft account subject", () => { expect(mockedBetterFetch).not.toHaveBeenCalled(); }); }); + +/** + * @see https://github.com/better-auth/better-auth/issues/11652 + */ +describe("microsoft id_token alg omission", () => { + it("verifies a token when Microsoft's JWKS keys omit alg", async () => { + const provider = microsoft({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + }); + + const token = await new SignJWT({ + sub: "user-123", + email: "user@example.com", + tid: WORK_TENANT_ID, + }) + .setProtectedHeader({ alg: "RS256", kid: KID }) + .setIssuer(`${AUTHORITY}/${WORK_TENANT_ID}/v2.0`) + .setAudience(CLIENT_ID) + .setExpirationTime("1h") + .setIssuedAt() + .sign(privateKey); + + const isValid = await verifyProviderIdToken(provider, token); + expect(isValid).toBe(true); + }); + + it("rejects a token signed with an algorithm other than RS256", async () => { + const ecKeyPair = await generateKeyPair("ES256", { extractable: true }); + const ecJWK = await exportJWK(ecKeyPair.publicKey); + const ecKid = "ms-test-es256-key"; + ecJWK.kid = ecKid; + ecJWK.alg = "ES256"; + ecJWK.use = "sig"; + + mockedBetterFetch.mockResolvedValueOnce({ + data: { keys: [ecJWK] }, + error: null, + } as Awaited>); + + const provider = microsoft({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + }); + + const token = await new SignJWT({ + sub: "user-123", + email: "user@example.com", + tid: WORK_TENANT_ID, + }) + .setProtectedHeader({ alg: "ES256", kid: ecKid }) + .setIssuer(`${AUTHORITY}/${WORK_TENANT_ID}/v2.0`) + .setAudience(CLIENT_ID) + .setExpirationTime("1h") + .setIssuedAt() + .sign(ecKeyPair.privateKey); + + const isValid = await verifyProviderIdToken(provider, token); + expect(isValid).toBe(false); + }); +}); diff --git a/packages/core/src/social-providers/microsoft-entra-id.ts b/packages/core/src/social-providers/microsoft-entra-id.ts index db0846b409..22d9e28da2 100644 --- a/packages/core/src/social-providers/microsoft-entra-id.ts +++ b/packages/core/src/social-providers/microsoft-entra-id.ts @@ -228,6 +228,7 @@ export const microsoft = (options: MicrosoftOptions) => { }, idToken: { jwks: (header) => getMicrosoftPublicKey(header.kid!, tenant, authority), + algorithms: ["RS256"], audience: options.clientId, maxTokenAge: "1h", /** @@ -370,7 +371,7 @@ export const getMicrosoftPublicKey = async ( const { data } = await betterFetch<{ keys: Array<{ kid: string; - alg: string; + alg?: string; kty: string; use: string; n: string; @@ -391,5 +392,6 @@ export const getMicrosoftPublicKey = async ( throw new Error(`JWK with kid ${kid} not found`); } - return await importJWK(jwk, jwk.alg); + // Microsoft's key set omits `alg`; Entra ID signs ID tokens with RS256. + return await importJWK(jwk, jwk.alg ?? "RS256"); };