mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
fix(one-tap): apply the Google provider's mapProfileToUser and getUserInfo (#11819)
Co-authored-by: kaiizer777 <kanekigaming786@gmail.com>
This commit is contained in:
co-authored by
kaiizer777
parent
414e845f12
commit
eaecb7229e
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user