Skip to content

Commit 809f875

Browse files
fix(web): auth callback redirects to public origin + failure-reason surfacing (#212)
Behind nginx + Cloudflare Tunnel, request.url resolves to http://0.0.0.0:3000, so every redirect out of /auth/callback (success AND failure) sent users to a dead address. Google OAuth (enabled in Supabase 2026-07-11) surfaced it: sign-in ended at 0.0.0.0:3000/login?error=auth_callback_failed. - redirect via NEXT_PUBLIC_APP_URL (deploy-time config), request origin only as local-dev fallback; open-redirect guard on ?next= - forward the exchange failure reason (?reason=<code>) and render it on the login page so the next field failure self-diagnoses from the URL bar - surface Supabase error params (error_code/error_description) when the provider flow lands here without a code - tolerate duplicate callback hits: if the code is already consumed but a session exists, proceed as success instead of erroring an authed user - nginx: also forward X-Forwarded-Host
1 parent 442bef6 commit 809f875

4 files changed

Lines changed: 81 additions & 31 deletions

File tree

docker/nginx.prod.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ http {
1818

1919
proxy_http_version 1.1;
2020
proxy_set_header Host $host;
21+
proxy_set_header X-Forwarded-Host $host;
2122
proxy_set_header X-Real-IP $remote_addr;
2223
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
2324
proxy_set_header X-Forwarded-Proto $scheme;

src/StackAlchemist.Web/__tests__/auth/login-callback-error.test.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { describe, it, expect, vi } from "vitest";
22
import { render, screen, fireEvent } from "@testing-library/react";
33

4-
// Stub useSearchParams to inject ?error=auth_callback_failed
4+
// Stub useSearchParams to inject ?error=auth_callback_failed (mutable so
5+
// individual tests can add ?reason=)
6+
const mockParams = vi.hoisted(() => ({ value: "error=auth_callback_failed" }));
57
vi.mock("next/navigation", () => ({
6-
useSearchParams: () => new URLSearchParams("error=auth_callback_failed"),
8+
useSearchParams: () => new URLSearchParams(mockParams.value),
79
useRouter: () => ({ push: vi.fn() }),
810
}));
911

@@ -21,6 +23,13 @@ describe("Login — ?error=auth_callback_failed", () => {
2123
expect(screen.getByText(/sign-in link expired or invalid/i)).toBeInTheDocument();
2224
});
2325

26+
it("shows the failure reason when /auth/callback forwards one", () => {
27+
mockParams.value = "error=auth_callback_failed&reason=flow_state_not_found";
28+
render(<LoginPage />);
29+
expect(screen.getByText(/flow_state_not_found/)).toBeInTheDocument();
30+
mockParams.value = "error=auth_callback_failed";
31+
});
32+
2433
it("clicking 'send a new magic link' switches to magic mode", () => {
2534
render(<LoginPage />);
2635
fireEvent.click(screen.getByRole("button", { name: /send a new magic link/i }));

src/StackAlchemist.Web/src/app/auth/callback/route.ts

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,46 +8,81 @@ import { NextResponse, type NextRequest } from "next/server";
88
* Handles the PKCE code-exchange step for:
99
* - Magic-link sign-ins (Supabase sends ?code=... after OTP verification)
1010
* - Email confirmations (new accounts click the confirmation email)
11-
* - OAuth providers (future: GitHub, Google)
11+
* - OAuth providers (Google enabled 2026-07-11; Apple)
1212
*
1313
* The `emailRedirectTo` option in signInWithOtp() and signUp() must point here,
1414
* e.g. `${origin}/auth/callback?next=/dashboard`.
1515
*/
1616
export async function GET(request: NextRequest) {
1717
const { searchParams, origin } = new URL(request.url);
1818
const code = searchParams.get("code");
19-
const next = searchParams.get("next") ?? "/";
20-
21-
if (code) {
22-
const cookieStore = await cookies();
23-
24-
const supabase = createServerClient(
25-
process.env.NEXT_PUBLIC_SUPABASE_URL!,
26-
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
27-
{
28-
cookies: {
29-
getAll() {
30-
return cookieStore.getAll();
31-
},
32-
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
33-
cookiesToSet.forEach(({ name, value, options }) =>
34-
cookieStore.set(name, value, options)
35-
);
36-
},
37-
},
38-
}
19+
20+
// Behind nginx + Cloudflare Tunnel the request's own origin resolves to the
21+
// container bind address (http://0.0.0.0:3000) — never a valid browser
22+
// destination. The public origin is deploy-time config; the request origin
23+
// is only a fallback for local dev, where the two match.
24+
const publicOrigin =
25+
process.env.NEXT_PUBLIC_APP_URL?.replace(/\/+$/, "") || origin;
26+
27+
// Open-redirect guard: `next` must be an app-internal absolute path.
28+
const rawNext = searchParams.get("next") ?? "/";
29+
const next =
30+
rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : "/";
31+
32+
const fail = (reason: string) =>
33+
NextResponse.redirect(
34+
`${publicOrigin}/login?error=auth_callback_failed&reason=${encodeURIComponent(reason)}`
3935
);
4036

41-
const { error } = await supabase.auth.exchangeCodeForSession(code);
37+
if (!code) {
38+
// Supabase redirects here with error params instead of a code when the
39+
// provider flow itself failed (e.g. ?error=access_denied&error_code=
40+
// otp_expired). Surface the most specific one instead of a generic hint.
41+
const reason =
42+
searchParams.get("error_code") ??
43+
searchParams.get("error_description") ??
44+
searchParams.get("error") ??
45+
"missing_code";
46+
return fail(reason);
47+
}
48+
49+
const cookieStore = await cookies();
4250

43-
if (!error) {
44-
// Successful exchange — redirect to the intended destination.
45-
return NextResponse.redirect(`${origin}${next}`);
51+
const supabase = createServerClient(
52+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
53+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
54+
{
55+
cookies: {
56+
getAll() {
57+
return cookieStore.getAll();
58+
},
59+
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
60+
cookiesToSet.forEach(({ name, value, options }) =>
61+
cookieStore.set(name, value, options)
62+
);
63+
},
64+
},
4665
}
66+
);
67+
68+
const { error } = await supabase.auth.exchangeCodeForSession(code);
69+
70+
if (!error) {
71+
// Successful exchange — redirect to the intended destination.
72+
return NextResponse.redirect(`${publicOrigin}${next}`);
73+
}
4774

48-
console.error("[auth/callback] Code exchange failed:", error.message);
75+
// A duplicate hit on this route (browser preload, back/forward retry)
76+
// consumes the one-time code on the first pass and fails the second with
77+
// "invalid flow state". If a session already exists the sign-in actually
78+
// succeeded — proceed instead of bouncing an authed user to an error.
79+
const {
80+
data: { user },
81+
} = await supabase.auth.getUser();
82+
if (user) {
83+
return NextResponse.redirect(`${publicOrigin}${next}`);
4984
}
5085

51-
// No code param or exchange failure — send back to login with an error hint.
52-
return NextResponse.redirect(`${origin}/login?error=auth_callback_failed`);
86+
console.error("[auth/callback] Code exchange failed:", error.message);
87+
return fail(error.code ?? error.message);
5388
}

src/StackAlchemist.Web/src/app/login/LoginPageClient.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function LoginPageContent() {
1616
const searchParams = useSearchParams();
1717
const returnTo = searchParams?.get("returnTo") ?? "/";
1818
const callbackError = searchParams?.get("error") === "auth_callback_failed";
19+
const callbackReason = searchParams?.get("reason");
1920

2021
const emailRef = useRef<HTMLInputElement>(null);
2122
const [email, setEmail] = useState("");
@@ -82,7 +83,11 @@ function LoginPageContent() {
8283
{/* Callback error — shown when /auth/callback redirects here with ?error= */}
8384
{callbackError && (
8485
<Alert variant="error" data-testid="login-callback-error">
85-
Sign-in link expired or invalid.{" "}
86+
Sign-in link expired or invalid
87+
{callbackReason ? (
88+
<span className="font-mono text-xs"> ({callbackReason})</span>
89+
) : null}
90+
.{" "}
8691
<button
8792
type="button"
8893
className="underline underline-offset-2 hover:no-underline"

0 commit comments

Comments
 (0)