mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-23 15:42:09 -05:00
fix(oauth-proxy): should skip state check for oauth proxy (#4991)
This commit is contained in:
@@ -60,8 +60,10 @@ await authClient.signIn.social({
|
||||
|
||||
When the OAuth provider returns the user to your server, the plugin automatically redirects them to the intended callback URL.
|
||||
|
||||
<Callout>
|
||||
To share cookies between the proxy server and your main server it uses URL query parameters to pass the cookies encrypted in the URL. This is secure as the cookies are encrypted and can only be decrypted by the server.
|
||||
|
||||
<Callout type="warn">
|
||||
This plugin requires skipping the state cookie check. This has security implications and should only be used in dev or staging environments. If `baseURL` and `productionURL` are the same, the plugin will not proxy the request.
|
||||
</Callout>
|
||||
|
||||
## Options
|
||||
|
||||
@@ -189,6 +189,12 @@ export type AuthContext = {
|
||||
appName: string;
|
||||
baseURL: string;
|
||||
trustedOrigins: string[];
|
||||
oauthConfig?: {
|
||||
/**
|
||||
* This is dangerous and should only be used in dev or staging environments.
|
||||
*/
|
||||
skipStateCookieCheck?: boolean;
|
||||
};
|
||||
/**
|
||||
* New session that will be set after the request
|
||||
* meaning: there is a `set-cookie` header that will set
|
||||
|
||||
@@ -101,7 +101,16 @@ export async function parseState(c: GenericEndpointContext) {
|
||||
stateCookie.name,
|
||||
c.context.secret,
|
||||
);
|
||||
if (!stateCookieValue || stateCookieValue !== state) {
|
||||
/**
|
||||
* This is generally cause security issue and should only be used in
|
||||
* dev or staging environments. It's currently used by the oauth-proxy
|
||||
* plugin
|
||||
*/
|
||||
const skipStateCookieCheck = c.context.oauthConfig?.skipStateCookieCheck;
|
||||
if (
|
||||
!skipStateCookieCheck &&
|
||||
(!stateCookieValue || stateCookieValue !== state)
|
||||
) {
|
||||
const errorURL =
|
||||
c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
|
||||
throw c.redirect(`${errorURL}?error=state_mismatch`);
|
||||
|
||||
@@ -54,6 +54,19 @@ export const oAuthProxy = (opts?: OAuthProxyOptions) => {
|
||||
);
|
||||
};
|
||||
|
||||
const checkSkipProxy = (ctx: EndpointContext<string, any>) => {
|
||||
// if skip proxy header is set, we don't need to proxy
|
||||
const skipProxy = ctx.request?.headers.get("x-skip-oauth-proxy");
|
||||
if (skipProxy) {
|
||||
return true;
|
||||
}
|
||||
const productionURL = opts?.productionURL || env.BETTER_AUTH_URL;
|
||||
if (productionURL === ctx.context.options.baseURL) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
id: "oauth-proxy",
|
||||
options: opts,
|
||||
@@ -189,6 +202,26 @@ export const oAuthProxy = (opts?: OAuthProxyOptions) => {
|
||||
},
|
||||
],
|
||||
before: [
|
||||
{
|
||||
matcher() {
|
||||
return true;
|
||||
},
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
const skipProxy = checkSkipProxy(ctx);
|
||||
if (skipProxy || ctx.path !== "/callback/:id") {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
context: {
|
||||
context: {
|
||||
oauthConfig: {
|
||||
skipStateCookieCheck: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
{
|
||||
matcher(context) {
|
||||
return (
|
||||
@@ -197,14 +230,13 @@ export const oAuthProxy = (opts?: OAuthProxyOptions) => {
|
||||
);
|
||||
},
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
// if skip proxy header is set, we don't need to proxy
|
||||
const skipProxy = ctx.request?.headers.get("x-skip-oauth-proxy");
|
||||
const skipProxy = checkSkipProxy(ctx);
|
||||
console.log("skipProxy", skipProxy);
|
||||
if (skipProxy) {
|
||||
return;
|
||||
}
|
||||
const url = resolveCurrentURL(ctx);
|
||||
const productionURL = opts?.productionURL || env.BETTER_AUTH_URL;
|
||||
if (productionURL === ctx.context.options.baseURL) {
|
||||
if (!ctx.body) {
|
||||
return;
|
||||
}
|
||||
ctx.body.callbackURL = `${url.origin}${
|
||||
|
||||
@@ -42,21 +42,20 @@ vi.mock("../../oauth2", async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe("oauth-proxy", async () => {
|
||||
const { client, cookieSetter } = await getTestInstance({
|
||||
plugins: [
|
||||
oAuthProxy({
|
||||
currentURL: "http://preview-localhost:3000",
|
||||
}),
|
||||
],
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: "test",
|
||||
clientSecret: "test",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("should redirect to proxy url", async () => {
|
||||
const { client, cookieSetter } = await getTestInstance({
|
||||
plugins: [
|
||||
oAuthProxy({
|
||||
currentURL: "http://preview-localhost:3000",
|
||||
}),
|
||||
],
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: "test",
|
||||
clientSecret: "test",
|
||||
},
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
const res = await client.signIn.social(
|
||||
{
|
||||
@@ -65,7 +64,6 @@ describe("oauth-proxy", async () => {
|
||||
},
|
||||
{
|
||||
throw: true,
|
||||
onSuccess: cookieSetter(headers),
|
||||
},
|
||||
);
|
||||
const state = new URL(res.url!).searchParams.get("state");
|
||||
@@ -108,7 +106,6 @@ describe("oauth-proxy", async () => {
|
||||
);
|
||||
const state = new URL(res.url!).searchParams.get("state");
|
||||
await client.$fetch(`/callback/google?code=test&state=${state}`, {
|
||||
headers,
|
||||
onError(context) {
|
||||
const location = context.response.headers.get("location");
|
||||
if (!location) {
|
||||
@@ -121,7 +118,7 @@ describe("oauth-proxy", async () => {
|
||||
});
|
||||
|
||||
it("should proxy to the original request url", async () => {
|
||||
const { client, cookieSetter } = await getTestInstance({
|
||||
const { client } = await getTestInstance({
|
||||
baseURL: "https://myapp.com",
|
||||
plugins: [
|
||||
oAuthProxy({
|
||||
@@ -135,7 +132,6 @@ describe("oauth-proxy", async () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
const res = await client.signIn.social(
|
||||
{
|
||||
provider: "google",
|
||||
@@ -143,12 +139,10 @@ describe("oauth-proxy", async () => {
|
||||
},
|
||||
{
|
||||
throw: true,
|
||||
onSuccess: cookieSetter(headers),
|
||||
},
|
||||
);
|
||||
const state = new URL(res.url!).searchParams.get("state");
|
||||
await client.$fetch(`/callback/google?code=test&state=${state}`, {
|
||||
headers,
|
||||
onError(context) {
|
||||
const location = context.response.headers.get("location");
|
||||
if (!location) {
|
||||
@@ -163,10 +157,50 @@ describe("oauth-proxy", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should require state cookie if it's not in proxy url", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
baseURL: "https://myapp.com",
|
||||
plugins: [
|
||||
oAuthProxy({
|
||||
productionURL: "https://myapp.com",
|
||||
}),
|
||||
],
|
||||
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");
|
||||
await client.$fetch(`/callback/google?code=test&state=${state}`, {
|
||||
onError(context) {
|
||||
const location = context.response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error("Location header not found");
|
||||
}
|
||||
expect(location).toContain("state_mismatch");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shouldn't redirect to proxy url on same origin", async () => {
|
||||
const { client, cookieSetter } = await getTestInstance({
|
||||
baseURL: "https://myapp.com",
|
||||
plugins: [oAuthProxy()],
|
||||
plugins: [
|
||||
oAuthProxy({
|
||||
productionURL: "https://myapp.com",
|
||||
}),
|
||||
],
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: "test",
|
||||
|
||||
Reference in New Issue
Block a user