mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
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
This commit is contained in:
@@ -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
|
||||
@@ -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`.
|
||||
</Callout>
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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<void>((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}
|
||||
*/
|
||||
|
||||
@@ -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<GoogleProfile> | null;
|
||||
nonce,
|
||||
})) as (Partial<GoogleProfile> & { 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",
|
||||
|
||||
@@ -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<string, unknown>).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<CallbackResponse>("/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<CallbackResponse>("/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<CallbackResponse>("/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<CallbackResponse>("/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<CallbackResponse>("/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<CallbackResponse>("/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",
|
||||
|
||||
Reference in New Issue
Block a user