fix(microsoft): add verifyIdToken support for Microsoft Entra ID provider (#7795)

This commit is contained in:
Taesu
2026-02-23 23:33:17 +08:00
committed by Alex Yang
parent 6efdc3832c
commit 80120ae67f
2 changed files with 461 additions and 1 deletions
+377
View File
@@ -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<MicrosoftEntraIDProfile> = {
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<MicrosoftEntraIDProfile> = {
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<string, unknown>,
)
.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<MicrosoftEntraIDProfile> = {
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<string, unknown>,
)
.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<MicrosoftEntraIDProfile> = {
sub: "ms-disabled-user",
email: "disabled@outlook.com",
name: "Disabled User",
};
const idToken = await new SignJWT(
microsoftProfile as unknown as Record<string, unknown>,
)
.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<MicrosoftEntraIDProfile> = {
sub: "ms-tenant-user",
email: "tenant@outlook.com",
name: "Tenant User",
};
const validToken = await new SignJWT(
microsoftProfile as unknown as Record<string, unknown>,
)
.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<string, unknown>,
)
.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);
});
});
@@ -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);
};