diff --git a/.changeset/thin-kids-poke.md b/.changeset/thin-kids-poke.md new file mode 100644 index 0000000000..f9b85cc85e --- /dev/null +++ b/.changeset/thin-kids-poke.md @@ -0,0 +1,7 @@ +--- +"better-auth": patch +--- + +Fix OAuth proxy flows that failed with `state_mismatch` when production and preview use different `BETTER_AUTH_SECRET` values. + +Two issues are addressed. The proxy callback's state cleanup now skips the state-cookie check, which could not be satisfied at the bounced cross-origin hop and left the verification record uncleaned. And with the cookie state strategy, the `oauth_state` cookie (encrypted with the local environment secret) is now re-encrypted with the proxy key before it is handed to production, mirroring the database strategy; previously a dedicated proxy `secret` that differed from `BETTER_AUTH_SECRET` broke cookie-strategy proxy flows because production could not decrypt the inner state. diff --git a/docs/content/docs/plugins/oauth-proxy.mdx b/docs/content/docs/plugins/oauth-proxy.mdx index 2ccdb8728e..534e01eb4a 100644 --- a/docs/content/docs/plugins/oauth-proxy.mdx +++ b/docs/content/docs/plugins/oauth-proxy.mdx @@ -19,6 +19,7 @@ A proxy plugin that allows you to proxy OAuth requests. Useful for development a plugins: [ oAuthProxy({ // [!code highlight] productionURL: "https://my-production-app.com", // [!code highlight] + secret: process.env.OAUTH_PROXY_SECRET, // [!code highlight] }), // [!code highlight] ], socialProviders: { @@ -30,6 +31,8 @@ A proxy plugin that allows you to proxy OAuth requests. Useful for development a }) ``` + Set `OAUTH_PROXY_SECRET` to the same value on all environments (production, preview, localhost). + The plugin will automatically route OAuth requests through your production server. @@ -62,8 +65,19 @@ A proxy plugin that allows you to proxy OAuth requests. Useful for development a - - All environments participating in the OAuth proxy flow must share the same encryption key. By default, the plugin uses `BETTER_AUTH_SECRET`. To avoid sharing your main secret across environments, you can provide a dedicated `secret` option (see [Options](#options)). + + **Important: Shared Secret Required** + + All environments (production, preview, localhost) must use the same encryption key to communicate. Configure a dedicated `secret` in the plugin options: + + ```ts title="auth.ts" + oAuthProxy({ + productionURL: "https://my-production-app.com", + secret: process.env.OAUTH_PROXY_SECRET, // Same value on ALL environments // [!code highlight] + }) + ``` + + If you don't configure a shared `secret`, the plugin falls back to `BETTER_AUTH_SECRET`. Since production and preview typically have different main secrets (which is correct for security), the OAuth flow will fail with a `state_mismatch` error. ## How it works @@ -100,3 +114,39 @@ The encrypted profile data is passed via URL query parameters and can only be de **maxAge**: Maximum age in seconds for encrypted profile payloads. Payloads older than this will be rejected to prevent replay attacks. Keep this value short (e.g., 30-60 seconds) to minimize the window for potential replay attacks while still allowing normal OAuth flows. Defaults to `60` seconds. **secret**: A dedicated secret used for encrypting and decrypting data during the OAuth proxy flow. When set, this is used **instead of** the global `BETTER_AUTH_SECRET`, limiting the blast radius if the key is shared across environments — a leaked proxy secret cannot forge sessions or decrypt other data protected by the main secret. All environments participating in the proxy flow must share the same `secret` value. + +## Troubleshooting + +### `state_mismatch` or "State not persisted correctly" error + +This error typically occurs when production and preview environments have **different secrets** and no shared `secret` is configured in the plugin options. + +**What happens:** + +1. Preview encrypts the OAuth state with its secret +2. OAuth provider redirects to production +3. Production tries to decrypt with a different secret → **fails** +4. The regular OAuth callback runs and fails because the state cookie doesn't exist on production + +**Solution:** Configure a shared `secret` in the `oAuthProxy` options on **all** environments: + +```ts title="auth.ts" +oAuthProxy({ + productionURL: "https://my-production-app.com", + secret: process.env.OAUTH_PROXY_SECRET, // [!code highlight] +}) +``` + +Make sure `OAUTH_PROXY_SECRET` has the same value on production, preview, and localhost. + + + Using a dedicated proxy secret (instead of sharing `BETTER_AUTH_SECRET`) is recommended for security. If the proxy secret is compromised, attackers cannot forge sessions or access other encrypted data — they can only potentially hijack OAuth flows during the short `maxAge` window. + + +### OAuth works on production but fails on preview/localhost + +Ensure all of the following: + +1. **Shared secret** is configured (see above) +2. **Trusted origins** include your preview/localhost URLs +3. **Production callback URL** is registered with your OAuth provider (e.g., `https://production.com/api/auth/callback/github`) diff --git a/docs/content/docs/reference/errors/state_mismatch.mdx b/docs/content/docs/reference/errors/state_mismatch.mdx index f888dc2851..12abd5e0ac 100644 --- a/docs/content/docs/reference/errors/state_mismatch.mdx +++ b/docs/content/docs/reference/errors/state_mismatch.mdx @@ -142,6 +142,7 @@ CSRF protection. This error means the cookie is missing or its value does not ma * Cross-origin POST callbacks (common with SAML IdPs) do not send `SameSite=Lax` cookies. * Preview vs. production domain mismatch. * The user opened multiple sign-in tabs - each overwrites the state cookie, so only the last one is valid. +* **OAuth Proxy with mismatched secrets:** When using the [OAuth Proxy plugin](/docs/plugins/oauth-proxy) and production/preview have different `BETTER_AUTH_SECRET` values without a shared `secret` configured in the plugin options, the proxy's before hook fails to decrypt the state package, causing the regular callback handler to run and fail. ### How to fix @@ -192,6 +193,7 @@ triggers, and what to do about it. | Likelihood | Root cause | Error code | Fix | | ------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | **Very high** | Cookie blocked or missing (Safari ITP, cross-domain, preview domains) | `state_security_mismatch` or `state_mismatch` (cookie strategy) | Use a stable custom domain; verify `SameSite` / `Secure` attributes | +| **Very high** | OAuth Proxy: different secrets on production vs preview | `state_security_mismatch` or `state_mismatch` | Configure a shared `secret` in the [OAuth Proxy plugin](/docs/plugins/oauth-proxy) | | **High** | Callback URL replayed (refresh, back button, redirect loop) | `state_mismatch` (DB) | Ensure your error/redirect page does not re-trigger the callback | | **High** | User took too long (>10 min) on the provider page | `state_mismatch` (DB) or request expired | Inform users to retry; consider switching to cookie strategy | | **High** | Multiple tabs / concurrent sign-in attempts | `state_security_mismatch` | Only the last-opened tab's cookie is valid; earlier tabs will fail | @@ -200,7 +202,7 @@ triggers, and what to do about it. | **Medium** | Signed cookie expired (5 min) while DB record is still valid (10 min) | `state_security_mismatch` | The cookie has a shorter TTL than the DB record; users between 5–10 min will hit this | | **Low** | Secret rotation mid-flow | `state_invalid` (cookie) or `state_mismatch` (DB + hashed) | Rotate secrets during low-traffic windows | | **Low** | OAuth provider altered the state parameter | `state_mismatch` (DB) | Verify the provider preserves `state` exactly; check for URL encoding issues | -| **Low** | Missing verification table / migration not run | `state_generation_error` | Run `npx auth migrate` | +| **Low** | Missing verification table / migration not run | `state_generation_error` | Run `npx auth migrate` | | **Very low** | Clock skew between server instances | request expired | Enable NTP on all servers | *** diff --git a/packages/better-auth/src/plugins/oauth-proxy/index.ts b/packages/better-auth/src/plugins/oauth-proxy/index.ts index 3f4bdd95e9..7a8efe5105 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/index.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/index.ts @@ -82,6 +82,11 @@ export interface OAuthProxyOptions { */ type OAuthProxyStatePackage = { state: string; + /** + * The OAuth state, encrypted under the proxy key (`getEncryptionKey`), not + * the per-environment `oauth_state` cookie key. Production decrypts it with + * the same proxy key, so both state strategies must produce it that way. + */ stateCookie: string; isOAuthProxy: boolean; }; @@ -234,7 +239,9 @@ export const oAuthProxy = (opts?: O) => { // Clean up OAuth state try { - await parseGenericState(ctx, payload.state); + await parseGenericState(ctx, payload.state, { + skipStateCookieCheck: true, + }); } catch (e) { ctx.context.logger.warn("Failed to clean up OAuth state", e); } @@ -347,7 +354,13 @@ export const oAuthProxy = (opts?: O) => { statePackage = parseJSON(decryptedPackage); } catch { - // Not an OAuth proxy state, continue normally + // State is either a regular (non-proxy) state, or an encrypted proxy + // package that can't be decrypted (e.g. different secrets on preview vs production). + // If you're using oauth-proxy and seeing state_mismatch errors, ensure all + // environments share the same `secret` in the oAuthProxy plugin options. + ctx.context.logger.debug( + "OAuth proxy: could not decrypt state package, falling back to regular callback", + ); return; } @@ -536,42 +549,53 @@ export const oAuthProxy = (opts?: O) => { return; } - // Get state value based on storage strategy - let stateCookieValue: string | undefined; - if (ctx.context.oauthConfig.storeStateStrategy === "cookie") { - // Cookie mode - extract from response headers - const headers = ctx.context.responseHeaders; - const setCookieHeader = headers?.get("set-cookie"); - if (setCookieHeader) { - const parsedCookies = parseSetCookieHeader(setCookieHeader); - const stateCookie = ctx.context.createAuthCookie("oauth_state"); - const stateCookieAttrs = parsedCookies.get(stateCookie.name); - stateCookieValue = stateCookieAttrs?.value; - } - } else { - // Database mode - read from DB - const verification = - await ctx.context.internalAdapter.findVerificationValue( - originalState, - ); - if (verification) { - // Encrypt the verification value so it matches cookie mode format - stateCookieValue = await symmetricEncrypt({ - key: getEncryptionKey(ctx), - data: verification.value, - }); - } - } - if (!stateCookieValue) { - ctx.context.logger.warn("No OAuth state cookie value found"); - return; - } - + // Recover the plaintext OAuth state for the configured strategy, + // then re-encrypt it under `getEncryptionKey` (the shared/proxy + // secret) so production can read it back; production does not have + // this environment's `BETTER_AUTH_SECRET`. Any failure (malformed + // cookie, decrypt, or encrypt) falls back to a non-proxied flow. try { - // Create and encrypt state package + let plaintextState: string | undefined; + if (ctx.context.oauthConfig.storeStateStrategy === "cookie") { + // Cookie mode: the `oauth_state` cookie is encrypted with this + // environment's secret, so decrypt it locally to recover the state. + const setCookieHeader = + ctx.context.responseHeaders?.get("set-cookie"); + if (setCookieHeader) { + const oauthStateCookie = + ctx.context.createAuthCookie("oauth_state"); + const encryptedCookieValue = parseSetCookieHeader( + setCookieHeader, + ).get(oauthStateCookie.name)?.value; + if (encryptedCookieValue) { + plaintextState = await symmetricDecrypt({ + key: ctx.context.secretConfig, + data: encryptedCookieValue, + }); + } + } + } else { + // Database mode: the verification value is already plaintext JSON. + const verification = + await ctx.context.internalAdapter.findVerificationValue( + originalState, + ); + plaintextState = verification?.value; + } + if (!plaintextState) { + ctx.context.logger.warn("No OAuth state found for proxy"); + return; + } + + // Re-encrypt the state under the proxy key, then wrap it in the + // package production reads back with that same key. + const stateCookie = await symmetricEncrypt({ + key: getEncryptionKey(ctx), + data: plaintextState, + }); const statePackage: OAuthProxyStatePackage = { state: originalState, - stateCookie: stateCookieValue, + stateCookie, isOAuthProxy: true, }; const encryptedPackage = await symmetricEncrypt({ @@ -589,7 +613,7 @@ export const oAuthProxy = (opts?: O) => { }; } catch (e) { ctx.context.logger.error( - "Failed to encrypt OAuth proxy state package:", + "Failed to prepare OAuth proxy state:", e, ); // Continue without proxy diff --git a/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts b/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts index b475a19369..0375a26a47 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts @@ -505,6 +505,58 @@ describe("oauth-proxy", async () => { expect(payload.timestamp).toBeDefined(); }); + /** + * Cookie strategy with a dedicated proxy `secret` that differs from + * `BETTER_AUTH_SECRET`. The `oauth_state` cookie is encrypted with the + * environment secret, so the proxy must re-encrypt it with the proxy key + * for the production callback to recover the inner state. Without that, + * the callback fails to decrypt the state package and produces no + * passthrough profile. + * + * @see https://github.com/better-auth/better-auth/pull/9385 + */ + it("recovers cookie-strategy state when the proxy secret differs from the env secret", async () => { + const { client } = await getTestInstance({ + secret: "env-secret-not-shared", + account: { storeStateStrategy: "cookie" }, + plugins: [ + oAuthProxy({ + currentURL: "http://preview.example.com", + secret: "shared-proxy-secret", + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }); + + const res = await client.signIn.social( + { + provider: "google", + callbackURL: "/dashboard", + }, + { + throw: true, + }, + ); + const state = new URL(res.url!).searchParams.get("state"); + + let encryptedProfile: string | null = null; + await client.$fetch(`/callback/google?code=test&state=${state}`, { + onError(context) { + const location = context.response.headers.get("location"); + if (location?.includes("profile=")) { + encryptedProfile = new URL(location).searchParams.get("profile"); + } + }, + }); + + expect(encryptedProfile).toBeTruthy(); + }); + it("should create user/session on preview from profile data", async () => { // Production instance - handles OAuth callback const production = await getTestInstance( @@ -1159,6 +1211,374 @@ describe("oauth-proxy", async () => { }); }); + /** + * + * Tests that oauth-proxy-callback correctly skips the state cookie check + * when cleaning up OAuth state. In the proxy flow: + * 1. User starts OAuth on preview - state cookie set on preview domain + * 2. OAuth provider redirects to production + * 3. Production redirects to preview's oauth-proxy-callback + * 4. parseGenericState is called to clean up, but the state cookie + * may not be present (cross-origin redirect) or may not match + * + * The fix is to pass `skipStateCookieCheck: true` when calling parseGenericState + * in the oauth-proxy-callback endpoint. + */ + describe("database mode state cleanup", () => { + it("should not fail when state cookie is missing during proxy callback cleanup", async () => { + // This test simulates the scenario where: + // - Both preview and production share the SAME database + // - State storage is "database" (default) + // - The state cookie was set on preview, but when oauth-proxy-callback + // is called, the cookie may not be present + + // Use a shared database for both instances + const { client: productionClient, auth: productionAuth } = + await getTestInstance( + { + baseURL: "http://localhost:3000", + plugins: [ + oAuthProxy({ + currentURL: "http://preview.example.com", + productionURL: "http://localhost:3000", + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }, + { + disableTestUser: true, + }, + ); + + // Step 1: Initiate OAuth flow + const res = await productionClient.signIn.social( + { + provider: "google", + callbackURL: "/dashboard", + }, + { + throw: true, + }, + ); + + const state = new URL(res.url!).searchParams.get("state"); + + // Step 2: Complete OAuth callback to get encrypted profile + let encryptedProfile: string | null = null; + let callbackURL: string | null = null; + await productionClient.$fetch( + `/callback/google?code=test&state=${state}`, + { + onError(context) { + const location = context.response.headers.get("location"); + if (location && location.includes("profile=")) { + const url = new URL(location); + encryptedProfile = url.searchParams.get("profile"); + callbackURL = url.searchParams.get("callbackURL"); + } + }, + }, + ); + + expect(encryptedProfile).toBeTruthy(); + expect(callbackURL).toBeTruthy(); + + // Step 3: Call oauth-proxy-callback WITHOUT cookies (simulating cross-origin) + // This is the key: we explicitly DON'T pass any cookies/headers + // to simulate the state cookie not being present + const _response = await productionClient.$fetch( + `/oauth-proxy-callback?callbackURL=${encodeURIComponent(callbackURL!)}&profile=${encodeURIComponent(encryptedProfile!)}`, + { + onError(context) { + const location = context.response.headers.get("location"); + // The callback should succeed and redirect to dashboard + // NOT fail with state_mismatch error + expect(location).not.toContain("error="); + expect(location).toContain("/dashboard"); + }, + }, + ); + + // Verify user was created successfully + const ctx = await productionAuth.$context; + const users = await ctx.internalAdapter.listUsers(); + expect(users.length).toBe(1); + expect(users[0]?.email).toBe("user@email.com"); + }); + + it("should handle state cleanup gracefully when verification is already deleted", async () => { + // This tests that parseGenericState errors are caught and don't break the flow + const { client, auth } = await getTestInstance( + { + plugins: [ + oAuthProxy({ + currentURL: "http://preview.example.com", + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }, + { + disableTestUser: true, + }, + ); + + const { secret, internalAdapter } = await auth.$context; + + // Create a valid payload with a state that doesn't exist in DB + const payload = { + userInfo: { + id: "123", + email: "test@email.com", + name: "Test User", + emailVerified: true, + }, + account: { + providerId: "google", + accountId: "123", + accessToken: "test", + }, + state: "non-existent-state-id", + callbackURL: "/dashboard", + timestamp: Date.now(), + }; + + const encryptedProfile = await symmetricEncrypt({ + key: secret, + data: JSON.stringify(payload), + }); + + // The callback should still succeed even if state cleanup fails + await client.$fetch( + `/oauth-proxy-callback?callbackURL=/dashboard&profile=${encodeURIComponent(encryptedProfile)}`, + { + onError(context) { + const location = context.response.headers.get("location"); + // Should redirect to dashboard, not error + expect(location).not.toContain("error=state_mismatch"); + expect(location).toContain("/dashboard"); + }, + }, + ); + + // Verify user was created + const users = await internalAdapter.listUsers(); + expect(users.length).toBe(1); + }); + }); + + /** + * Tests for secret configuration across environments. + * When production and preview have different BETTER_AUTH_SECRET values, + * a shared `secret` must be configured in the oAuthProxy options. + */ + describe("secret configuration", () => { + /** + * This test verifies that when production and preview have DIFFERENT secrets + * (without a shared oAuthProxy secret configured), the callback fails because + * the before hook can't decrypt the state package. + * + * This is the root cause of the issue where users see: + * "ERROR [Better Auth]: Failed to parse state BetterAuthError: State mismatch: State not persisted correctly" + */ + it("should fail when preview and production have different secrets (no shared secret)", async () => { + // Preview instance with its own secret + const preview = await getTestInstance( + { + baseURL: "http://preview.example.com", + secret: "preview-secret-key-that-is-different", + plugins: [ + oAuthProxy({ + productionURL: "http://localhost:3000", + // Note: NO shared secret configured - this is the problem + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }, + { + disableTestUser: true, + }, + ); + + // Production instance with a DIFFERENT secret + const production = await getTestInstance( + { + baseURL: "http://localhost:3000", + secret: "production-secret-key-that-is-different", + plugins: [ + oAuthProxy({ + // Note: NO shared secret configured + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }, + { + disableTestUser: true, + }, + ); + + // Step 1: Start OAuth on preview + const res = await preview.client.signIn.social( + { + provider: "google", + callbackURL: "/dashboard", + }, + { + throw: true, + }, + ); + + // The state is encrypted with preview's secret + const encryptedState = new URL(res.url!).searchParams.get("state"); + expect(encryptedState).toBeTruthy(); + + // Step 2: OAuth callback arrives at production + // Production tries to decrypt with its own secret - THIS FAILS + // The before hook catches the error and returns early + // The regular callback handler runs and fails + await production.client.$fetch( + `/callback/google?code=test&state=${encryptedState}`, + { + onError(context) { + const location = context.response.headers.get("location"); + // Should fail with state error because regular callback runs + expect(location).toMatch(/error=.*state|please_restart/i); + }, + }, + ); + }); + + /** + * This test verifies the CORRECT configuration: using a shared secret + * for the oauth-proxy plugin across all environments. + */ + it("should work correctly when a shared secret is configured", async () => { + const sharedProxySecret = "shared-oauth-proxy-secret-for-all-envs"; + + // Preview instance with shared proxy secret + const preview = await getTestInstance( + { + baseURL: "http://preview.example.com", + secret: "preview-main-secret", // Main secret can be different + plugins: [ + oAuthProxy({ + productionURL: "http://localhost:3000", + secret: sharedProxySecret, // SHARED secret for proxy + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }, + { + disableTestUser: true, + }, + ); + + // Production instance with the SAME shared proxy secret + const production = await getTestInstance( + { + baseURL: "http://localhost:3000", + secret: "production-main-secret", // Main secret can be different + plugins: [ + oAuthProxy({ + secret: sharedProxySecret, // SAME shared secret for proxy + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }, + { + disableTestUser: true, + }, + ); + + // Step 1: Start OAuth on preview (the non-production environment) + const res = await preview.client.signIn.social( + { + provider: "google", + callbackURL: "/dashboard", + }, + { + throw: true, + }, + ); + + const encryptedState = new URL(res.url!).searchParams.get("state"); + expect(encryptedState).toBeTruthy(); + + // Step 2: OAuth callback arrives at production + // Production can decrypt because it uses the same shared secret + let encryptedProfile: string | null = null; + let callbackURL: string | null = null; + await production.client.$fetch( + `/callback/google?code=test&state=${encryptedState}`, + { + onError(context) { + const location = context.response.headers.get("location"); + // Should redirect to preview's oauth-proxy-callback + expect(location).toContain("preview.example.com"); + expect(location).toContain("/oauth-proxy-callback"); + + if (location && location.includes("profile=")) { + const url = new URL(location); + encryptedProfile = url.searchParams.get("profile"); + callbackURL = url.searchParams.get("callbackURL"); + } + }, + }, + ); + + expect(encryptedProfile).toBeTruthy(); + + // Step 3: Preview receives the callback + // Preview can decrypt the profile because it uses the same shared secret + await preview.client.$fetch( + `/oauth-proxy-callback?callbackURL=${encodeURIComponent(callbackURL!)}&profile=${encodeURIComponent(encryptedProfile!)}`, + { + onError(context) { + const location = context.response.headers.get("location"); + // Should successfully redirect to dashboard + expect(location).not.toContain("error="); + expect(location).toContain("/dashboard"); + }, + }, + ); + + // Verify user was created on preview + const previewCtx = await preview.auth.$context; + const users = await previewCtx.internalAdapter.listUsers(); + expect(users.length).toBe(1); + expect(users[0]?.email).toBe("user@email.com"); + }); + }); + /** * @see https://github.com/better-auth/better-auth/issues/8889 */ diff --git a/packages/better-auth/src/state.ts b/packages/better-auth/src/state.ts index 479b5893e5..e2d732d415 100644 --- a/packages/better-auth/src/state.ts +++ b/packages/better-auth/src/state.ts @@ -149,7 +149,7 @@ export async function generateGenericState( export async function parseGenericState( c: GenericEndpointContext, state: string | undefined, - settings?: { cookieName: string; skipStateCookieCheck?: boolean }, + settings?: { cookieName?: string; skipStateCookieCheck?: boolean }, ) { if (!state) { throw new StateError("State not found in OAuth callback", {