Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

Our proxy writes down where you were going on every bounce to the login form, and the login form has never read it

Every signed-out request for a private page on PubTrivia gets the same treatment, and you can see it from anywhere: $ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://pub-trivia.app/dashboard 307 https

Every signed-out request for a private page on PubTrivia gets the same treatment, and you can see it from anywhere:

$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://pub-trivia.app/dashboard
307 https://pub-trivia.app/login?next=%2Fdashboard

That next is deliberate. The code that writes it says what it is for:

// Unauthenticated users hitting protected routes β†’ redirect to login,
// preserving the original destination for post-login redirect.
if (!user && !isPublicRoute(pathname)) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    url.searchParams.set('next', pathname)
    return NextResponse.redirect(url)
}

The login page's props are the next thing to read:

export default async function Login({
    searchParams,
}: {
    searchParams: Promise<{ reason?: string; invite?: string }>
}) {
    const { reason, invite } = await searchParams

reason drives the session limit banner. invite is forwarded to the signup link so an invited host does not lose their token by clicking the wrong one of the two. There is no next in the type, because there is no next in the page.

Nor in the form. components/LoginForm.tsx is 52 lines: two inputs, a submit button, a useActionState wired to a server action, and a useEffect that raises a toast when the action returns a message. It imports no router and reads no search params. And the action it posts to ends like this:

const { error } = await supabase.auth.signInWithPassword({
    email: formData.get('email') as string,
    password: formData.get('password') as string,
})

if (error) return { message: error.message }

revalidatePath('/', 'layout')
redirect('/dashboard')

So the parameter is written on every gated request, carried through a redirect, rendered into the address bar, and then dropped. Follow a bookmark to the billing screen, sign in, and you arrive at the dashboard home with no explanation. It is a small and extremely common papercut: the mechanism that exists to remove it is there, visible in the URL, and not plugged in at the far end.

The only next we actually honour

One route does read one. app/auth/callback/route.ts, which is where Supabase sends people after an email confirmation or an OAuth round trip:

const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
// if "next" is in param, use it as the redirect URL
const next = searchParams.get('next') ?? '/'

That one has a real consumer. The signup action builds it so an invited host lands on the accept-invite route the moment their email is confirmed:

const nextPath = data.inviteToken
    ? `/auth/invite?token=${data.inviteToken}`
    : '/dashboard'

await supabase.auth.signUp({
    email: data.email,
    password: data.password,
    options: {
        emailRedirectTo: `${PUBLIC_URL}/auth/callback?next=${encodeURIComponent(nextPath)}`,
        ...

Two paths, both explicit. Which leaves the ?? '/' default for everybody who arrives at the callback without one, and the Google button is exactly that case:

const { data } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: {
        redirectTo: `${PUBLIC_URL}/auth/callback`,
    },
})

No next, so next is /, so the callback redirects to the homepage. The homepage is not where a signed-in user goes, and the proxy knows it:

// Authenticated users hitting the homepage β†’ send straight to dashboard.
if (user && pathname === '/') {
    const url = request.nextUrl.clone()
    url.pathname = '/dashboard'
    return NextResponse.redirect(url)
}

Every Google sign-in therefore ends in two redirects where one would do, and the default is correct only because a rule in a different file quietly covers for it. That rule is the one the destination should have named in the first place.

The failure branch points at a page we never wrote

The callback's last line, when there is no code or the exchange fails:

// return the user to an error page with instructions
return NextResponse.redirect(`${origin}/auth/auth-code-error`)

There is no app/auth/auth-code-error. The directory holds callback, invite, and a doubled auth/auth segment containing confirm and logout. Both curls:

$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://pub-trivia.app/auth/callback
307 https://pub-trivia.app/auth/auth-code-error

$ curl -s -o /dev/null -w '%{http_code}\n' https://pub-trivia.app/auth/auth-code-error
404

The comment promises an error page with instructions. What a person with an expired confirmation link actually gets is the generic 404, which inherits the site's default metadata and so arrives titled "PubTrivia | Run Live Pub Quiz Nights". The one moment in the product where somebody needs a sentence of help is the one place we send them to a page with none.

Worth noting what is not wrong here. The invite flow has its own error page, app/auth/invite/error/page.tsx, which does explain itself. The hole is specifically the OAuth and email-confirmation branch.

Plugging the hole costs a validator

The reason not to write redirect(next) the moment you notice this is that next arrives from the query string, and a redirect target from a query string is an open redirect unless somebody checks it. Right now our exposure on the login URL is zero, by accident: the parameter is unreadable because no code reads it.

So the change is two parts, in this order. A validator that accepts a path and nothing else, same origin, leading slash, no scheme, no protocol-relative //evil.example, and that falls back to /dashboard on anything it does not like. Then loginUser takes the destination from the form rather than hardcoding it, and the login page puts it in a hidden field so the server action can see it, since a server action cannot read the page's search params by itself.

The other two are smaller. signInWithGoogle should pass redirectTo: .../auth/callback?next=/dashboard and stop leaning on the homepage bounce. And /auth/auth-code-error should become a real page that says the link has expired and offers to send another, which is three sentences and a link.

The gated redirect is live and public if you want to watch it: ask for the dashboard while signed out, or any of our private paths, and read the next in the Location header that you now know nothing is listening for. The public side of the site, like the quizmaster guide, never touches this path at all.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.