feat(auth): opt-in local auto-login for single-user deployments
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:
co-authored by
Claude Opus 5
parent
7cf77a9248
commit
022ef98e44
@@ -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;
|
||||||
|
}
|
||||||
@@ -38,6 +38,23 @@ export default async function proxy(
|
|||||||
|
|
||||||
// Local mode: key on our own session cookie.
|
// Local mode: key on our own session cookie.
|
||||||
if (!request.cookies.has(SESSION_COOKIE)) {
|
if (!request.cookies.has(SESSION_COOKIE)) {
|
||||||
|
// LOCAL-ONLY: a single-user local stack goes straight to the dashboard.
|
||||||
|
// The route itself re-checks both env vars and 404s without them, so this
|
||||||
|
// redirect is inert in any deployment that has not opted in. Excluded
|
||||||
|
// paths would otherwise loop: /auth/autologin sets the cookie, and /login
|
||||||
|
// must stay reachable to show a failed-autologin message.
|
||||||
|
const path = request.nextUrl.pathname;
|
||||||
|
if (
|
||||||
|
process.env.LOCAL_AUTOLOGIN_EMAIL &&
|
||||||
|
process.env.LOCAL_AUTOLOGIN_PASSWORD &&
|
||||||
|
path !== "/auth/autologin" &&
|
||||||
|
path !== "/login"
|
||||||
|
) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/auth/autologin";
|
||||||
|
url.search = `?next=${encodeURIComponent(path + request.nextUrl.search)}`;
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
const rewrite = marketingRewrite(request);
|
const rewrite = marketingRewrite(request);
|
||||||
if (rewrite) return rewrite;
|
if (rewrite) return rewrite;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user