diff --git a/.changeset/oauth-state-ttl.md b/.changeset/oauth-state-ttl.md new file mode 100644 index 0000000000..f22fb9550d --- /dev/null +++ b/.changeset/oauth-state-ttl.md @@ -0,0 +1,7 @@ +--- +"better-auth": patch +"@better-auth/core": patch +"@better-auth/sso": patch +--- + +Keep the OAuth state cookie alive as long as the state it protects, and make the window configurable via `account.stateExpiresIn`. diff --git a/docs/content/docs/reference/errors/state_mismatch.mdx b/docs/content/docs/reference/errors/state_mismatch.mdx index 12abd5e0ac..2ee5175c0b 100644 --- a/docs/content/docs/reference/errors/state_mismatch.mdx +++ b/docs/content/docs/reference/errors/state_mismatch.mdx @@ -34,7 +34,8 @@ provider was used to look up a verification record in the database, but no match ### Common causes -1. **The user took too long on the provider's login page.** The verification record expires after 10 minutes. +1. **The user took too long on the provider's login page.** The verification record expires after 10 minutes + by default (`account.stateExpiresIn`). Once expired, any other `findVerificationValue` call (from OTP checks, magic links, 2FA, etc.) triggers a background cleanup that deletes all expired records - including this one. @@ -97,7 +98,7 @@ cookie was not present on the callback request. ## `state_mismatch` - request expired The state data was successfully retrieved (from either the database or cookie), but its embedded -`expiresAt` timestamp has passed. The state payload is valid for 10 minutes from creation. +`expiresAt` timestamp has passed. The state payload is valid for 10 minutes from creation by default. ### Common causes @@ -108,7 +109,7 @@ The state data was successfully retrieved (from either the database or cookie), * Ensure **NTP is enabled** on all server instances so clocks stay synchronized. * If your users regularly need more than 10 minutes (e.g. enterprise SSO with approval workflows), - this timeout is currently not configurable - consider opening a feature request. + raise `account.stateExpiresIn` (in seconds). The state cookie and verification record follow the same window. *** diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx index 0c43cc2eef..be477d5e5b 100644 --- a/docs/content/docs/reference/options.mdx +++ b/docs/content/docs/reference/options.mdx @@ -479,6 +479,7 @@ export const auth = betterAuth({ }, encryptOAuthTokens: true, // Encrypt OAuth tokens before storing them in the database storeStateStrategy: "database", // Store OAuth state payload in verification storage + stateExpiresIn: 10 * 60, // How long (seconds) an OAuth flow may take before its state expires storeAccountCookie: true, // Store provider account data after OAuth flow in an encrypted cookie (useful for database-less flows) accountLinking: { enabled: true, @@ -522,6 +523,12 @@ Controls where OAuth state data is stored during the authentication flow. Defaults to `"database"` when a database or `secondaryStorage` is configured, and to `"cookie"` only when neither is set (a fully stateless setup). With `secondaryStorage`, OAuth state is stored there automatically; you no longer need to set this manually. +### `stateExpiresIn` + +How long, in seconds, an OAuth flow may take between the redirect to the provider and the callback. One value drives the state payload expiry, the verification record TTL (database strategy) and the state cookie `Max-Age`, so the cookie can never expire before the state it protects. Raise it for providers where users routinely spend a long time on the provider side (MFA prompts, enterprise approval flows). + +Defaults to `600` (10 minutes). + ### `storeAccountCookie` Store provider account data after an OAuth flow in an encrypted cookie. This is useful for database-less flows where OAuth token material, such as access tokens, refresh tokens, ID tokens, scopes, and token expiry, must be available without an account table. @@ -684,7 +691,7 @@ export const auth = betterAuth({ * `database`: Database configuration options * `backgroundTasks`: Configure background task handling for deferred operations * `skipTrailingSlashes`: Skip trailing slash validation in route matching. (default: `false`) -* OAuth state configuration options (`storeStateStrategy`, `skipStateCookieCheck`) are now part of the `account` option +* OAuth state configuration options (`storeStateStrategy`, `skipStateCookieCheck`, `stateExpiresIn`) are now part of the `account` option

