From 626b8928f479016dc30f4a7a3de2380aa4170e7e Mon Sep 17 00:00:00 2001 From: shangzy Date: Tue, 16 Jun 2026 10:01:13 +0800 Subject: [PATCH] fix oidc callback behind reverse proxy --- README.md | 3 +++ docker-compose.coolify.yml | 7 ++++++ lib/auth-config.ts | 1 + proxy.test.ts | 26 ++++++++++++++++++++++ proxy.ts | 44 +++++++++++++++++++++++++++++++++++--- 5 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 proxy.test.ts diff --git a/README.md b/README.md index 6b2e049..d49b738 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ OIDC_ISSUER=https://casdoor.example.com OIDC_CLIENT_ID=analytics OIDC_CLIENT_SECRET=replace-me AUTH_SECRET=replace-with-random-secret +NEXTAUTH_URL=https://your-analytics-domain # Optional login button label: OIDC_PROVIDER_NAME=Sinodoor ``` @@ -64,6 +65,8 @@ When OIDC is enabled, configure the provider redirect URI as: https://your-analytics-domain/api/auth/callback/oidc ``` +`NEXTAUTH_URL` must be the same public `https://` origin that users open through the reverse proxy. This keeps login redirects and callback URLs from using the container listener such as `http://0.0.0.0:8019`. + Partial OIDC configuration is treated as an error instead of falling back to open access. ## Deployment diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 8fd4151..977039f 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -7,6 +7,13 @@ services: - "8019:8019" environment: - SERVICE_URL_ANALYTICS_8019 + - PG_CONNECTION_STRING + - OIDC_ISSUER + - OIDC_CLIENT_ID + - OIDC_CLIENT_SECRET + - AUTH_SECRET + - NEXTAUTH_URL + - OIDC_PROVIDER_NAME networks: - coolify diff --git a/lib/auth-config.ts b/lib/auth-config.ts index 6079068..14306ab 100644 --- a/lib/auth-config.ts +++ b/lib/auth-config.ts @@ -4,6 +4,7 @@ export interface AuthEnv { OIDC_CLIENT_SECRET?: string; OIDC_PROVIDER_NAME?: string; AUTH_SECRET?: string; + NEXTAUTH_URL?: string; } export interface AuthMode { diff --git a/proxy.test.ts b/proxy.test.ts new file mode 100644 index 0000000..c35ae15 --- /dev/null +++ b/proxy.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { NextRequest } from "next/server"; +import { buildPublicRequestUrl } from "./proxy"; + +describe("public request URL", () => { + test("uses NEXTAUTH_URL for OIDC callback URLs behind Docker reverse proxies", () => { + const request = new NextRequest("http://0.0.0.0:8019/logs?range=7d"); + + expect( + buildPublicRequestUrl(request, { + NEXTAUTH_URL: "https://analytics.example.com", + }).href + ).toBe("https://analytics.example.com/logs?range=7d"); + }); + + test("falls back to forwarded proxy headers when no public URL is configured", () => { + const request = new NextRequest("http://0.0.0.0:8019/", { + headers: { + "x-forwarded-host": "analytics.example.com", + "x-forwarded-proto": "https", + }, + }); + + expect(buildPublicRequestUrl(request).href).toBe("https://analytics.example.com/"); + }); +}); diff --git a/proxy.ts b/proxy.ts index b35395d..820acc3 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,6 +1,12 @@ import { getToken } from "next-auth/jwt"; import { NextResponse, type NextRequest } from "next/server"; -import { getAuthMode, getRequiredAuthSecret, isAuthRoute, isProtectedPath } from "@/lib/auth-config"; +import { + getAuthMode, + getRequiredAuthSecret, + isAuthRoute, + isProtectedPath, + type AuthEnv, +} from "@/lib/auth-config"; export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; @@ -24,8 +30,9 @@ export async function proxy(request: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const signInUrl = new URL("/api/auth/signin/oidc", request.url); - signInUrl.searchParams.set("callbackUrl", request.nextUrl.href); + const callbackUrl = buildPublicRequestUrl(request); + const signInUrl = new URL("/api/auth/signin/oidc", callbackUrl); + signInUrl.searchParams.set("callbackUrl", callbackUrl.href); return NextResponse.redirect(signInUrl); } @@ -46,6 +53,37 @@ function authConfigErrorResponse(request: NextRequest, error: string) { }); } +export function buildPublicRequestUrl(request: NextRequest, env: AuthEnv = process.env): URL { + const origin = getPublicOrigin(request, env); + return new URL(`${request.nextUrl.pathname}${request.nextUrl.search}`, origin); +} + +function getPublicOrigin(request: NextRequest, env: AuthEnv): string { + const configuredOrigin = getConfiguredOrigin(env.NEXTAUTH_URL); + if (configuredOrigin) return configuredOrigin; + + const forwardedHost = firstHeaderValue(request.headers.get("x-forwarded-host")); + const host = forwardedHost || firstHeaderValue(request.headers.get("host")) || request.nextUrl.host; + const forwardedProto = firstHeaderValue(request.headers.get("x-forwarded-proto")); + const proto = forwardedProto || request.nextUrl.protocol.replace(":", "") || "http"; + + return `${proto}://${host}`; +} + +function getConfiguredOrigin(value: string | undefined): string | null { + if (!value?.trim()) return null; + + try { + return new URL(value).origin; + } catch { + return null; + } +} + +function firstHeaderValue(value: string | null): string { + return value?.split(",")[0]?.trim() ?? ""; +} + function authErrorHtml(error: string): string { return `