fix(auth): post-callback redirect honors APP_URL (reverse-proxy) #102

Closed
opened 2026-06-18 03:59:31 +00:00 by james · 0 comments
Owner

Problem

The redirectTo() helper in app/api/auth/oauth/callback/[provider]/route.ts resolves redirect locations (/login, /profile, /account) against req.nextUrl:

function redirectTo(req: NextRequest, location: string, status: 302 = 302): NextResponse {
  let url: URL;
  try {
    url = new URL(location, req.nextUrl);
  } catch {
    url = new URL("/", req.nextUrl);
  }
  if (url.origin !== req.nextUrl.origin) {
    url = new URL("/", req.nextUrl);
  }
  return NextResponse.redirect(url, { status });
}

A behind-reverse-proxy deployment lands here with req.nextUrl.origin === "http://0.0.0.0:3000" (the container bind address) when the proxy doesn't pass X-Forwarded-Host + X-Forwarded-Proto in a way Next trusts. Every refused-callback path then 302s the user to an internal URL their browser can't reach.

Same class of bug as the outbound redirect_uri before APP_URL was threaded through appOrigin() — the inbound IdP callback works correctly (uses APP_URL), but the post-callback redirect uses req.nextUrl raw.

Discovered while testing the #100 diagnostic surfacing on a deployed Authentik: ?error=oidc_claim correctly identified, but the redirect landed on http://0.0.0.0:3000/login?error=oidc_claim instead of https://carol.int.wynning.tech/login?error=oidc_claim.

Scope

  • Update redirectTo() to build the URL against appOrigin(req) (which honors APP_URL when set, falls back to req.nextUrl.origin).
  • Update the same-origin defence-in-depth check to compare against the same public origin.
  • Add a test confirming APP_URL is honored on a refused-callback redirect (e.g. the existing no_verified_email path under APP_URL="https://carol.example.com").

Out of scope

  • Auto-deriving from X-Forwarded-* headers. Still out per the rationale in #99.
  • Auditing other redirect call sites for the same bug. Not aware of any — /api/auth/login and /api/auth/register use absolute URLs in redirect() that already reference APP_URL indirectly. Worth a separate sweep ticket if one's found.

Acceptance

A self-hoster with APP_URL=https://<host> set behind a reverse proxy lands on https://<host>/login?error=... (not http://0.0.0.0:3000/...) after every refused-callback path.

## Problem The `redirectTo()` helper in `app/api/auth/oauth/callback/[provider]/route.ts` resolves redirect locations (`/login`, `/profile`, `/account`) against `req.nextUrl`: ```ts function redirectTo(req: NextRequest, location: string, status: 302 = 302): NextResponse { let url: URL; try { url = new URL(location, req.nextUrl); } catch { url = new URL("/", req.nextUrl); } if (url.origin !== req.nextUrl.origin) { url = new URL("/", req.nextUrl); } return NextResponse.redirect(url, { status }); } ``` A behind-reverse-proxy deployment lands here with `req.nextUrl.origin === "http://0.0.0.0:3000"` (the container bind address) when the proxy doesn't pass `X-Forwarded-Host` + `X-Forwarded-Proto` in a way Next trusts. Every refused-callback path then 302s the user to an internal URL their browser can't reach. Same class of bug as the outbound `redirect_uri` before `APP_URL` was threaded through `appOrigin()` — the inbound IdP callback works correctly (uses `APP_URL`), but the post-callback redirect uses `req.nextUrl` raw. Discovered while testing the #100 diagnostic surfacing on a deployed Authentik: `?error=oidc_claim` correctly identified, but the redirect landed on `http://0.0.0.0:3000/login?error=oidc_claim` instead of `https://carol.int.wynning.tech/login?error=oidc_claim`. ## Scope - [ ] Update `redirectTo()` to build the URL against `appOrigin(req)` (which honors `APP_URL` when set, falls back to `req.nextUrl.origin`). - [ ] Update the same-origin defence-in-depth check to compare against the same public origin. - [ ] Add a test confirming `APP_URL` is honored on a refused-callback redirect (e.g. the existing `no_verified_email` path under `APP_URL="https://carol.example.com"`). ## Out of scope - Auto-deriving from `X-Forwarded-*` headers. Still out per the rationale in #99. - Auditing other redirect call sites for the same bug. Not aware of any — `/api/auth/login` and `/api/auth/register` use absolute URLs in `redirect()` that already reference `APP_URL` indirectly. Worth a separate sweep ticket if one's found. ## Acceptance A self-hoster with `APP_URL=https://<host>` set behind a reverse proxy lands on `https://<host>/login?error=...` (not `http://0.0.0.0:3000/...`) after every refused-callback path.
james closed this issue 2026-06-18 12:08:47 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#102
No description provided.