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",