From f07c87dfb52049c83d60d4787cec61890c96a0af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 00:59:41 +0000 Subject: [PATCH] feat(oauth-proxy): add earlyRedirect option for separate database deployments When production and preview servers have different databases, the default OAuth proxy flow doesn't work because the session/user data is created on the production server's database. This adds a new 'earlyRedirect' option that redirects the OAuth callback from the production server to the preview server BEFORE processing, allowing the preview server to run the full callback logic against its own database. Changes: - Add 'earlyRedirect' option to OAuthProxyOptions - Add 'earlyRedirect' and 'previewBaseURL' fields to OAuthProxyStatePackage - Add early redirect before hook to detect and redirect callbacks - Update after hook to skip proxy redirect when processing on preview - Add comprehensive tests for early redirect functionality - Update documentation with new option and 'Early Redirect Mode' section Co-authored-by: bekacru --- docs/content/docs/plugins/oauth-proxy.mdx | 37 +++ .../src/plugins/oauth-proxy/index.ts | 125 ++++++++++ .../plugins/oauth-proxy/oauth-proxy.test.ts | 224 ++++++++++++++++++ .../src/plugins/oauth-proxy/types.ts | 16 ++ 4 files changed, 402 insertions(+) diff --git a/docs/content/docs/plugins/oauth-proxy.mdx b/docs/content/docs/plugins/oauth-proxy.mdx index cc6917709b..f16860b279 100644 --- a/docs/content/docs/plugins/oauth-proxy.mdx +++ b/docs/content/docs/plugins/oauth-proxy.mdx @@ -73,3 +73,40 @@ This plugin requires skipping the state cookie check. This has security implicat **productionURL**: If this value matches the `baseURL` in your auth config, requests will not be proxied. Defaults to the `BETTER_AUTH_URL` environment variable. **maxAge**: Maximum age in seconds for encrypted cookie 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. + +**earlyRedirect**: When enabled, the production server will redirect to the preview/current server BEFORE processing the OAuth callback. This allows the preview server to run the full callback logic against its own database. This is useful when the production server and preview server have different databases, as the session and user data need to be created in the preview server's database. Defaults to `false`. + +## Early Redirect Mode + +By default, the OAuth proxy processes the callback on the production server and then redirects to the preview server to set cookies. This works well when both servers share the same database. + +However, if your preview deployments have separate databases from production, you need the preview server to process the entire callback so that user and session data are created in the correct database. + +Enable `earlyRedirect` to handle this scenario: + +```ts title="auth.ts" +import { betterAuth } from "better-auth" +import { oAuthProxy } from "better-auth/plugins" + +export const auth = betterAuth({ + plugins: [ + oAuthProxy({ + productionURL: "https://my-main-app.com", + earlyRedirect: true, // [!code highlight] + }), + ] +}) +``` + +### How Early Redirect Works + +1. User initiates OAuth sign-in on the preview server +2. Preview server redirects to OAuth provider (with callback URL pointing to production) +3. OAuth provider redirects to production server with the authorization code +4. Production server immediately redirects to preview server with the code (without processing) +5. Preview server processes the callback, creating user and session in its own database +6. User is redirected to the final callback URL with cookies set + + +Both the production and preview servers must use the same `secret` in their Better Auth configuration for the encrypted state to be decrypted correctly. + diff --git a/packages/better-auth/src/plugins/oauth-proxy/index.ts b/packages/better-auth/src/plugins/oauth-proxy/index.ts index 7fb8ebba6d..75d5e3938e 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/index.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/index.ts @@ -47,6 +47,18 @@ export interface OAuthProxyOptions { * @default 60 (1 minute) */ maxAge?: number | undefined; + /** + * When enabled, the production server will redirect to the preview/current + * server BEFORE processing the OAuth callback. This allows the preview server + * to run the full callback logic against its own database. + * + * This is useful when the production server and preview server have different + * databases, as the session and user data need to be created in the preview + * server's database. + * + * @default false + */ + earlyRedirect?: boolean | undefined; } interface EncryptedCookiesPayload { @@ -282,6 +294,94 @@ export const oAuthProxy = (opts?: O) => { ctx.body.callbackURL = newCallbackURL; }), }, + { + // Early redirect hook: redirect to preview server before processing + matcher(context) { + return !!( + context.path?.startsWith("/callback") || + context.path?.startsWith("/oauth2/callback") + ); + }, + handler: createAuthMiddleware(async (ctx) => { + const state = ctx.query?.state || ctx.body?.state; + if (!state || typeof state !== "string") { + return; + } + + // Try to decrypt and parse OAuth proxy state package + let statePackage: OAuthProxyStatePackage | undefined; + try { + const decryptedPackage = await symmetricDecrypt({ + key: ctx.context.secret, + data: state, + }); + statePackage = + parseJSON(decryptedPackage); + } catch { + // Not an OAuth proxy state, continue normally + return; + } + + // Check if this is an early redirect request + if ( + !statePackage.isOAuthProxy || + !statePackage.earlyRedirect || + !statePackage.previewBaseURL + ) { + return; + } + + // Check if we're on the production server (not the preview server) + // If we're already on the preview server, don't redirect again + const currentURL = resolveCurrentURL(ctx, opts); + const previewOrigin = getOrigin(statePackage.previewBaseURL); + const currentOrigin = currentURL.origin; + + if (currentOrigin === previewOrigin) { + // We're on the preview server, continue processing normally + // Mark this as early redirect so we skip the after hook proxy redirect + ( + ctx.context as AuthContextWithSnapshot + )._oauthProxyEarlyRedirect = true; + return; + } + + // We're on the production server, redirect to preview server + // Build the redirect URL with all OAuth callback parameters + // Use the actual request path if available, falling back to resolving :id from params + let actualPath = ctx.path; + if (ctx.params?.id && ctx.path.includes(":id")) { + actualPath = ctx.path.replace(":id", ctx.params.id); + } + const previewCallbackURL = new URL( + `${statePackage.previewBaseURL}${ctx.context.options.basePath || "/api/auth"}${actualPath}`, + ); + + // Forward all query parameters + if (ctx.query) { + for (const [key, value] of Object.entries(ctx.query)) { + if (value !== undefined && value !== null) { + previewCallbackURL.searchParams.set(key, String(value)); + } + } + } + + // Also forward body parameters as query params if present (for POST callbacks) + if (ctx.body) { + for (const [key, value] of Object.entries(ctx.body)) { + if ( + value !== undefined && + value !== null && + !previewCallbackURL.searchParams.has(key) + ) { + previewCallbackURL.searchParams.set(key, String(value)); + } + } + } + + throw ctx.redirect(previewCallbackURL.toString()); + }), + }, { matcher(context) { return !!( @@ -483,11 +583,17 @@ export const oAuthProxy = (opts?: O) => { const stateCookieValue = stateCookieAttrs.value; try { + const currentURL = resolveCurrentURL(ctx, opts); + // Create and encrypt state package const statePackage: OAuthProxyStatePackage = { state: originalState, stateCookie: stateCookieValue, isOAuthProxy: true, + earlyRedirect: opts?.earlyRedirect, + previewBaseURL: opts?.earlyRedirect + ? currentURL.origin + : undefined, }; const encryptedPackage = await symmetricEncrypt({ key: ctx.context.secret, @@ -519,6 +625,25 @@ export const oAuthProxy = (opts?: O) => { ); }, handler: createAuthMiddleware(async (ctx) => { + // Skip proxy redirect if this is an early redirect being processed on preview server + if ( + (ctx.context as AuthContextWithSnapshot)._oauthProxyEarlyRedirect + ) { + const headers = ctx.context.responseHeaders; + const location = headers?.get("location"); + + // If the location contains oauth-proxy-callback, extract the original callbackURL + if (location?.includes("/oauth-proxy-callback?callbackURL")) { + const locationURL = new URL(location); + const originalCallbackURL = + locationURL.searchParams.get("callbackURL"); + if (originalCallbackURL) { + ctx.setHeader("location", originalCallbackURL); + } + } + return; + } + const headers = ctx.context.responseHeaders; const location = headers?.get("location"); 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 fa5933f3b7..55f53809b8 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 @@ -483,6 +483,230 @@ describe("oauth-proxy", async () => { }); }); + describe("early redirect mode", () => { + it("should include earlyRedirect and previewBaseURL in state package", async () => { + const { client, auth } = await getTestInstance({ + database: undefined, // Stateless mode + plugins: [ + oAuthProxy({ + currentURL: "http://preview-localhost:3000", + earlyRedirect: true, + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }); + + const { secret } = await auth.$context; + + const res = await client.signIn.social( + { + provider: "google", + callbackURL: "/dashboard", + }, + { + throw: true, + }, + ); + + const encryptedState = new URL(res.url!).searchParams.get("state"); + expect(encryptedState).toBeTruthy(); + + // Decrypt and verify state package contains earlyRedirect info + const { symmetricDecrypt } = await import("../../crypto"); + const { parseJSON } = await import("../../client/parser"); + + const decrypted = await symmetricDecrypt({ + key: secret, + data: encryptedState!, + }); + + const statePackage = parseJSON<{ + state: string; + stateCookie: string; + isOAuthProxy: boolean; + earlyRedirect?: boolean; + previewBaseURL?: string; + }>(decrypted); + + expect(statePackage.isOAuthProxy).toBe(true); + expect(statePackage.earlyRedirect).toBe(true); + expect(statePackage.previewBaseURL).toBe("http://preview-localhost:3000"); + }); + + it("should redirect to preview server on production callback with earlyRedirect", async () => { + // Simulate the production server receiving the callback + const { client, auth } = await getTestInstance({ + baseURL: "https://production.example.com", + database: undefined, // Stateless mode + plugins: [ + oAuthProxy({ + currentURL: "https://production.example.com", // We're on production + productionURL: "https://production.example.com", + earlyRedirect: true, // This needs to be enabled to process early redirect + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }); + + const { secret } = await auth.$context; + + // Create an encrypted state package as if from preview server + const statePackage = { + state: "original-state-123", + stateCookie: await symmetricEncrypt({ + key: secret, + data: JSON.stringify({ + codeVerifier: "test-verifier", + callbackURL: + "http://preview-localhost:3000/api/auth/oauth-proxy-callback?callbackURL=%2Fdashboard", + }), + }), + isOAuthProxy: true, + earlyRedirect: true, + previewBaseURL: "http://preview-localhost:3000", + }; + + const encryptedState = await symmetricEncrypt({ + key: secret, + data: JSON.stringify(statePackage), + }); + + // Simulate callback from OAuth provider to production server + await client.$fetch( + `/callback/google?code=test-code&state=${encodeURIComponent(encryptedState)}`, + { + onError(context) { + const location = context.response.headers.get("location"); + expect(location).toBeTruthy(); + + // Should redirect to preview server's callback endpoint + expect(location).toContain("http://preview-localhost:3000"); + expect(location).toContain("/api/auth/callback/google"); + expect(location).toContain("code=test-code"); + expect(location).toContain( + `state=${encodeURIComponent(encryptedState)}`, + ); + }, + }, + ); + }); + + it("should process callback normally when on preview server (early redirect flow)", async () => { + const { client } = await getTestInstance({ + baseURL: "http://preview-localhost:3000", + database: undefined, // Stateless mode + plugins: [ + oAuthProxy({ + currentURL: "http://preview-localhost:3000", // We're on preview + productionURL: "https://production.example.com", + earlyRedirect: true, + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }); + + // First, initiate sign-in to get the encrypted state + const res = await client.signIn.social( + { + provider: "google", + callbackURL: "/dashboard", + }, + { + throw: true, + }, + ); + + const encryptedState = new URL(res.url!).searchParams.get("state"); + expect(encryptedState).toBeTruthy(); + + // Now simulate the callback arriving at preview server (after early redirect from production) + // This should process the callback and redirect to the final destination + await client.$fetch( + `/callback/google?code=test&state=${encryptedState}`, + { + onError(context) { + const location = context.response.headers.get("location"); + expect(location).toBeTruthy(); + + // Should redirect to final callback URL, not proxy callback + // (since we're already on the preview server) + expect(location).toContain("/dashboard"); + // Should NOT redirect to oauth-proxy-callback since we're handling it directly + expect(location).not.toContain("&cookies="); + }, + }, + ); + }); + + it("should not include earlyRedirect in state package when earlyRedirect is false", async () => { + const { client, auth } = await getTestInstance({ + database: undefined, // Stateless mode + plugins: [ + oAuthProxy({ + currentURL: "http://preview-localhost:3000", + earlyRedirect: false, // Explicitly disabled + }), + ], + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }); + + const { secret } = await auth.$context; + + const res = await client.signIn.social( + { + provider: "google", + callbackURL: "/dashboard", + }, + { + throw: true, + }, + ); + + const encryptedState = new URL(res.url!).searchParams.get("state"); + expect(encryptedState).toBeTruthy(); + + const { symmetricDecrypt } = await import("../../crypto"); + const { parseJSON } = await import("../../client/parser"); + + const decrypted = await symmetricDecrypt({ + key: secret, + data: encryptedState!, + }); + + const statePackage = parseJSON<{ + state: string; + stateCookie: string; + isOAuthProxy: boolean; + earlyRedirect?: boolean; + previewBaseURL?: string; + }>(decrypted); + + expect(statePackage.isOAuthProxy).toBe(true); + expect(statePackage.earlyRedirect).toBeFalsy(); + expect(statePackage.previewBaseURL).toBeUndefined(); + }); + }); + describe("payload timestamp", () => { it("should include timestamp in encrypted payload", async () => { const { client, auth } = await getTestInstance({ diff --git a/packages/better-auth/src/plugins/oauth-proxy/types.ts b/packages/better-auth/src/plugins/oauth-proxy/types.ts index 3ca92b929b..715f8b5095 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/types.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/types.ts @@ -11,6 +11,11 @@ type OAuthConfigSnapshot = { export type AuthContextWithSnapshot = AuthContext & { _oauthProxySnapshot?: OAuthConfigSnapshot; + /** + * Flag indicating this callback is being processed on the preview server + * after an early redirect from the production server. + */ + _oauthProxyEarlyRedirect?: boolean; }; /** @@ -20,4 +25,15 @@ export type OAuthProxyStatePackage = { state: string; stateCookie: string; isOAuthProxy: boolean; + /** + * If true, the production server will redirect to the preview server + * BEFORE processing the callback, allowing the preview server to run + * the full callback logic against its own database. + */ + earlyRedirect?: boolean; + /** + * The preview server's base URL to redirect to for early redirect flow. + * Required when earlyRedirect is true. + */ + previewBaseURL?: string; };