From bbeccec036bce40ed6b0cc0e6cb011f73dd9d04b Mon Sep 17 00:00:00 2001 From: Bereket Engida Date: Thu, 3 Sep 2026 22:27:30 -0700 Subject: [PATCH] fix(one-tap): bind ID tokens to a server-issued single-use nonce The One Tap client forwarded a caller-supplied nonce to Google, but the callback never passed it to verifyGoogleIdToken, so the nonce claim was never checked and a captured ID token could be replayed until it expired. Add POST /one-tap/nonce, which stores a random single-use nonce in the verification table. The client now fetches it before initializing Google and sends it back with the ID token. The callback consumes the nonce before verification, passes it to verifyGoogleIdToken, and rejects a token that carries a nonce claim when none is presented. Closes #10926 --- .changeset/one-tap-nonce-binding.md | 5 + docs/content/docs/plugins/one-tap.mdx | 30 ++++++ .../better-auth/src/plugins/one-tap/client.ts | 41 +++++++- .../better-auth/src/plugins/one-tap/index.ts | 83 +++++++++++++++- .../src/plugins/one-tap/one-tap.test.ts | 98 +++++++++++++++++++ 5 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 .changeset/one-tap-nonce-binding.md diff --git a/.changeset/one-tap-nonce-binding.md b/.changeset/one-tap-nonce-binding.md new file mode 100644 index 0000000000..2910d19917 --- /dev/null +++ b/.changeset/one-tap-nonce-binding.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +fix(one-tap): bind Google One Tap ID tokens to a server-issued, single-use nonce so captured tokens cannot be replayed diff --git a/docs/content/docs/plugins/one-tap.mdx b/docs/content/docs/plugins/one-tap.mdx index 4147b85da3..4933b3f130 100644 --- a/docs/content/docs/plugins/one-tap.mdx +++ b/docs/content/docs/plugins/one-tap.mdx @@ -224,6 +224,36 @@ When using button mode, pass a `button` object to the `oneTap` action with the f Workspace domain, configure `socialProviders.google` with `hd`. +### Replay protection (nonce) + +Before showing the prompt or rendering the button, the client requests a +single-use nonce from `POST /one-tap/nonce` and passes it to Google, so the +issued ID token carries a matching `nonce` claim. The callback consumes that +nonce and rejects the token if the claim does not match, if the nonce was +never issued or has already been used, or if the token was minted with a +nonce that the request does not present. This binds every ID token to one +sign-in attempt, so a captured token cannot be replayed. + +Nonces expire after five minutes. If you call `/one-tap/callback` yourself +instead of through `authClient.oneTap()`, obtain a nonce first, pass it to +`google.accounts.id.initialize({ nonce })`, and send it back in the callback +body: + +```ts +const { data } = await authClient.$fetch<{ nonce: string }>("/one-tap/nonce", { + method: "POST", +}); + +// ...pass data.nonce to Google, then: +await authClient.$fetch("/one-tap/callback", { + method: "POST", + body: { idToken, nonce: data.nonce }, +}); +``` + +The client-side `nonce` option is deprecated and ignored: the server now +issues the nonce. + ### 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/client.ts b/packages/better-auth/src/plugins/one-tap/client.ts index 02c88112bf..e0afea7409 100644 --- a/packages/better-auth/src/plugins/one-tap/client.ts +++ b/packages/better-auth/src/plugins/one-tap/client.ts @@ -159,6 +159,10 @@ export interface GoogleOneTapActionOptions * This lets you render an alternative UI (e.g. a Google Sign-In button) to restart the process. */ onPromptNotification?: ((notification?: any | undefined) => void) | undefined; + /** + * @deprecated The nonce is now issued by the server (`/one-tap/nonce`) and + * bound to the sign-in attempt. A custom value is ignored. + */ nonce?: string | undefined; /** * Button mode configuration. When provided, renders a "Sign In with Google" button @@ -194,6 +198,30 @@ const noRetryReasons = { skipped: ["user_cancel", "tap_outside"], } as const; +async function getServerNonce( + $fetch: BetterFetch, + opts: GoogleOneTapActionOptions | undefined, + fetchOptions: ClientFetchOption | undefined, +) { + if (opts?.nonce) { + console.warn( + "Google One Tap: the `nonce` option is ignored; the nonce is issued by the server.", + ); + } + const res = await $fetch<{ nonce: string }>("/one-tap/nonce", { + method: "POST", + ...opts?.fetchOptions, + ...fetchOptions, + // Only the callback response should trigger the caller's hooks. + onSuccess: undefined, + onError: undefined, + }); + if (res.error || !res.data?.nonce) { + throw new Error("Google One Tap: failed to obtain a nonce from the server"); + } + return res.data.nonce; +} + export const oneTapClient = (options: GoogleOneTapOptions) => { return { id: "one-tap", @@ -256,10 +284,12 @@ export const oneTapClient = (options: GoogleOneTapOptions) => { return; } + const nonce = await getServerNonce($fetch, opts, fetchOptions); + async function callback(idToken: string) { const res = await $fetch("/one-tap/callback", { method: "POST", - body: { idToken, callbackURL: opts?.callbackURL }, + body: { idToken, nonce, callbackURL: opts?.callbackURL }, ...opts?.fetchOptions, ...fetchOptions, }); @@ -295,7 +325,7 @@ export const oneTapClient = (options: GoogleOneTapOptions) => { cancel_on_tap_outside: cancelOnTapOutside, context: contextValue, ux_mode: opts?.uxMode || "popup", - nonce: opts?.nonce, + nonce, itp_support: true, use_fedcm_for_prompt: useFedCM, ...options.additionalOptions, @@ -311,10 +341,12 @@ export const oneTapClient = (options: GoogleOneTapOptions) => { return; } + let nonce: string | undefined; + async function callback(idToken: string) { const res = await $fetch("/one-tap/callback", { method: "POST", - body: { idToken, callbackURL: opts?.callbackURL }, + body: { idToken, nonce, callbackURL: opts?.callbackURL }, ...opts?.fetchOptions, ...fetchOptions, }); @@ -339,6 +371,7 @@ export const oneTapClient = (options: GoogleOneTapOptions) => { try { await loadGoogleScript(); + nonce = await getServerNonce($fetch, opts, fetchOptions); await new Promise((resolve, reject) => { let isResolved = false; const baseDelay = options.promptOptions?.baseDelay ?? 1000; @@ -361,7 +394,7 @@ export const oneTapClient = (options: GoogleOneTapOptions) => { cancel_on_tap_outside: cancelOnTapOutside, context: contextValue, ux_mode: opts?.uxMode || "popup", - nonce: opts?.nonce, + nonce, /** * @see {@link https://developers.google.com/identity/gsi/web/guides/overview} */ diff --git a/packages/better-auth/src/plugins/one-tap/index.ts b/packages/better-auth/src/plugins/one-tap/index.ts index bde4a12d78..63d535d66b 100644 --- a/packages/better-auth/src/plugins/one-tap/index.ts +++ b/packages/better-auth/src/plugins/one-tap/index.ts @@ -9,6 +9,7 @@ import { import * as z from "zod"; import { APIError } from "../../api"; import { setSessionCookie } from "../../cookies"; +import { generateRandomString } from "../../crypto"; import { parseUserOutput } from "../../db/schema"; import { OAUTH_CALLBACK_ERROR_CODES } from "../../oauth2/errors"; import { handleOAuthUserInfo } from "../../oauth2/link-account"; @@ -39,11 +40,28 @@ export interface OneTapOptions { clientId?: string | undefined; } +const ONE_TAP_NONCE_IDENTIFIER_PREFIX = "one-tap-nonce:"; +const ONE_TAP_NONCE_EXPIRES_IN_MS = 5 * 60 * 1000; + +const oneTapNonceBodySchema = z.object({}).strict().optional(); + const oneTapCallbackBodySchema = z.object({ idToken: z.string().meta({ description: "Google ID token, which the client obtains from the One Tap API", }), + /** + * Nonce previously issued by `/one-tap/nonce`. It is consumed on use and + * must equal the ID token's `nonce` claim, which binds the token to this + * sign-in attempt and prevents a captured token from being replayed. + */ + nonce: z + .string() + .meta({ + description: + "Nonce issued by /one-tap/nonce and passed to Google when the ID token was requested", + }) + .optional(), /** * Sent so the global origin-check middleware validates the post-login * redirect target against `trustedOrigins`. Without it the client performs @@ -62,6 +80,45 @@ export const oneTap = (options?: OneTapOptions | undefined) => id: "one-tap", version: PACKAGE_VERSION, endpoints: { + oneTapNonce: createAuthEndpoint( + "/one-tap/nonce", + { + method: "POST", + body: oneTapNonceBodySchema, + metadata: { + openapi: { + summary: "Issue a One Tap nonce", + description: + "Issues a single-use nonce to pass to Google when requesting the One Tap ID token. The callback consumes it and requires the token's nonce claim to match.", + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "object", + properties: { + nonce: { type: "string" }, + }, + required: ["nonce"], + }, + }, + }, + }, + }, + }, + }, + }, + async (ctx) => { + const nonce = generateRandomString(32, "a-z", "A-Z", "0-9"); + await ctx.context.internalAdapter.createVerificationValue({ + identifier: `${ONE_TAP_NONCE_IDENTIFIER_PREFIX}${nonce}`, + value: nonce, + expiresAt: new Date(Date.now() + ONE_TAP_NONCE_EXPIRES_IN_MS), + }); + return ctx.json({ nonce }); + }, + ), oneTapCallback: createAuthEndpoint( "/one-tap/callback", { @@ -99,7 +156,20 @@ export const oneTap = (options?: OneTapOptions | undefined) => }, }, async (ctx) => { - const { idToken } = ctx.body; + const { idToken, nonce } = ctx.body; + // Consume the nonce before any verification work so a failed + // attempt burns it too and concurrent racers cannot share it. + if (nonce !== undefined) { + const issued = + await ctx.context.internalAdapter.consumeVerificationValue( + `${ONE_TAP_NONCE_IDENTIFIER_PREFIX}${nonce}`, + ); + if (!issued) { + throw new APIError("BAD_REQUEST", { + message: "invalid or expired nonce", + }); + } + } const googleProvider = typeof ctx.context.options.socialProviders?.google === "function" ? await ctx.context.options.socialProviders?.google() @@ -119,12 +189,21 @@ export const oneTap = (options?: OneTapOptions | undefined) => const payload = (await verifyGoogleIdToken({ token: idToken, audience, - })) as Partial | null; + nonce, + })) as (Partial & { nonce?: unknown }) | null; if (!payload) { throw new APIError("BAD_REQUEST", { message: "invalid id token", }); } + // A token minted with a nonce must be presented with that nonce. + // Accepting it without one would let a captured token bypass the + // single-use binding above. + if (payload.nonce !== undefined && nonce === undefined) { + throw new APIError("BAD_REQUEST", { + message: "invalid id token", + }); + } if (!payload.sub) { throw new APIError("BAD_REQUEST", { message: "invalid id token", 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 819a85a344..36fa2100df 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 @@ -496,6 +496,104 @@ describe("one-tap implicit linking gate", async () => { }); }); +/** + * @see https://github.com/better-auth/better-auth/issues/10926 + */ +describe("one-tap nonce binding", async () => { + afterEach(() => { + (verifiedPayload as Record).nonce = undefined; + }); + + const googleOptions = { + socialProviders: { + google: { + clientId: "test-client", + clientSecret: "test-secret", + enabled: true, + }, + }, + plugins: [oneTap()], + }; + + type CallbackResponse = { + data: { token: string } | null; + error: { status: number; message?: string } | null; + }; + + it("issues a nonce and accepts a token bound to it exactly once", async () => { + const { client } = await getTestInstance(googleOptions); + const issued = await client.$fetch<{ nonce: string }>("/one-tap/nonce", { + method: "POST", + }); + expect(issued.data?.nonce).toBeTypeOf("string"); + const nonce = issued.data!.nonce; + Object.assign(verifiedPayload, { nonce }); + + const first = await client.$fetch("/one-tap/callback", { + method: "POST", + body: { idToken: "stub-id-token", nonce }, + }); + expect(first.error).toBeNull(); + + // Replaying the same token and nonce must fail: the nonce is single-use. + const replay = await client.$fetch("/one-tap/callback", { + method: "POST", + body: { idToken: "stub-id-token", nonce }, + }); + expect(replay.error?.status).toBe(400); + expect(replay.error?.message).toBe("invalid or expired nonce"); + }); + + it("rejects a nonce the server never issued", async () => { + const { client } = await getTestInstance(googleOptions); + Object.assign(verifiedPayload, { nonce: "attacker-chosen" }); + + const res = await client.$fetch("/one-tap/callback", { + method: "POST", + body: { idToken: "stub-id-token", nonce: "attacker-chosen" }, + }); + expect(res.error?.status).toBe(400); + expect(res.error?.message).toBe("invalid or expired nonce"); + }); + + it("rejects a token whose nonce claim differs from the presented nonce", async () => { + const { client } = await getTestInstance(googleOptions); + const issued = await client.$fetch<{ nonce: string }>("/one-tap/nonce", { + method: "POST", + }); + Object.assign(verifiedPayload, { nonce: "some-other-nonce" }); + + const res = await client.$fetch("/one-tap/callback", { + method: "POST", + body: { idToken: "stub-id-token", nonce: issued.data!.nonce }, + }); + expect(res.error?.status).toBe(400); + expect(res.error?.message).toBe("invalid id token"); + }); + + it("rejects a token minted with a nonce when the callback omits it", async () => { + const { client } = await getTestInstance(googleOptions); + Object.assign(verifiedPayload, { nonce: "minted-with-nonce" }); + + const res = await client.$fetch("/one-tap/callback", { + method: "POST", + body: { idToken: "stub-id-token" }, + }); + expect(res.error?.status).toBe(400); + expect(res.error?.message).toBe("invalid id token"); + }); + + it("still accepts a token without a nonce claim when none is presented", async () => { + const { client } = await getTestInstance(googleOptions); + + const res = await client.$fetch("/one-tap/callback", { + method: "POST", + body: { idToken: "stub-id-token" }, + }); + expect(res.error).toBeNull(); + }); +}); + describe("one-tap callbackURL origin validation", async () => { const googleProvider = { clientId: "test-client",