fix(core): verify microsoft entra id tokens when jwks keys omit alg (#11663)

Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Md Sufiyan Bari
2026-10-14 17:04:35 +01:00
committed by GitHub
co-authored by Gustavo Valverde
parent e9d9921217
commit 60525e08fd
3 changed files with 71 additions and 3 deletions
@@ -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.
@@ -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<ReturnType<typeof betterFetch>>);
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);
});
});
@@ -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");
};