mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-17 17:01:18 -05:00
fix(oauth-proxy): missing state-cookie skip for oauth-proxy (#9385)
Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
7
.changeset/thin-kids-poke.md
Normal file
7
.changeset/thin-kids-poke.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
</Step>
|
||||
|
||||
@@ -62,8 +65,19 @@ A proxy plugin that allows you to proxy OAuth requests. Useful for development a
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
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)).
|
||||
<Callout type="warn">
|
||||
**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.
|
||||
</Callout>
|
||||
|
||||
## 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.
|
||||
|
||||
<Callout type="info">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
### 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`)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
***
|
||||
|
||||
@@ -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 = <O extends OAuthProxyOptions>(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 = <O extends OAuthProxyOptions>(opts?: O) => {
|
||||
statePackage =
|
||||
parseJSON<OAuthProxyStatePackage>(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 = <O extends OAuthProxyOptions>(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 = <O extends OAuthProxyOptions>(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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user