fix(one-tap): apply the Google provider's mapProfileToUser and getUserInfo (#11819)

Co-authored-by: kaiizer777 <kanekigaming786@gmail.com>
This commit is contained in:
Gustavo Valverde
2026-10-15 13:02:59 +00:00
committed by GitHub
co-authored by kaiizer777
parent 414e845f12
commit eaecb7229e
4 changed files with 185 additions and 13 deletions
@@ -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.
+10
View File
@@ -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`.
</Callout>
<Callout type="info">
**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.
</Callout>
### 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.
@@ -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<string, unknown>,
profile: toOAuthProfileRecord(providerResult.data),
},
},
});
@@ -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<string, unknown>).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