From eaecb7229eeef5cfd763477b12f7dd2e44b89e51 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Thu, 15 Oct 2026 13:02:59 +0000 Subject: [PATCH] fix(one-tap): apply the Google provider's mapProfileToUser and getUserInfo (#11819) Co-authored-by: kaiizer777 --- .changeset/one-tap-google-profile-mapping.md | 11 ++ docs/content/docs/plugins/one-tap.mdx | 10 ++ .../better-auth/src/plugins/one-tap/index.ts | 46 ++++-- .../src/plugins/one-tap/one-tap.test.ts | 131 ++++++++++++++++++ 4 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 .changeset/one-tap-google-profile-mapping.md diff --git a/.changeset/one-tap-google-profile-mapping.md b/.changeset/one-tap-google-profile-mapping.md new file mode 100644 index 0000000000..ace908aa98 --- /dev/null +++ b/.changeset/one-tap-google-profile-mapping.md @@ -0,0 +1,11 @@ +--- +"better-auth": patch +--- + +Google One Tap now applies your `socialProviders.google.mapProfileToUser` and custom `getUserInfo`, the same as the standard Google sign-in. Before, One Tap ignored both and always stored the token's `name`, `email`, `email_verified`, and `picture` claims. + +If you configured either one, users created through One Tap now get the fields it returns, including any extra fields such as a first name from `given_name`. Returning users are updated from it on each One Tap sign-in when `socialProviders.google.overrideUserInfoOnSignIn` is on, and so are existing users that One Tap links when `account.accountLinking.updateUserInfoOnLink` is on. Check that your mapper suits One Tap users too. + +If you set `socialProviders.google.overrideUserInfoOnSignIn`, One Tap sign-ins now update returning users' name, image, and email from Google, as the redirect sign-in already does. + +Setups that configure none of these, including `oneTap({ clientId })` with no Google provider, are unaffected. diff --git a/docs/content/docs/plugins/one-tap.mdx b/docs/content/docs/plugins/one-tap.mdx index 5519b92898..6c5c84f87f 100644 --- a/docs/content/docs/plugins/one-tap.mdx +++ b/docs/content/docs/plugins/one-tap.mdx @@ -228,6 +228,16 @@ When using button mode, pass a `button` object to the `oneTap` action with the f Workspace domain, configure `socialProviders.google` with `hd`. + + **Mapping the Google profile to your user** + + When `socialProviders.google` is configured, One Tap builds the user with its `mapProfileToUser` or `getUserInfo`, as the standard Google sign-in does. An extra field you map, such as a first name from `given_name`, is saved only if it is declared in `user.additionalFields` and not marked `input: false`. The account is always keyed by the verified token's `sub`. + + With `overrideUserInfoOnSignIn`, a returning user is updated from the mapped profile on every One Tap sign-in. + + With only `oneTap({ clientId })` and no `socialProviders.google`, One Tap uses the token's `name`, `email`, `email_verified`, and `picture` claims. + + ### Authorized JavaScript origins Ensure you have configured the Authorized JavaScript origins (e.g., [http://localhost:3000](http://localhost:3000), [https://example.com](https://example.com)) for your Client ID in the Google Cloud Console. This is a required step for the Google One Tap API, and it will not function correctly unless your origins are correctly set. diff --git a/packages/better-auth/src/plugins/one-tap/index.ts b/packages/better-auth/src/plugins/one-tap/index.ts index 9984d68333..596ca41489 100644 --- a/packages/better-auth/src/plugins/one-tap/index.ts +++ b/packages/better-auth/src/plugins/one-tap/index.ts @@ -8,8 +8,10 @@ import { } from "@better-auth/core/social-providers"; import * as z from "zod"; import { APIError } from "../../api"; +import { getAwaitableValue } from "../../context/helpers"; import { setSessionCookie } from "../../cookies"; import { parseUserOutput } from "../../db/schema"; +import { toOAuthProfileRecord } from "../../oauth2/account-key"; import { OAUTH_CALLBACK_ERROR_CODES } from "../../oauth2/errors"; import { handleOAuthUserInfo } from "../../oauth2/link-account"; import { toBoolean } from "../../utils/boolean"; @@ -146,13 +148,32 @@ export const oneTap = (options?: OneTapOptions | undefined) => message: "invalid id token", }); } - const { - email: rawEmail, - email_verified, - name, - picture, - sub, - } = payload; + // Map the verified token the way the redirect and `signIn.social` + // flows do, so a configured `mapProfileToUser` or `getUserInfo` + // applies to One Tap too. + const googleOAuthProvider = await getAwaitableValue( + ctx.context.socialProviders, + { value: "google" }, + ); + const providerResult = googleOAuthProvider + ? await googleOAuthProvider.getUserInfo({ idToken }) + : { + user: { + name: payload.name, + email: payload.email, + image: payload.picture, + emailVerified: toBoolean(payload.email_verified), + }, + data: payload, + }; + if (!providerResult?.user) { + throw new APIError("BAD_REQUEST", { + message: "invalid id token", + }); + } + const providerUser = providerResult.user; + const { sub } = payload; + const { email: rawEmail, name, image } = providerUser; if (typeof rawEmail !== "string" || !rawEmail) { throw new APIError("BAD_REQUEST", { message: "Email not available in token", @@ -165,10 +186,7 @@ export const oneTap = (options?: OneTapOptions | undefined) => } const email = rawEmail.toLowerCase(); - const emailVerified = - typeof email_verified === "boolean" - ? email_verified - : toBoolean(email_verified); + const emailVerified = toBoolean(providerUser.emailVerified); // Resolve identity through the shared OAuth path so One Tap matches // the redirect and `signIn.social` flows: the account that owns the @@ -176,11 +194,12 @@ export const oneTap = (options?: OneTapOptions | undefined) => // token's email. const result = await handleOAuthUserInfo(ctx, { userInfo: { + ...providerUser, id: sub, email, emailVerified, name: typeof name === "string" ? name : "", - image: typeof picture === "string" ? picture : undefined, + image: typeof image === "string" ? image : undefined, }, account: { providerId: "google", @@ -190,11 +209,12 @@ export const oneTap = (options?: OneTapOptions | undefined) => }, disableSignUp: options?.disableSignup || googleProvider?.disableSignUp, + overrideUserInfo: googleProvider?.overrideUserInfoOnSignIn, source: { method: "oauth", oauth: { providerId: "google", - profile: payload as Record, + profile: toOAuthProfileRecord(providerResult.data), }, }, }); diff --git a/packages/better-auth/src/plugins/one-tap/one-tap.test.ts b/packages/better-auth/src/plugins/one-tap/one-tap.test.ts index b569318ac6..f04b5ffcc6 100644 --- a/packages/better-auth/src/plugins/one-tap/one-tap.test.ts +++ b/packages/better-auth/src/plugins/one-tap/one-tap.test.ts @@ -1,5 +1,6 @@ // cspell:ignore AQAB import type { BetterAuthClientPlugin } from "@better-auth/core"; +import type { GoogleProfile } from "@better-auth/core/social-providers"; import type { oneTap as oneTapFromSubpath } from "better-auth/plugins/one-tap"; import { jwtVerify } from "jose"; import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; @@ -48,6 +49,7 @@ vi.mock("jose", async (importOriginal) => { kid: "test-google-key", alg: "RS256", })), + decodeJwt: vi.fn(() => verifiedPayload), importJWK: vi.fn(async () => ({})), jwtVerify: vi.fn(async () => ({ payload: verifiedPayload, @@ -797,6 +799,135 @@ describe("one-tap disableSignUp", () => { }); }); +/** + * @see https://github.com/better-auth/better-auth/issues/9460 + */ +describe("one-tap Google profile mapping", () => { + afterEach(() => { + // biome-ignore lint/performance/noDelete: drop the claim the mapper test adds + delete (verifiedPayload as Record).given_name; + Object.assign(verifiedPayload, defaultVerifiedPayload); + }); + + it("applies socialProviders.google.mapProfileToUser", async () => { + verifiedPayload.email = "one-tap-mapper@example.com"; + verifiedPayload.sub = "one-tap-mapper-sub"; + Object.assign(verifiedPayload, { given_name: "Mary Jane" }); + + const { auth } = await getTestInstance({ + user: { + additionalFields: { + firstName: { type: "string", required: false }, + }, + }, + socialProviders: { + google: { + clientId: "test-client", + clientSecret: "test-secret", + mapProfileToUser: (profile) => ({ + firstName: profile.given_name, + name: "Mapped Name", + }), + }, + }, + plugins: [oneTap()], + }); + + const res = await auth.api.oneTapCallback({ + body: { idToken: "stub-id-token" }, + }); + + expect(res.user).toMatchObject({ + email: "one-tap-mapper@example.com", + name: "Mapped Name", + firstName: "Mary Jane", + }); + }); + + it("uses a custom socialProviders.google.getUserInfo, keeping the verified sub as the account id", async () => { + verifiedPayload.email = "one-tap-override@example.com"; + verifiedPayload.sub = "one-tap-override-sub"; + + const { auth } = await getTestInstance({ + socialProviders: { + google: { + clientId: "test-client", + clientSecret: "test-secret", + getUserInfo: async () => ({ + user: { + name: "From Override", + email: "one-tap-override@example.com", + emailVerified: true, + }, + data: { ...verifiedPayload } as GoogleProfile, + }), + }, + }, + plugins: [oneTap()], + }); + + const res = await auth.api.oneTapCallback({ + body: { idToken: "stub-id-token" }, + }); + + expect(res.user.name).toBe("From Override"); + const ctx = await auth.$context; + const accounts = await ctx.adapter.findMany<{ accountId: string }>({ + model: "account", + where: [{ field: "userId", value: res.user.id }], + }); + expect(accounts).toEqual([ + expect.objectContaining({ accountId: "one-tap-override-sub" }), + ]); + }); + + it("refreshes a returning user from the mapper when overrideUserInfoOnSignIn is on", async () => { + verifiedPayload.email = "one-tap-override-on-sign-in@example.com"; + verifiedPayload.sub = "one-tap-override-on-sign-in-sub"; + + const { auth } = await getTestInstance({ + socialProviders: { + google: { + clientId: "test-client", + clientSecret: "test-secret", + overrideUserInfoOnSignIn: true, + mapProfileToUser: (profile) => ({ name: `Mapped ${profile.name}` }), + }, + }, + plugins: [oneTap()], + }); + + await auth.api.oneTapCallback({ body: { idToken: "stub-id-token" } }); + verifiedPayload.name = "Renamed"; + const res = await auth.api.oneTapCallback({ + body: { idToken: "stub-id-token" }, + }); + + expect(res.user.name).toBe("Mapped Renamed"); + }); + + it("builds the user from the token claims without a Google provider", async () => { + verifiedPayload.email = "One-Tap-No-Provider@Example.com"; + verifiedPayload.sub = "one-tap-no-provider-sub"; + + const { auth } = await getTestInstance({ + socialProviders: {}, + plugins: [oneTap({ clientId: "explicit-one-tap-client" })], + }); + + const res = await auth.api.oneTapCallback({ + body: { idToken: "stub-id-token" }, + }); + + expect(res.user).toMatchObject({ + email: "one-tap-no-provider@example.com", + emailVerified: true, + name: defaultVerifiedPayload.name, + image: defaultVerifiedPayload.picture, + }); + }); +}); + /** * Declaration emit previously left `getActions`'s `$fetch` parameter * un-annotated. With no import of `BetterFetch` in the file, emit had no