feat(social-providers): accept array of Client IDs for ID token audience (#9292)

This commit is contained in:
Gustavo Valverde
2026-04-22 09:02:56 +00:00
committed by GitHub
parent 4a180f0b0c
commit 4f373eed8a
12 changed files with 351 additions and 11 deletions
+23
View File
@@ -0,0 +1,23 @@
---
"better-auth": patch
"@better-auth/core": patch
---
Accept an array of Client IDs on providers that verify ID tokens by audience (Google, Apple, Microsoft Entra, Facebook, Cognito). The first entry is used for the authorization code flow; all entries are accepted when verifying an ID token's `aud` claim, so a single backend can serve Web, iOS, and Android clients with their platform-specific Client IDs.
```ts
socialProviders: {
google: {
clientId: [
process.env.GOOGLE_WEB_CLIENT_ID!,
process.env.GOOGLE_IOS_CLIENT_ID!,
process.env.GOOGLE_ANDROID_CLIENT_ID!,
],
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
}
```
Passing a single string keeps working; no migration needed.
Also exports `getPrimaryClientId` from `@better-auth/core/oauth2` for provider authors: it returns the primary Client ID (the raw string, or the entry at array index 0), paired with `clientSecret` for the authorization code flow. Providers now reject empty arrays, empty strings, and missing config at sign-in time instead of silently producing a malformed authorization URL. Google, Apple, and Facebook require both `clientId` and `clientSecret` because each of those providers mandates a client secret for their server-side code exchange. Microsoft Entra and Cognito only require `clientId`, since both support public-client flows with PKCE alone (no secret).
@@ -113,6 +113,38 @@ const data = await authClient.signIn.social({
Plugin](/docs/plugins/one-tap) guide.
</Callout>
### Cross-Platform Sign In (Web, iOS, Android)
Google issues a separate Client ID per platform in the same Google Cloud project. Pass an array to `clientId` to accept ID tokens from any of them. See [clientId](/docs/concepts/oauth#clientid) for the shared provider-option semantics.
```ts title="auth.ts"
socialProviders: {
google: {
clientId: [ // [!code highlight]
process.env.GOOGLE_WEB_CLIENT_ID as string, // [!code highlight]
process.env.GOOGLE_IOS_CLIENT_ID as string, // [!code highlight]
process.env.GOOGLE_ANDROID_CLIENT_ID as string, // [!code highlight]
], // [!code highlight]
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
}
```
Your mobile app signs in with the native Google SDK and forwards the ID token:
```ts title="Mobile app"
const { idToken, accessToken } = await GoogleSignin.signIn();
await authClient.signIn.social({
provider: "google",
idToken: { token: idToken, accessToken },
});
```
<Callout type="info">
The array only expands ID token audience verification. The authorization code flow still uses the first entry paired with the single `clientSecret` and `redirectURI`, so those cannot vary per platform within one provider block.
</Callout>
### Always ask to select an account
If you want to always ask the user to select an account, you pass the `prompt` parameter to the provider, setting it to `select_account`.
+26
View File
@@ -268,6 +268,32 @@ Example using a database hook:
## Provider Options
### clientId
The OAuth 2.0 Client ID issued by the provider.
For providers that verify ID tokens by audience (Google, Apple, Microsoft Entra, Facebook, Cognito), you can pass an array to accept tokens issued for any of the configured clients. The first entry is used when Better Auth drives the authorization code flow; all entries are accepted when verifying an ID token's `aud` claim. This enables cross-platform sign-in (Web, iOS, Android) with a single backend configuration, where each platform's native SDK issues tokens under its own Client ID.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: [
process.env.GOOGLE_WEB_CLIENT_ID as string,
process.env.GOOGLE_IOS_CLIENT_ID as string,
process.env.GOOGLE_ANDROID_CLIENT_ID as string,
],
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
},
});
```
All Client IDs must live in the same provider project so consent is shared. For providers that don't verify ID tokens by audience, only a single string is accepted.
### scope
The scope of the access request. For example, `email` or `profile`.
@@ -186,6 +186,9 @@ Most of the features of Better Auth will not work correctly.`,
checkEndpointConflicts(options, logger);
const cookies = getCookies(options);
const tables = getAuthTables(options);
// TODO(#9294): allow registering the same provider multiple times under
// distinct ids (e.g. `google:ios`, `google:android`) to support
// per-platform clientSecret/redirectURI in the authorization code flow.
const providers = (
await Promise.all(
(
+210
View File
@@ -994,6 +994,195 @@ describe("updateAccountOnSignIn", async () => {
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/4498
*/
describe("Google Provider — multiple client IDs", async () => {
const googleKeyPair = await generateKeyPair("RS256");
const googleJwk = await exportJWK(googleKeyPair.publicKey);
const googleKid = "test-google-kid";
googleJwk.kid = googleKid;
googleJwk.alg = "RS256";
googleJwk.use = "sig";
const webClientId = "123-web.googleusercontent.com";
const iosClientId = "456-ios.googleusercontent.com";
const androidClientId = "789-android.googleusercontent.com";
const signIdToken = (audience: string) =>
new SignJWT({
email: "mobile-user@example.com",
email_verified: true,
name: "Mobile User",
sub: "google-sub-999",
})
.setProtectedHeader({ alg: "RS256", kid: googleKid })
.setIssuedAt()
.setIssuer("https://accounts.google.com")
.setAudience(audience)
.setExpirationTime("1h")
.sign(googleKeyPair.privateKey);
beforeAll(() => {
mswServer.use(
http.get("https://www.googleapis.com/oauth2/v3/certs", async () =>
HttpResponse.json({ keys: [googleJwk] }),
),
);
});
it.each([
["web", webClientId],
["iOS", iosClientId],
["Android", androidClientId],
])("accepts an id token issued for the %s client", async (_, audience) => {
const idToken = await signIdToken(audience);
const { client } = await getTestInstance(
{
socialProviders: {
google: {
clientId: [webClientId, iosClientId, androidClientId],
clientSecret: "test-secret",
},
},
},
{ disableTestUser: true },
);
const res = await client.signIn.social({
provider: "google",
idToken: { token: idToken },
});
expect(res.data).toBeDefined();
expect(res.data!.redirect).toBe(false);
const data = res.data as { user: { email: string } };
expect(data.user.email).toBe("mobile-user@example.com");
});
it("rejects an id token whose audience is not configured", async () => {
const idToken = await signIdToken("999-unknown.googleusercontent.com");
const { client } = await getTestInstance(
{
socialProviders: {
google: {
clientId: [webClientId, iosClientId],
clientSecret: "test-secret",
},
},
},
{ disableTestUser: true },
);
const res = await client.signIn.social({
provider: "google",
idToken: { token: idToken },
});
expect(res.error?.status).toBe(401);
});
it("uses the first configured client id when building the authorization URL", async () => {
const { client } = await getTestInstance(
{
socialProviders: {
google: {
clientId: [webClientId, iosClientId, androidClientId],
clientSecret: "test-secret",
},
},
},
{ disableTestUser: true },
);
const signInRes = await client.signIn.social({
provider: "google",
callbackURL: "/callback",
});
expect(signInRes.data?.url).toContain(encodeURIComponent(webClientId));
expect(signInRes.data?.url).not.toContain(encodeURIComponent(iosClientId));
});
it("rejects an empty clientId array at sign-in time", async () => {
const { client } = await getTestInstance(
{
socialProviders: {
google: {
clientId: [],
clientSecret: "test-secret",
},
},
},
{ disableTestUser: true },
);
const res = await client.signIn.social({
provider: "google",
callbackURL: "/callback",
});
expect(res.error?.status).toBe(500);
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/4498
*/
describe("Multi-client ID support — other widened providers", () => {
const appleConfig = {
clientId: ["apple-web", "apple-ios"],
clientSecret: "apple-secret",
};
const facebookConfig = {
clientId: ["fb-web", "fb-mobile"],
clientSecret: "fb-secret",
};
const cognitoConfig = {
clientId: ["cog-web", "cog-mobile"],
clientSecret: "cog-secret",
domain: "test.auth.us-east-1.amazoncognito.com",
region: "us-east-1",
userPoolId: "us-east-1_testpool",
};
it.each([
["apple", appleConfig, "apple-web"],
["facebook", facebookConfig, "fb-web"],
["cognito", cognitoConfig, "cog-web"],
] as const)("%s uses the first entry of the clientId array for the auth URL", async (provider, config, firstId) => {
const { client } = await getTestInstance(
{ socialProviders: { [provider]: config } },
{ disableTestUser: true },
);
const res = await client.signIn.social({
provider,
callbackURL: "/callback",
});
expect(res.data?.url).toContain(firstId);
});
it.each([
["apple", { ...appleConfig, clientId: [] as string[] }],
["facebook", { ...facebookConfig, clientId: [] as string[] }],
["cognito", { ...cognitoConfig, clientId: [] as string[] }],
] as const)("%s rejects an empty clientId array", async (provider, config) => {
const { client } = await getTestInstance(
{ socialProviders: { [provider]: config } },
{ disableTestUser: true },
);
const res = await client.signIn.social({
provider,
callbackURL: "/callback",
});
expect(res.error?.status).toBe(500);
});
});
describe("Apple Provider", async () => {
it("should not use email as fallback for name when name is not provided", async () => {
const appleProfile = {
@@ -2015,6 +2204,27 @@ describe("Microsoft Provider", async () => {
});
expect(invalidRes.error?.status).toBe(401);
});
it("builds an authorization URL without clientSecret (public client)", async () => {
const { client } = await getTestInstance(
{
socialProviders: {
microsoft: {
clientId: "public-ms-client",
},
},
},
{ disableTestUser: true },
);
const res = await client.signIn.social({
provider: "microsoft",
callbackURL: "/callback",
});
expect(res.data?.url).toContain("public-ms-client");
expect(res.data?.redirect).toBe(true);
});
});
describe("Railway Provider", async () => {
+5 -1
View File
@@ -15,7 +15,11 @@ export {
refreshAccessToken,
refreshAccessTokenRequest,
} from "./refresh-access-token";
export { generateCodeChallenge, getOAuth2Tokens } from "./utils";
export {
generateCodeChallenge,
getOAuth2Tokens,
getPrimaryClientId,
} from "./utils";
export {
authorizationCodeRequest,
createAuthorizationCodeRequest,
+13
View File
@@ -28,6 +28,19 @@ export function getOAuth2Tokens(data: Record<string, any>): OAuth2Tokens {
};
}
/**
* Return the provider's primary Client ID: the single string, or the entry at
* array index 0 for the cross-platform form used by ID token audience
* verification. Index 0 is the designated primary and pairs with
* `clientSecret` for the authorization code flow; later array entries are
* only used as additional accepted audiences. Returns `undefined` when the
* primary value is missing or an empty string.
*/
export function getPrimaryClientId(clientId: unknown): string | undefined {
const value = Array.isArray(clientId) ? clientId[0] : clientId;
return typeof value === "string" && value.length > 0 ? value : undefined;
}
export async function generateCodeChallenge(codeVerifier: string) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
+10 -2
View File
@@ -1,10 +1,12 @@
import { betterFetch } from "@better-fetch/fetch";
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
import { APIError } from "../error";
import { logger } from "../env";
import { APIError, BetterAuthError } from "../error";
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import {
createAuthorizationURL,
getPrimaryClientId,
refreshAccessToken,
validateAuthorizationCode,
} from "../oauth2";
@@ -70,7 +72,7 @@ export interface AppleNonConformUser {
}
export interface AppleOptions extends ProviderOptions<AppleProfile> {
clientId: string;
clientId: string | string[];
appBundleIdentifier?: string | undefined;
audience?: (string | string[]) | undefined;
}
@@ -81,6 +83,12 @@ export const apple = (options: AppleOptions) => {
id: "apple",
name: "Apple",
async createAuthorizationURL({ state, scopes, redirectURI }) {
if (!getPrimaryClientId(options.clientId) || !options.clientSecret) {
logger.error(
"Client ID and client secret are required for Apple. Make sure to provide them in the options.",
);
throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
}
const _scope = options.disableDefaultScope ? [] : ["email", "name"];
if (options.scope) _scope.push(...options.scope);
if (scopes) _scope.push(...scopes);
@@ -5,6 +5,7 @@ import { APIError, BetterAuthError } from "../error";
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import {
createAuthorizationURL,
getPrimaryClientId,
refreshAccessToken,
validateAuthorizationCode,
} from "../oauth2";
@@ -30,7 +31,7 @@ export interface CognitoProfile {
}
export interface CognitoOptions extends ProviderOptions<CognitoProfile> {
clientId: string;
clientId: string | string[];
/**
* The Cognito domain (e.g., "your-app.auth.us-east-1.amazoncognito.com")
*/
@@ -60,7 +61,7 @@ export const cognito = (options: CognitoOptions) => {
id: "cognito",
name: "Cognito",
async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) {
if (!options.clientId) {
if (!getPrimaryClientId(options.clientId)) {
logger.error(
"ClientId is required for Amazon Cognito. Make sure to provide them in the options.",
);
+10 -1
View File
@@ -1,8 +1,11 @@
import { betterFetch } from "@better-fetch/fetch";
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
import { logger } from "../env";
import { BetterAuthError } from "../error";
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import {
createAuthorizationURL,
getPrimaryClientId,
refreshAccessToken,
validateAuthorizationCode,
} from "../oauth2";
@@ -22,7 +25,7 @@ export interface FacebookProfile {
}
export interface FacebookOptions extends ProviderOptions<FacebookProfile> {
clientId: string;
clientId: string | string[];
/**
* Extend list of fields to retrieve from the Facebook user profile.
*
@@ -41,6 +44,12 @@ export const facebook = (options: FacebookOptions) => {
id: "facebook",
name: "Facebook",
async createAuthorizationURL({ state, scopes, redirectURI, loginHint }) {
if (!getPrimaryClientId(options.clientId) || !options.clientSecret) {
logger.error(
"Client ID and client secret are required for Facebook. Make sure to provide them in the options.",
);
throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
}
const _scopes = options.disableDefaultScope
? []
: ["email", "public_profile"];
+3 -2
View File
@@ -5,6 +5,7 @@ import { APIError, BetterAuthError } from "../error";
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import {
createAuthorizationURL,
getPrimaryClientId,
refreshAccessToken,
validateAuthorizationCode,
} from "../oauth2";
@@ -37,7 +38,7 @@ export interface GoogleProfile {
}
export interface GoogleOptions extends ProviderOptions<GoogleProfile> {
clientId: string;
clientId: string | string[];
/**
* The access type to use for the authorization code request
*/
@@ -64,7 +65,7 @@ export const google = (options: GoogleOptions) => {
loginHint,
display,
}) {
if (!options.clientId || !options.clientSecret) {
if (!getPrimaryClientId(options.clientId) || !options.clientSecret) {
logger.error(
"Client Id and Client Secret is required for Google. Make sure to provide them in the options.",
);
@@ -2,10 +2,11 @@ import { base64 } from "@better-auth/utils/base64";
import { betterFetch } from "@better-fetch/fetch";
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
import { logger } from "../env";
import { APIError } from "../error";
import { APIError, BetterAuthError } from "../error";
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import {
createAuthorizationURL,
getPrimaryClientId,
refreshAccessToken,
validateAuthorizationCode,
} from "../oauth2";
@@ -116,7 +117,7 @@ export interface MicrosoftEntraIDProfile extends Record<string, any> {
export interface MicrosoftOptions
extends ProviderOptions<MicrosoftEntraIDProfile> {
clientId: string;
clientId: string | string[];
/**
* The tenant ID of the Microsoft account
* @default "common"
@@ -149,6 +150,15 @@ export const microsoft = (options: MicrosoftOptions) => {
id: "microsoft",
name: "Microsoft EntraID",
createAuthorizationURL(data) {
// Microsoft Entra supports public clients (SPA / native apps with
// PKCE only), so clientSecret is intentionally not required here.
// See https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow
if (!getPrimaryClientId(options.clientId)) {
logger.error(
"Client Id is required for Microsoft Entra ID. Make sure to provide it in the options.",
);
throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
}
const scopes = options.disableDefaultScope
? []
: ["openid", "profile", "email", "User.Read", "offline_access"];
@@ -190,7 +200,7 @@ export const microsoft = (options: MicrosoftOptions) => {
const publicKey = await getMicrosoftPublicKey(kid, tenant, authority);
const verifyOptions: {
algorithms: [string];
audience: string;
audience: string | string[];
maxTokenAge: string;
issuer?: string;
} = {