feat(auth): opt-in local auto-login for single-user deployments
deploy / test (push) Successful in 4m51s
deploy / build (push) Successful in 6m33s

Skips the login form and lands on the dashboard. It performs a REAL backend
login — the API still issues and can revoke the session — so this does not
weaken auth; it only removes a form for a deployment with exactly one operator.

Gated on BOTH LOCAL_AUTOLOGIN_EMAIL and LOCAL_AUTOLOGIN_PASSWORD, and refuses
outright in clerk mode. Prod sets neither, so the route 404s there. Two
conditions rather than one flag: a single misread value should not be able to
hand a session to an anonymous visitor.

The route emits a RELATIVE Location — inside the container request.url is the
0.0.0.0:3000 bind, so NextResponse.redirect would send the browser to a host
that only exists in Docker — and the cookie's secure flag keys on
x-forwarded-proto rather than NODE_ENV.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-13 10:47:14 -07:00
co-authored by Claude Opus 5
parent 7cf77a9248
commit 022ef98e44
2 changed files with 85 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
// LOCAL-ONLY auto-login. Mints a real session for a fixed operator so a
// single-user local stack lands on the dashboard instead of the login form.
//
// This is a genuine login against the Rust backend — the same call
// /auth/session makes — not an auth bypass in the server. The backend still
// issues (and can revoke) the session, so nothing here weakens API auth.
//
// OFF unless LOCAL_AUTOLOGIN_EMAIL *and* LOCAL_AUTOLOGIN_PASSWORD are both
// set. Prod (Clerk) never sets them, and `authMode() === "clerk"` refuses
// outright, so this route 404s everywhere but the local box. It is deliberately
// two conditions: a single misread flag should not be able to hand a session to
// an anonymous visitor.
import { NextResponse, type NextRequest } from "next/server";
import { apiOrigin, TOKEN_COOKIE } from "@/lib/api/http";
import { authMode } from "@/lib/auth/mode";
const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
// A RELATIVE Location, deliberately. NextResponse.redirect() needs an absolute
// URL, and inside the container `request.url` is the 0.0.0.0:3000 bind — so
// behind `tailscale serve` it would redirect the browser to a host that only
// exists inside Docker. A relative Location (RFC 7231 §7.1.2) lets the browser
// resolve against whatever origin it actually asked for.
function redirectTo(location: string): NextResponse {
return new NextResponse(null, { status: 307, headers: { location } });
}
export async function GET(request: NextRequest) {
const email = process.env.LOCAL_AUTOLOGIN_EMAIL;
const password = process.env.LOCAL_AUTOLOGIN_PASSWORD;
if (authMode() === "clerk" || !email || !password) {
return new NextResponse(null, { status: 404 });
}
const upstream = await fetch(`${apiOrigin()}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!upstream.ok) {
// Land on the real login form rather than a redirect loop back through the
// middleware — a wrong password here must be visibly a login problem.
return redirectTo("/login?autologin=failed");
}
const { token } = (await upstream.json()) as { token: string };
// `next` is same-origin-checked: an open redirect here would be a way to
// bounce a freshly-minted session cookie off this host.
const requested = request.nextUrl.searchParams.get("next") ?? "/";
const target = requested.startsWith("/") && !requested.startsWith("//") ? requested : "/";
const response = redirectTo(target);
response.cookies.set({
name: TOKEN_COOKIE,
value: token,
httpOnly: true,
sameSite: "lax",
// Mirrors /auth/session. Behind `tailscale serve` the browser hop is HTTPS,
// so a Secure cookie is correct there; over plain http://localhost it would
// never be sent back, hence keying on the forwarded scheme.
secure: request.headers.get("x-forwarded-proto") === "https",
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
});
return response;
}