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