database

diff --git a/packages/better-auth/src/context/create-context.ts b/packages/better-auth/src/context/create-context.ts index 5bc05c349c..c807521c65 100644 --- a/packages/better-auth/src/context/create-context.ts +++ b/packages/better-auth/src/context/create-context.ts @@ -290,6 +290,7 @@ Most of the features of Better Auth will not work correctly.`, options.account?.storeStateStrategy || (isStateful ? "database" : "cookie"), skipStateCookieCheck: !!options.account?.skipStateCookieCheck, + stateExpiresIn: options.account?.stateExpiresIn ?? 10 * 60, }, tables, trustedOrigins, diff --git a/packages/better-auth/src/oauth2/state-expiry.test.ts b/packages/better-auth/src/oauth2/state-expiry.test.ts new file mode 100644 index 0000000000..f696c48865 --- /dev/null +++ b/packages/better-auth/src/oauth2/state-expiry.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { parseSetCookieHeader } from "../cookies"; +import { getTestInstance } from "../test-utils/test-instance"; + +/** + * @see https://github.com/better-auth/better-auth/issues/11012 + * + * The signed state cookie proves ownership of the verification row it is + * checked against, so it must live at least as long as that row. Both derive + * from a single `account.stateExpiresIn` window. + */ +describe("oauth state expiry", () => { + async function startSignIn(options?: { stateExpiresIn?: number }) { + const { client, auth } = await getTestInstance({ + account: options, + socialProviders: { + google: { clientId: "test", clientSecret: "test" }, + }, + }); + let maxAge: number | undefined; + const res = await client.signIn.social({ + provider: "google", + callbackURL: "/callback", + fetchOptions: { + onSuccess(context) { + maxAge = parseSetCookieHeader( + context.response.headers.get("set-cookie") || "", + ).get("better-auth.state")?.["max-age"]; + }, + }, + }); + const state = new URL(res.data!.url!).searchParams.get("state")!; + // Measured after the response so it can only be shorter than the cookie's + // remaining lifetime, never longer. + const now = Date.now(); + const row = await auth.$context.then((c) => + c.internalAdapter.findVerificationValue(state), + ); + return { maxAge: maxAge!, rowTTL: row!.expiresAt.getTime() - now }; + } + + it("keeps the state cookie alive at least as long as the verification row", async () => { + const { maxAge, rowTTL } = await startSignIn(); + expect(rowTTL).toBeGreaterThan(9 * 60 * 1000); + expect(maxAge * 1000).toBeGreaterThanOrEqual(rowTTL); + }); + + it("honors account.stateExpiresIn for the cookie and the row", async () => { + const { maxAge, rowTTL } = await startSignIn({ stateExpiresIn: 30 * 60 }); + expect(rowTTL).toBeGreaterThan(29 * 60 * 1000); + expect(maxAge * 1000).toBeGreaterThanOrEqual(rowTTL); + }); +}); diff --git a/packages/better-auth/src/oauth2/state.ts b/packages/better-auth/src/oauth2/state.ts index 50dac50dab..3d315993dc 100644 --- a/packages/better-auth/src/oauth2/state.ts +++ b/packages/better-auth/src/oauth2/state.ts @@ -63,7 +63,7 @@ export async function generateState( // Assigned after the client-controlled `additionalData` spread so a client // cannot smuggle a `serverContext` of its own through the request body. serverContext, - expiresAt: Date.now() + 10 * 60 * 1000, + expiresAt: Date.now() + c.context.oauthConfig.stateExpiresIn * 1000, requestSignUp: c.body?.requestSignUp, idTokenNonce: options?.idTokenNonce, }; diff --git a/packages/better-auth/src/plugins/oauth-popup/index.ts b/packages/better-auth/src/plugins/oauth-popup/index.ts index fed1433ca5..af50377134 100644 --- a/packages/better-auth/src/plugins/oauth-popup/index.ts +++ b/packages/better-auth/src/plugins/oauth-popup/index.ts @@ -233,7 +233,7 @@ const oauthPopupStart = createAuthEndpoint( errorURL: c.query.errorCallbackURL, newUserURL: c.query.newUserCallbackURL, requestSignUp: c.query.requestSignUp === "true" ? true : undefined, - expiresAt: Date.now() + 10 * 60 * 1000, + expiresAt: Date.now() + c.context.oauthConfig.stateExpiresIn * 1000, }; await setOAuthState(stateData); const { state } = await generateGenericState(c, stateData); diff --git a/packages/better-auth/src/state.ts b/packages/better-auth/src/state.ts index 7dac3d5616..339261423b 100644 --- a/packages/better-auth/src/state.ts +++ b/packages/better-auth/src/state.ts @@ -86,6 +86,10 @@ export async function generateGenericState( ) { const state = generateRandomString(32); const storeStateStrategy = c.context.oauthConfig.storeStateStrategy; + // The payload `expiresAt` is what `parseGenericState` enforces, so the cookie + // and the verification row are derived from it and cannot outlive or, worse, + // expire before it. + const maxAge = Math.ceil((stateData.expiresAt - Date.now()) / 1000); // Cookie strategy: // @@ -100,9 +104,7 @@ export async function generateGenericState( const stateCookie = c.context.createAuthCookie( settings?.cookieName ?? "oauth_state", - { - maxAge: 10 * 60, // 10 minutes - }, + { maxAge }, ); c.setCookie(stateCookie.name, encryptedData, stateCookie.attributes); @@ -119,9 +121,7 @@ export async function generateGenericState( // the adapter hashes it at rest when storeIdentifier is set const stateCookie = c.context.createAuthCookie( settings?.cookieName ?? "state", - { - maxAge: 5 * 60, // 5 minutes - }, + { maxAge }, ); await c.setSignedCookie( @@ -131,16 +131,13 @@ export async function generateGenericState( stateCookie.attributes, ); - const expiresAt = new Date(); - expiresAt.setMinutes(expiresAt.getMinutes() + 10); - const verification = await c.context.internalAdapter.createVerificationValue({ value: JSON.stringify({ ...stateData, oauthState: state, } satisfies StateData), identifier: state, - expiresAt, + expiresAt: new Date(stateData.expiresAt), }); if (!verification) { diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index 891bfd2343..1d2f7ea994 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -383,6 +383,10 @@ export type AuthContext = * @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise */ storeStateStrategy: "database" | "cookie"; + /** + * OAuth state lifetime in seconds. Resolved from `account.stateExpiresIn`. + */ + stateExpiresIn: number; }; /** * New session that will be set after the request diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts index bd767f9a58..fe43981232 100644 --- a/packages/core/src/types/init-options.ts +++ b/packages/core/src/types/init-options.ts @@ -1313,6 +1313,15 @@ export type BetterAuthOptions = { * @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise */ storeStateStrategy?: "database" | "cookie"; + /** + * How long an OAuth flow may take between the redirect to the provider + * and the callback, in seconds. Drives the state payload expiry, the + * verification record TTL and the state cookie `Max-Age` together so + * the cookie can never expire before the state it protects. + * + * @default 600 (10 minutes) + */ + stateExpiresIn?: number; /** * Store provider account data after an OAuth flow in an encrypted * cookie. This includes OAuth token material such as access tokens, diff --git a/packages/sso/src/saml-state.ts b/packages/sso/src/saml-state.ts index 2e9b973056..293e89e08a 100644 --- a/packages/sso/src/saml-state.ts +++ b/packages/sso/src/saml-state.ts @@ -32,7 +32,7 @@ export async function generateRelayState( /** * This is the actual expiry time of the state */ - expiresAt: Date.now() + 10 * 60 * 1000, + expiresAt: Date.now() + c.context.oauthConfig.stateExpiresIn * 1000, requestSignUp: c.body.requestSignUp, serverContext: providerReference ? { [SSO_PROVIDER_STATE_KEY]: providerReference }