From 42284be643c3b76dffa0cc1256b8974d4e96ce14 Mon Sep 17 00:00:00 2001 From: Andrew Yang Date: Sun, 19 Apr 2026 16:19:33 -0400 Subject: [PATCH 1/6] Harden dev auth bypass and fix marketing NEXT_PUBLIC_ build Dev auto-login bypass in apps/api/lib/user.ts previously triggered on NODE_ENV=development alone, which is load-bearing: any environment that accidentally sets NODE_ENV=development would serve every request as dev@baseline.local. Now requires all three: - NODE_ENV === 'development' - BASELIE_DEV_AUTO_LOGIN === 'true' (explicit opt-in) - DATABASE_URL containing 'localhost' (defense in depth against pointing a dev-mode container at a shared/prod database) Also logs a warning every time the bypass fires so it can't be forgotten. Separately, fix the marketing Docker build so NEXT_PUBLIC_GITHUB_USERNAME actually reaches Next.js at build time. Next.js inlines NEXT_PUBLIC_* vars into the client bundle during `next build`, but docker-compose was only passing the value as a runtime env, so the fallback literal "YOUR_GITHUB_USERNAME" was being baked into the compiled bundle. - apps/marketing/Dockerfile: accept ARG in builder stage, export as ENV - docker-compose.yml: pass NEXT_PUBLIC_GITHUB_USERNAME via build.args Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/lib/user.ts | 16 ++++++++++++---- apps/marketing/Dockerfile | 2 ++ docker-compose.yml | 2 ++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/api/lib/user.ts b/apps/api/lib/user.ts index 4e74d85..cb0b31f 100644 --- a/apps/api/lib/user.ts +++ b/apps/api/lib/user.ts @@ -3,20 +3,28 @@ import { db, users } from '@baseline/db'; import { eq } from 'drizzle-orm'; export async function getCurrentUserId(): Promise { - // Try real auth session first const session = await auth(); if (session?.user?.id) { return session.user.id; } - // Dev fallback: use seed user - if (process.env.NODE_ENV === 'development') { + if ( + process.env.NODE_ENV === 'development' && + process.env.BASELINE_DEV_AUTO_LOGIN === 'true' && + process.env.DATABASE_URL?.includes('localhost') + ) { const [user] = await db .select({ id: users.id }) .from(users) .where(eq(users.email, 'dev@baseline.local')) .limit(1); - if (user) return user.id; + if (user) { + console.warn( + '[auth] ⚠️ dev auto-login active — request resolved to dev@baseline.local. ' + + 'Unset BASELINE_DEV_AUTO_LOGIN to disable.', + ); + return user.id; + } } throw new Error('Unauthorized'); diff --git a/apps/marketing/Dockerfile b/apps/marketing/Dockerfile index ca2e62b..4823770 100644 --- a/apps/marketing/Dockerfile +++ b/apps/marketing/Dockerfile @@ -17,6 +17,8 @@ COPY --from=deps /app/ ./ COPY apps/marketing ./apps/marketing COPY pnpm-workspace.yaml package.json turbo.json tsconfig.base.json ./ +ARG NEXT_PUBLIC_GITHUB_USERNAME +ENV NEXT_PUBLIC_GITHUB_USERNAME=${NEXT_PUBLIC_GITHUB_USERNAME} ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production diff --git a/docker-compose.yml b/docker-compose.yml index 08f31b3..3d27c25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,8 @@ services: build: context: . dockerfile: apps/marketing/Dockerfile + args: + NEXT_PUBLIC_GITHUB_USERNAME: ${GITHUB_USERNAME:-} container_name: baseline-marketing restart: unless-stopped environment: From 45dc6d7108f0895dbcd6afa4900504094c693af5 Mon Sep 17 00:00:00 2001 From: Andrew Yang Date: Sun, 19 Apr 2026 16:24:45 -0400 Subject: [PATCH 2/6] Fix sign-in bypass when a prior session cookie exists The sign-in handler POSTed credentials to /api/auth/callback/credentials but ignored the response, then checked /api/auth/session to decide success. Because next-auth's session endpoint honors any valid session cookie sent along, a user with a previous valid session could submit any password (including nonsense) and be treated as signed-in: the session check picked up the stale cookie, the sentinel baseline-session cookie got set, and middleware let them through. Three changes: 1. Explicitly sign out and clear baseline-session BEFORE attempting the new sign-in, so the subsequent session check only reflects this attempt. 2. Parse the credentials callback response; treat an error url or missing url as a failed sign-in and bail. 3. Secondary verification: after the callback succeeds, confirm session.user.email matches the submitted email (case-insensitive) before setting the session cookie. Any one of these defenses would block the bypass; combining them is defense in depth against future regressions. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/app/sign-in/page.tsx | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/sign-in/page.tsx b/apps/web/src/app/sign-in/page.tsx index b400102..a4c4959 100644 --- a/apps/web/src/app/sign-in/page.tsx +++ b/apps/web/src/app/sign-in/page.tsx @@ -22,7 +22,18 @@ export default function SignIn() { }); const { csrfToken } = await csrfRes.json(); - await fetch(`${apiUrl}/api/auth/callback/credentials`, { + // Clear any pre-existing session so the subsequent session check only + // reflects the current login attempt (defends against a stale session + // cookie making any password "succeed"). + await fetch(`${apiUrl}/api/auth/signout`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ csrfToken, redirect: 'false' }), + credentials: 'include', + }); + document.cookie = 'baseline-session=; path=/; max-age=0'; + + const callbackRes = await fetch(`${apiUrl}/api/auth/callback/credentials`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ email, password, csrfToken, redirect: 'false' }), @@ -30,13 +41,26 @@ export default function SignIn() { redirect: 'manual', }); - // Check if session cookie was set by verifying with the session endpoint + // NextAuth v5 with redirect:false returns JSON with either `url` (success) + // or an error signaled by `url` pointing at /api/auth/error or an empty url. + const callbackData = await callbackRes.json().catch(() => ({} as { url?: string | null })); + const callbackUrl = callbackData.url ?? ''; + const signInFailed = !callbackUrl || callbackUrl.includes('/api/auth/error'); + + if (signInFailed) { + setError('Invalid email or password'); + return; + } + + // Secondary verification: confirm the freshly-issued session matches the + // email that was just submitted. Guards against any residual session. const sessionRes = await fetch(`${apiUrl}/api/auth/session`, { credentials: 'include', + cache: 'no-store', }); const session = await sessionRes.json(); - if (session?.user) { + if (session?.user?.email?.toLowerCase() === email.toLowerCase()) { document.cookie = `baseline-session=true; path=/; max-age=${60 * 60 * 24 * 30}`; window.location.href = '/'; } else { From 175e27ed03762d9be96ad7c37fff143ee04ccd6a Mon Sep 17 00:00:00 2001 From: Andrew Yang Date: Sun, 19 Apr 2026 16:33:05 -0400 Subject: [PATCH 3/6] Replace sentinel-cookie auth with real NextAuth JWT verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous design used a client-settable `baseline-session` cookie (value "true") as the sole signal for middleware to decide auth. Two problems: 1. The cookie was decoupled from the real NextAuth session — it could live on after the real session was invalidated, or be missing while the real session was valid. 2. It was trivially forgeable: `document.cookie = "baseline-session=true"` in DevTools was enough to bypass middleware and land on the dashboard. This replaces the sentinel with a real JWT check using getToken() from next-auth/jwt, which cryptographically verifies the session cookie against AUTH_SECRET. No client-controllable bypass. Changes: - apps/web/middleware.ts: getToken() with explicit cookieName + salt, derived from AUTH_URL/NEXT_PUBLIC_API_URL scheme (http vs https) - apps/web/src/app/sign-in/page.tsx: stop writing baseline-session - apps/web/src/app/sign-up/page.tsx: stop writing baseline-session - apps/web/src/app/components/sidebar.tsx: sign out now POSTs to /api/auth/signout (the prior client-side cookie clearing was a no-op for HttpOnly NextAuth cookies) - docker-compose.yml: AUTH_SECRET added to web service env so the web middleware can verify JWTs signed by the API Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/middleware.ts | 19 ++++++++++++++++--- apps/web/src/app/components/sidebar.tsx | 16 ++++++++++++---- apps/web/src/app/sign-in/page.tsx | 2 -- apps/web/src/app/sign-up/page.tsx | 1 - docker-compose.yml | 1 + 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 91427da..2be0595 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,18 +1,31 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import { getToken } from 'next-auth/jwt'; const publicPaths = ['/sign-in', '/sign-up']; -export function middleware(request: NextRequest) { +export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; if (publicPaths.some((p) => pathname.startsWith(p))) { return NextResponse.next(); } - const hasSession = request.cookies.get('baseline-session')?.value; + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + const secureCookie = apiUrl.startsWith('https://'); + const cookieName = secureCookie + ? '__Secure-authjs.session-token' + : 'authjs.session-token'; - if (!hasSession) { + const token = await getToken({ + req: request, + secret: process.env.AUTH_SECRET, + secureCookie, + cookieName, + salt: cookieName, + }); + + if (!token) { return NextResponse.redirect(new URL('/sign-in', request.url)); } diff --git a/apps/web/src/app/components/sidebar.tsx b/apps/web/src/app/components/sidebar.tsx index 7af2969..cf0e239 100644 --- a/apps/web/src/app/components/sidebar.tsx +++ b/apps/web/src/app/components/sidebar.tsx @@ -45,10 +45,18 @@ export function Sidebar() {