Skip to content

Commit e12e040

Browse files
Tomkoooocursoragent
andcommitted
Fix Auth.js post-login redirects landing on 0.0.0.0 behind Docker.
Prefer NEXT_PUBLIC_APP_URL/AUTH_URL when HOSTNAME bind address leaks into redirect origins after Google OAuth. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3635414 commit e12e040

5 files changed

Lines changed: 188 additions & 11 deletions

File tree

packages/core/src/app/auth/admin-callback/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import { NextResponse } from "next/server"
22
import { auth } from "@wse/core/auth"
3+
import { absoluteAppUrl } from "@wse/core/lib/auth-redirect"
34
import { activeOrgCookieOptions } from "@wse/plugin-t-book/lib/org-cookie"
45
import { resolveTBookPostLoginTarget } from "@wse/plugin-t-book/lib/post-login-redirect"
56

67
export async function GET(request: Request) {
78
const session = await auth()
89
if (!session?.user) {
9-
return NextResponse.redirect(new URL("/auth/admin-login", request.url))
10+
return NextResponse.redirect(absoluteAppUrl("/auth/admin-login", request.url))
1011
}
1112

1213
const url = new URL(request.url)
1314
const callbackUrl = url.searchParams.get("callbackUrl")
1415
const { redirectPath, autoSelectOrgId } = await resolveTBookPostLoginTarget(callbackUrl)
1516

16-
const response = NextResponse.redirect(new URL(redirectPath, request.url))
17+
const response = NextResponse.redirect(absoluteAppUrl(redirectPath, request.url))
1718

1819
if (autoSelectOrgId) {
1920
const cookie = activeOrgCookieOptions(autoSelectOrgId)

packages/core/src/auth.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { NextAuthConfig } from "next-auth"
22
import Google from "next-auth/providers/google"
3+
import { resolveAuthRedirectUrl } from "@wse/core/lib/auth-redirect"
34

45
type Role = "ADMIN" | "USER"
56

@@ -11,6 +12,13 @@ export const authConfig = {
1112
}),
1213
],
1314
callbacks: {
15+
/**
16+
* Docker sets HOSTNAME=0.0.0.0 for binding; Auth.js can infer that as the redirect
17+
* origin after OAuth. Always prefer NEXT_PUBLIC_APP_URL / AUTH_URL.
18+
*/
19+
async redirect({ url, baseUrl }) {
20+
return resolveAuthRedirectUrl(url, baseUrl)
21+
},
1422
async session({ session, token }) {
1523
if (session.user) {
1624
if (token.sub) {
Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,98 @@
1+
import { getPublicAppBaseUrl, isLocalhostBaseUrl } from "@wse/core/lib/app-base-url"
2+
13
/** Build login path preserving return URL after Google sign-in. */
24
export function authLoginPath(callbackUrl: string): string {
3-
const params = new URLSearchParams({ callbackUrl });
4-
return `/auth/login?${params.toString()}`;
5+
const params = new URLSearchParams({ callbackUrl })
6+
return `/auth/login?${params.toString()}`
7+
}
8+
9+
/**
10+
* Hosts that must never appear in browser redirects (Docker bind address, loopback).
11+
* Auth.js + `HOSTNAME=0.0.0.0` commonly produces `https://0.0.0.0:3000/...` after OAuth.
12+
*/
13+
export function isUnusableRedirectHost(hostname: string): boolean {
14+
const host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "")
15+
return (
16+
host === "0.0.0.0" ||
17+
host === "::" ||
18+
host === "localhost" ||
19+
host === "127.0.0.1" ||
20+
host === "::1"
21+
)
22+
}
23+
24+
function tryPublicBaseUrl(): string | null {
25+
try {
26+
return getPublicAppBaseUrl().replace(/\/+$/, "")
27+
} catch {
28+
return null
29+
}
30+
}
31+
32+
function resolveBase(fallbackBaseUrl?: string): URL {
33+
const configured = tryPublicBaseUrl()
34+
const candidates = [configured, fallbackBaseUrl, "http://localhost:3000"].filter(
35+
(v): v is string => Boolean(v?.trim())
36+
)
37+
38+
for (const raw of candidates) {
39+
try {
40+
const base = new URL(raw.includes("://") ? raw.replace(/\/+$/, "") : `https://${raw}`)
41+
if (isUnusableRedirectHost(base.hostname)) continue
42+
if (process.env.NODE_ENV === "production" && isLocalhostBaseUrl(base.origin) && configured) {
43+
continue
44+
}
45+
return base
46+
} catch {
47+
/* try next */
48+
}
49+
}
50+
51+
return new URL("http://localhost:3000")
52+
}
53+
54+
/**
55+
* Resolve Auth.js / route-handler redirects onto the configured public origin.
56+
* Rewrites absolute URLs that landed on `0.0.0.0` / loopback (Docker HOSTNAME bind).
57+
*/
58+
export function resolveAuthRedirectUrl(url: string, fallbackBaseUrl?: string): string {
59+
const base = resolveBase(fallbackBaseUrl)
60+
const trimmed = url.trim()
61+
if (!trimmed) return base.origin
62+
63+
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) {
64+
return `${base.origin}${trimmed}`
65+
}
66+
67+
try {
68+
const parsed = new URL(trimmed)
69+
if (isUnusableRedirectHost(parsed.hostname)) {
70+
return `${base.origin}${parsed.pathname}${parsed.search}${parsed.hash}`
71+
}
72+
if (parsed.origin === base.origin) {
73+
return parsed.toString()
74+
}
75+
// Auth.js may pass its inferred baseUrl origin (wrong behind Docker). If the path is
76+
// on that inferred origin, move it onto the public origin.
77+
if (fallbackBaseUrl) {
78+
try {
79+
const inferred = new URL(fallbackBaseUrl)
80+
if (parsed.origin === inferred.origin) {
81+
return `${base.origin}${parsed.pathname}${parsed.search}${parsed.hash}`
82+
}
83+
} catch {
84+
/* ignore */
85+
}
86+
}
87+
// Foreign absolute URL — do not open-redirect; send home.
88+
return base.origin
89+
} catch {
90+
return base.origin
91+
}
92+
}
93+
94+
/** Absolute URL for an app path, preferring NEXT_PUBLIC_APP_URL / AUTH_URL. */
95+
export function absoluteAppUrl(path: string, requestUrl?: string): string {
96+
const normalized = path.startsWith("/") ? path : `/${path}`
97+
return resolveAuthRedirectUrl(normalized, requestUrl)
598
}

packages/core/src/middleware.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,39 @@ function rewriteOrRedirectUrl(req: MiddlewareReq, pathname: string): URL {
4242
url.search = req.nextUrl.search
4343

4444
const forwardedHost = req.headers.get("x-forwarded-host") ?? req.headers.get("host")
45-
if (forwardedHost) {
46-
url.host = forwardedHost.split(",")[0]!.trim()
45+
const hostCandidate = forwardedHost?.split(",")[0]?.trim() ?? ""
46+
const hostNameOnly = hostCandidate.split(":")[0] ?? ""
47+
48+
const unusable =
49+
!hostCandidate ||
50+
hostNameOnly === "0.0.0.0" ||
51+
hostNameOnly === "localhost" ||
52+
hostNameOnly === "127.0.0.1"
53+
54+
if (!unusable) {
55+
url.host = hostCandidate
56+
const forwardedProto = req.headers.get("x-forwarded-proto")
57+
if (forwardedProto) {
58+
url.protocol = `${forwardedProto.split(",")[0]!.trim()}:`
59+
}
60+
return url
4761
}
48-
const forwardedProto = req.headers.get("x-forwarded-proto")
49-
if (forwardedProto) {
50-
url.protocol = `${forwardedProto.split(",")[0]!.trim()}:`
62+
63+
// Docker HOSTNAME=0.0.0.0 (or missing forwarded host): fall back to public env URL.
64+
for (const key of ["NEXT_PUBLIC_APP_URL", "AUTH_URL", "NEXTAUTH_URL"] as const) {
65+
const raw = process.env[key]?.trim()
66+
if (!raw) continue
67+
try {
68+
const pub = new URL(raw)
69+
if (pub.hostname === "0.0.0.0") continue
70+
url.protocol = pub.protocol
71+
url.host = pub.host
72+
return url
73+
} catch {
74+
/* try next */
75+
}
5176
}
77+
5278
return url
5379
}
5480

@@ -86,14 +112,14 @@ export const storefrontMiddleware = auth(async (req) => {
86112
const maintenanceEnabled = isConfiguredMaintenanceEnabled()
87113
const isAdminUser = req.auth?.user?.role === "ADMIN"
88114
if (maintenanceEnabled && !isAdminUser) {
89-
return NextResponse.redirect(new URL("/maintenance", req.nextUrl))
115+
return NextResponse.redirect(rewriteOrRedirectUrl(req, "/maintenance"))
90116
}
91117

92118
const isLoggedIn = !!req.auth
93119
const isAdminPath = pathname.startsWith("/admin")
94120

95121
if (isAdminPath && !isLoggedIn) {
96-
const signInUrl = new URL("/auth/admin-login", req.nextUrl)
122+
const signInUrl = rewriteOrRedirectUrl(req, "/auth/admin-login")
97123
signInUrl.searchParams.set("callbackUrl", "/admin")
98124
return NextResponse.redirect(signInUrl)
99125
}

tests/unit/auth-redirect.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest"
2+
import {
3+
absoluteAppUrl,
4+
isUnusableRedirectHost,
5+
resolveAuthRedirectUrl,
6+
} from "../../packages/core/src/lib/auth-redirect"
7+
8+
describe("auth-redirect", () => {
9+
afterEach(() => {
10+
vi.unstubAllEnvs()
11+
})
12+
13+
it("flags Docker bind / loopback hosts", () => {
14+
expect(isUnusableRedirectHost("0.0.0.0")).toBe(true)
15+
expect(isUnusableRedirectHost("127.0.0.1")).toBe(true)
16+
expect(isUnusableRedirectHost("localhost")).toBe(true)
17+
expect(isUnusableRedirectHost("ugyved.testsrt.org.hu")).toBe(false)
18+
})
19+
20+
it("rewrites 0.0.0.0 post-login redirects onto AUTH_URL", () => {
21+
vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://ugyved.testsrt.org.hu")
22+
vi.stubEnv("AUTH_URL", "https://ugyved.testsrt.org.hu")
23+
vi.stubEnv("NODE_ENV", "production")
24+
25+
expect(resolveAuthRedirectUrl("https://0.0.0.0:3000/admin")).toBe(
26+
"https://ugyved.testsrt.org.hu/admin"
27+
)
28+
expect(resolveAuthRedirectUrl("/admin", "https://0.0.0.0:3000")).toBe(
29+
"https://ugyved.testsrt.org.hu/admin"
30+
)
31+
expect(resolveAuthRedirectUrl("https://0.0.0.0:3000/auth/admin-callback?callbackUrl=%2Fadmin")).toBe(
32+
"https://ugyved.testsrt.org.hu/auth/admin-callback?callbackUrl=%2Fadmin"
33+
)
34+
})
35+
36+
it("keeps same-origin public redirects", () => {
37+
vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://ugyved.testsrt.org.hu")
38+
expect(resolveAuthRedirectUrl("https://ugyved.testsrt.org.hu/admin")).toBe(
39+
"https://ugyved.testsrt.org.hu/admin"
40+
)
41+
})
42+
43+
it("absoluteAppUrl prefers public env over request.url", () => {
44+
vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://ugyved.testsrt.org.hu")
45+
expect(absoluteAppUrl("/admin", "https://0.0.0.0:3000/auth/admin-callback")).toBe(
46+
"https://ugyved.testsrt.org.hu/admin"
47+
)
48+
})
49+
})

0 commit comments

Comments
 (0)