fix(auth): add HTTPS reverse proxy origin for Tailscale Serve

Browser accesses via HTTPS (Tailscale TLS termination) but trustedOrigins
only had the HTTP variant from BETTER_AUTH_URL. deriveReverseProxyOrigins()
now adds the HTTPS equivalent for non-localhost HTTP URLs.

Fixes 'Invalid origin' error on /login.

---
Build: pass | Tests: pass — 8 passed (1 file)

---
Build: pass | Tests: pass — Tests  2079 passed (2079)

---
Build: pass | Tests: pass — Tests  2080 passed (2080)
This commit is contained in:
Operator & Claude Code 2026-04-30 12:02:02 +02:00
parent 23a3edf06e
commit 54c74a9e22
2 changed files with 58 additions and 10 deletions

View file

@ -84,6 +84,33 @@ describe('createAuth', () => {
createRemoteAuth({} as never);
const args = tailscaleBetterAuthMock.mock.calls.at(-1)?.[0];
expect(args?.trustedOrigins).toContain('http://osa.taile682b7.ts.net:3100');
expect(args?.trustedOrigins).toContain('https://osa.taile682b7.ts.net');
});
it('does not add HTTPS variant for localhost BETTER_AUTH_URL', async () => {
vi.resetModules();
const localBetterAuthMock = vi.fn().mockReturnValue({});
vi.doMock('./config.js', () => ({
CONTROLPLANE_AUTH_MODE: 'local_trusted',
CONTROLPLANE_API_PORT: 4000,
CONTROLPLANE_EXPOSURE: 'internal',
PLATFORM_INTERNAL_BASE: 'home.arpa',
PLATFORM_INTERNAL_DOMAIN: 'ai.home.arpa',
PLATFORM_PUBLIC_BASE: '',
BETTER_AUTH_SECRET: 'test-secret',
BETTER_AUTH_URL: 'http://localhost:4000',
}));
vi.doMock('./logger.js', () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.doMock('better-auth', () => ({
betterAuth: localBetterAuthMock,
}));
const { createAuth: createLocalAuth } = await import('./auth.js');
createLocalAuth({} as never);
const args = localBetterAuthMock.mock.calls.at(-1)?.[0];
expect(args?.trustedOrigins).not.toContain('https://localhost');
expect(args?.trustedOrigins).toContain('http://localhost:4000');
});
it('includes the public controlplane origin when publicly exposed', async () => {

View file

@ -23,6 +23,23 @@ function getBetterAuthBaseOrigin(): string | null {
}
}
function deriveReverseProxyOrigins(): string[] {
const origins: string[] = [];
try {
const url = new URL(BETTER_AUTH_URL);
if (
url.protocol === 'http:' &&
url.hostname !== 'localhost' &&
url.hostname !== '127.0.0.1'
) {
origins.push(`https://${url.hostname}`);
}
} catch {
// ignore
}
return origins;
}
function assertControlplaneAuthConfiguration(): void {
if (
CONTROLPLANE_EXPOSURE === 'public' &&
@ -46,17 +63,21 @@ function assertControlplaneAuthConfiguration(): void {
export function createAuth(pool: Pool) {
assertControlplaneAuthConfiguration();
const betterAuthBaseOrigin = getBetterAuthBaseOrigin();
const reverseProxyOrigins = deriveReverseProxyOrigins();
const trustedOrigins = Array.from(new Set([
`http://localhost:${CONTROLPLANE_API_PORT}`,
`http://${PLATFORM_INTERNAL_DOMAIN}:${CONTROLPLANE_API_PORT}`,
`http://10.0.0.2:${CONTROLPLANE_API_PORT}`,
`http://ai.${PLATFORM_INTERNAL_BASE}:${CONTROLPLANE_API_PORT}`,
...(betterAuthBaseOrigin ? [betterAuthBaseOrigin] : []),
...(PLATFORM_PUBLIC_BASE && CONTROLPLANE_EXPOSURE === 'public'
? [`https://ai.${PLATFORM_PUBLIC_BASE}`]
: []),
]));
const trustedOrigins = Array.from(
new Set([
`http://localhost:${CONTROLPLANE_API_PORT}`,
`http://${PLATFORM_INTERNAL_DOMAIN}:${CONTROLPLANE_API_PORT}`,
`http://10.0.0.2:${CONTROLPLANE_API_PORT}`,
`http://ai.${PLATFORM_INTERNAL_BASE}:${CONTROLPLANE_API_PORT}`,
...(betterAuthBaseOrigin ? [betterAuthBaseOrigin] : []),
...reverseProxyOrigins,
...(PLATFORM_PUBLIC_BASE && CONTROLPLANE_EXPOSURE === 'public'
? [`https://ai.${PLATFORM_PUBLIC_BASE}`]
: []),
]),
);
const auth = betterAuth({
baseURL: BETTER_AUTH_URL,