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 <bekacru@gmail.com>
This commit is contained in:
Cursor Agent
2026-01-31 00:59:41 +00:00
parent f82960b984
commit f07c87dfb5
4 changed files with 402 additions and 0 deletions

View File

@@ -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
<Callout type="info">
Both the production and preview servers must use the same `secret` in their Better Auth configuration for the encrypted state to be decrypted correctly.
</Callout>

View File

@@ -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 = <O extends OAuthProxyOptions>(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<OAuthProxyStatePackage>(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 = <O extends OAuthProxyOptions>(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 = <O extends OAuthProxyOptions>(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");

View File

@@ -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({

View File

@@ -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;
};