diff --git a/.changeset/multiple-oauth-client-ids.md b/.changeset/multiple-oauth-client-ids.md new file mode 100644 index 0000000000..3f0443a8d0 --- /dev/null +++ b/.changeset/multiple-oauth-client-ids.md @@ -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). diff --git a/docs/content/docs/authentication/google.mdx b/docs/content/docs/authentication/google.mdx index dae775516d..dd80966b33 100644 --- a/docs/content/docs/authentication/google.mdx +++ b/docs/content/docs/authentication/google.mdx @@ -113,6 +113,38 @@ const data = await authClient.signIn.social({ Plugin](/docs/plugins/one-tap) guide. +### 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 }, +}); +``` + + + 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. + + ### 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`. diff --git a/docs/content/docs/concepts/oauth.mdx b/docs/content/docs/concepts/oauth.mdx index 32027d0194..1bbbb0c4a8 100644 --- a/docs/content/docs/concepts/oauth.mdx +++ b/docs/content/docs/concepts/oauth.mdx @@ -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`. diff --git a/packages/better-auth/src/context/create-context.ts b/packages/better-auth/src/context/create-context.ts index b5d02d9169..86e3f67906 100644 --- a/packages/better-auth/src/context/create-context.ts +++ b/packages/better-auth/src/context/create-context.ts @@ -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( ( diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts index 8b83d4a71a..fc93cd3767 100644 --- a/packages/better-auth/src/social.test.ts +++ b/packages/better-auth/src/social.test.ts @@ -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 () => { diff --git a/packages/core/src/oauth2/index.ts b/packages/core/src/oauth2/index.ts index bd1489d457..5c3e513168 100644 --- a/packages/core/src/oauth2/index.ts +++ b/packages/core/src/oauth2/index.ts @@ -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, diff --git a/packages/core/src/oauth2/utils.ts b/packages/core/src/oauth2/utils.ts index 0e62c5c41d..c1ef67d593 100644 --- a/packages/core/src/oauth2/utils.ts +++ b/packages/core/src/oauth2/utils.ts @@ -28,6 +28,19 @@ export function getOAuth2Tokens(data: Record): 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); diff --git a/packages/core/src/social-providers/apple.ts b/packages/core/src/social-providers/apple.ts index e58b9cc239..02952d5e10 100644 --- a/packages/core/src/social-providers/apple.ts +++ b/packages/core/src/social-providers/apple.ts @@ -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 { - 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); diff --git a/packages/core/src/social-providers/cognito.ts b/packages/core/src/social-providers/cognito.ts index 456be22fc7..60286a9be1 100644 --- a/packages/core/src/social-providers/cognito.ts +++ b/packages/core/src/social-providers/cognito.ts @@ -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 { - 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.", ); diff --git a/packages/core/src/social-providers/facebook.ts b/packages/core/src/social-providers/facebook.ts index 93a2e1d4a9..a1050b59d6 100644 --- a/packages/core/src/social-providers/facebook.ts +++ b/packages/core/src/social-providers/facebook.ts @@ -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 { - 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"]; diff --git a/packages/core/src/social-providers/google.ts b/packages/core/src/social-providers/google.ts index 7ce4d56677..b85a5b39ee 100644 --- a/packages/core/src/social-providers/google.ts +++ b/packages/core/src/social-providers/google.ts @@ -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 { - 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.", ); diff --git a/packages/core/src/social-providers/microsoft-entra-id.ts b/packages/core/src/social-providers/microsoft-entra-id.ts index e28594ee0c..2ab2852a6a 100644 --- a/packages/core/src/social-providers/microsoft-entra-id.ts +++ b/packages/core/src/social-providers/microsoft-entra-id.ts @@ -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 { export interface MicrosoftOptions extends ProviderOptions { - 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; } = {