fix(auth): strip internal port from OAuth redirects on BYOD domains (#728)

* fix(auth): strip internal port from OAuth redirects on BYOD domains

When BYOD custom domains proxy auth requests through the dashboard to
PRIVATE_SERVER_URL (e.g. http://cio-api-vymh:10000), Node's URL host setter
preserves the upstream port. Better Auth then redirects browsers to
https://customer.example:10000/... which fails.

- Add oauth-proxy-redirect helpers to clear backend ports explicitly
- Rewrite inbound auth request URLs and outbound oauth-proxy-callback Location headers
- Send hostname-only X-Forwarded-Host from dashboard proxy when port matches backend

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(auth): address Greptile review on oauth port stripping

- Only treat PRIVATE_SERVER_URL as an internal backend port
- Add test for rewriteAuthRequestUrl when forwarded host still has :10000

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-11 06:17:26 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 3eb5e1268d
commit 2fb6b1c477
4 changed files with 255 additions and 14 deletions
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
resolveOAuthBrowserOrigin,
rewriteAuthRequestUrl,
rewriteOAuthProxyCallbackLocation,
shouldRewriteOAuthProxyCallbackLocation,
stripBackendPortFromHost
} from '../oauth-proxy-redirect';
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
describe('stripBackendPortFromHost', () => {
it('removes a port that matches PRIVATE_SERVER_URL', () => {
process.env.PRIVATE_SERVER_URL = 'http://cio-api-vymh:10000';
expect(stripBackendPortFromHost('my.fastlearner.io:10000')).toBe('my.fastlearner.io');
});
it('keeps non-backend ports such as local dev', () => {
process.env.PRIVATE_SERVER_URL = 'http://localhost:3002';
expect(stripBackendPortFromHost('localhost:5173')).toBe('localhost:5173');
});
});
describe('rewriteAuthRequestUrl', () => {
it('clears the internal upstream port from the rewritten request URL', () => {
process.env.PRIVATE_SERVER_URL = 'http://cio-api-vymh:10000';
const request = new Request('http://cio-api-vymh:10000/api/auth/sign-in/social', { method: 'POST' });
const rewritten = rewriteAuthRequestUrl(request, {
forwardedHost: 'my.fastlearner.io',
forwardedProto: 'https'
});
expect(new URL(rewritten.url).href).toBe('https://my.fastlearner.io/api/auth/sign-in/social');
});
it('strips a backend port from x-forwarded-host before rewriting the request URL', () => {
process.env.PRIVATE_SERVER_URL = 'http://cio-api-vymh:10000';
const request = new Request('http://cio-api-vymh:10000/api/auth/sign-in/social', { method: 'POST' });
const rewritten = rewriteAuthRequestUrl(request, {
forwardedHost: 'my.fastlearner.io:10000',
forwardedProto: 'https'
});
expect(new URL(rewritten.url).href).toBe('https://my.fastlearner.io/api/auth/sign-in/social');
});
});
describe('resolveOAuthBrowserOrigin', () => {
it('prefers x-forwarded-host without the backend port', () => {
process.env.PRIVATE_SERVER_URL = 'http://cio-api-vymh:10000';
expect(
resolveOAuthBrowserOrigin({
forwardedHost: 'my.fastlearner.io:10000',
forwardedProto: 'https'
})
).toBe('https://my.fastlearner.io');
});
it('falls back to DASHBOARD_ORIGIN when forwarded host is absent', () => {
process.env.DASHBOARD_ORIGIN = 'https://my.fastlearner.io';
expect(resolveOAuthBrowserOrigin({})).toBe('https://my.fastlearner.io');
});
});
describe('rewriteOAuthProxyCallbackLocation', () => {
it('rewrites backend host to the public dashboard origin', () => {
const location =
'https://my.fastlearner.io:10000/api/auth/oauth-proxy-callback?callbackURL=https%3A%2F%2Fmy.fastlearner.io%2Fcourse%2Ffoo%2Fenroll&token=abc';
expect(shouldRewriteOAuthProxyCallbackLocation(location, 'https://my.fastlearner.io')).toBe(true);
expect(rewriteOAuthProxyCallbackLocation(location, 'https://my.fastlearner.io')).toBe(
'https://my.fastlearner.io/api/auth/oauth-proxy-callback?callbackURL=https%3A%2F%2Fmy.fastlearner.io%2Fcourse%2Ffoo%2Fenroll&token=abc'
);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* Better Auth's `oAuthProxy` plugin redirects the browser to
* `{host}/api/auth/oauth-proxy-callback` on the host that initiated OAuth.
*
* BYOD custom domains reach the API through the dashboard's internal proxy
* (`PRIVATE_SERVER_URL`, often `http://service:10000`). Node's URL setter keeps
* the upstream port unless it is cleared explicitly — e.g. `url.host =
* 'my.fastlearner.io'` on a `http://cio-api-vymh:10000/...` request still
* yields `https://my.fastlearner.io:10000/...`.
*/
function readBackendPorts(): Set<number> {
const ports = new Set<number>();
const privateServerUrl = process.env.PRIVATE_SERVER_URL?.trim();
if (!privateServerUrl) {
return ports;
}
try {
const parsed = new URL(privateServerUrl);
if (parsed.port) {
ports.add(Number(parsed.port));
}
} catch {
// Ignore malformed env values.
}
return ports;
}
/** Strip a backend/API port when it appears on a browser-facing host header. */
export function stripBackendPortFromHost(host: string): string {
const [hostname, portValue] = host.includes(':') ? host.split(':', 2) : [host, ''];
if (!portValue || !hostname) return host;
const port = Number(portValue);
if (!Number.isFinite(port) || !readBackendPorts().has(port)) {
return host;
}
return hostname;
}
/** Public origin where the browser should complete the oauth-proxy handoff. */
export function resolveOAuthBrowserOrigin(options: {
forwardedHost?: string | null;
forwardedProto?: string | null;
}): string | null {
const dashboardOrigin = process.env.DASHBOARD_ORIGIN?.trim().replace(/\/$/, '');
if (options.forwardedHost) {
const proto =
options.forwardedProto === 'http' || options.forwardedProto === 'https' ? options.forwardedProto : 'https';
const host = stripBackendPortFromHost(options.forwardedHost);
return `${proto}://${host}`;
}
if (dashboardOrigin) {
return dashboardOrigin;
}
return null;
}
/**
* Rebuild the auth request URL from proxy headers so Better Auth sees the
* browser-facing host without the internal upstream port.
*/
export function rewriteAuthRequestUrl(
request: Request,
options: { forwardedHost?: string | null; forwardedProto?: string | null }
): Request {
const { forwardedHost, forwardedProto } = options;
if (!forwardedHost?.trim()) {
return request;
}
const url = new URL(request.url);
const publicHost = stripBackendPortFromHost(forwardedHost.trim());
const [hostname, explicitPort = ''] = publicHost.includes(':') ? publicHost.split(':', 2) : [publicHost, ''];
url.hostname = hostname!;
url.port = explicitPort;
if (forwardedProto === 'https' || forwardedProto === 'http') {
url.protocol = `${forwardedProto}:`;
}
return new Request(url, request);
}
export function shouldRewriteOAuthProxyCallbackLocation(location: string, browserOrigin: string): boolean {
if (!location.includes('/oauth-proxy-callback')) {
return false;
}
try {
const target = new URL(location);
const origin = new URL(browserOrigin.endsWith('/') ? browserOrigin : `${browserOrigin}/`);
return target.host !== origin.host || target.protocol !== origin.protocol;
} catch {
return false;
}
}
export function rewriteOAuthProxyCallbackLocation(location: string, browserOrigin: string): string {
const target = new URL(location);
const origin = new URL(browserOrigin.endsWith('/') ? browserOrigin : `${browserOrigin}/`);
target.protocol = origin.protocol;
target.hostname = origin.hostname;
target.port = origin.port;
return target.toString();
}