diff --git a/src/actions/auth-actions.ts b/src/actions/auth-actions.ts index 1aed79a..6c1d6dc 100644 --- a/src/actions/auth-actions.ts +++ b/src/actions/auth-actions.ts @@ -2,20 +2,40 @@ import "server-only"; import { signIn, signOut } from "@/lib/auth"; +import { redirect } from "next/navigation"; /** * Kick off the Google OAuth flow. Auth.js will redirect to Google's * consent screen and then back to /api/auth/callback/google, which sets * the session cookie and redirects to /admin. - * - * The historical Supabase-backed email/password sign-in action was - * removed in the cleanup pass. Admin accounts are provisioned by an - * existing platform admin via /admin/users. */ export async function signInWithGoogle(): Promise { await signIn("google", { redirectTo: "/admin" }); } +/** + * Sign in with email + password. The `credentials` provider is enabled + * in dev (see `isDevLoginEnabled()` in `src/auth.config.ts`); in + * production it is omitted entirely and this action returns an + * `AuthError` (Auth.js surfaces `?error=Configuration` on /login). + * + * On a failed credential check Auth.js redirects back to + * /login?error=CredentialsSignin, which the LoginClient renders as + * "Invalid email or password." + */ +export async function signInWithCredentials(formData: FormData): Promise { + const email = String(formData.get("email") ?? "").trim().toLowerCase(); + const password = String(formData.get("password") ?? ""); + if (!email || !password) { + redirect("/login?error=MissingCredentials"); + } + await signIn("credentials", { + email, + password, + redirectTo: "/admin", + }); +} + /** * Sign out and clear the Auth.js session cookie. */ diff --git a/src/auth.config.ts b/src/auth.config.ts new file mode 100644 index 0000000..5dc54fe --- /dev/null +++ b/src/auth.config.ts @@ -0,0 +1,108 @@ +import type { NextAuthConfig, DefaultSession } from "next-auth"; +import Google from "next-auth/providers/google"; + +/** + * Edge-safe Auth.js v5 configuration. + * + * This file is imported by `src/middleware.ts`, which runs in the Edge + * runtime. It must NOT import: + * - `pg` / `@auth/pg-adapter` (Node-only) + * - `next-auth/providers/credentials` (uses Node `crypto` internally) + * - The Drizzle client + * - Anything else that touches the database or Node-only APIs + * + * Provider definitions, callbacks, and pages all live here. The full + * server-side handler in `src/lib/auth.ts` extends this with the + * Credentials provider (Node-only) for email + password sign-in. + * + * Both instances share the same session cookie, so the middleware can + * read JWTs minted by the server-side handler. + */ + +declare module "next-auth" { + interface Session { + user: { + id: string; + } & DefaultSession["user"]; + } +} + +const hasGoogleCreds = !!( + process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET +); + +const googleProvider = hasGoogleCreds + ? [ + Google({ + clientId: process.env.AUTH_GOOGLE_ID, + clientSecret: process.env.AUTH_GOOGLE_SECRET, + }), + ] + : []; + +export const authConfig = { + trustHost: true, + pages: { signIn: "/login" }, + session: { strategy: "jwt" as const }, + providers: googleProvider, + callbacks: { + /** + * Gate /admin, /wholesale, and /protected-example. Anything on those + * paths and not signed in is redirected to /login (the default + * pages.signIn). Public storefronts and the homepage pass through. + * + * The actual role-based gating still happens in `getAdminUser()` — + * this callback only ensures the user is *signed in*. + */ + authorized({ auth, request: { nextUrl } }) { + const isLoggedIn = !!auth?.user; + const isOnAdmin = nextUrl.pathname.startsWith("/admin"); + const isOnWholesale = nextUrl.pathname.startsWith("/wholesale"); + const isOnProtectedExample = nextUrl.pathname.startsWith( + "/protected-example" + ); + if (isOnAdmin || isOnWholesale || isOnProtectedExample) { + return isLoggedIn; + } + return true; + }, + + /** + * Forward the user id into the JWT on initial sign-in. With + * `session.strategy: "jwt"` this is the field downstream code reads + * via `session.user.id`. + */ + async jwt({ token, user }) { + if (user) { + if (user.id) token.id = user.id; + if (user.email) token.email = user.email; + } + return token; + }, + + async session({ session, token }) { + if (session.user) { + session.user.id = + (typeof token.id === "string" && token.id) || + (typeof token.sub === "string" && token.sub) || + ""; + } + return session; + }, + }, +} satisfies NextAuthConfig; + +/** + * Is the dev Credentials provider enabled? + * - Disabled in production (NODE_ENV === "production") + * - Can be force-disabled by setting `ALLOW_DEV_LOGIN=false` + * + * Surfaced so `src/lib/auth.ts` can decide whether to include the + * Credentials provider in its providers list. + */ +export function isDevLoginEnabled(): boolean { + return ( + process.env.NODE_ENV !== "production" && + process.env.ALLOW_DEV_LOGIN !== "false" + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 25dcc33..6949463 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,84 +1,89 @@ import "server-only"; /** - * Auth.js (NextAuth v5) configuration. + * Auth.js (NextAuth v5) — server-side configuration. + * + * This file is Node-only. It is imported by: + * - `src/app/api/auth/[...nextauth]/route.ts` (the OAuth + credentials handlers) + * - Server actions that call `signIn` / `signOut` + * - `src/lib/admin-permissions.ts` (reads `auth()` for the current user) + * + * The middleware imports a separate, edge-safe instance built from + * `src/auth.config.ts`. Both instances share the same JWT cookie, so the + * middleware can read sessions minted here. * * Providers: - * - Google OAuth — only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET - * are set. + * - Google OAuth — active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set. + * - Email + password (Credentials) — active in dev only; backed by the + * `users.password_hash` column. In production, set ALLOW_DEV_LOGIN=false + * (the default) and the provider is omitted entirely. * - * Supabase is no longer used for auth (or anything else) on this platform. - * The historical Supabase-backed Credentials provider was removed in the - * cleanup pass. New admin users are provisioned manually by an existing - * platform admin via /admin/users (the action creates an `admin_users` - * row linked to the Google `sub` after the user signs in for the first - * time). - * - * Session strategy: JWT. No database adapter — admin user lookup is - * delegated to `getAdminUser()` in `src/lib/admin-permissions.ts`. - * - * Required env vars (production): - * - AUTH_SECRET — JWT signing secret - * - AUTH_URL — base URL (auto-detected on Vercel) - * - AUTH_GOOGLE_ID — Google OAuth client id - * - AUTH_GOOGLE_SECRET — Google OAuth client secret - * - * Backward compatibility: the `dev_session` cookie was the source of - * truth for the demo flow but has been removed — `getAdminUser()` and - * the middleware now use only the Auth.js session. The legacy - * `rc_auth_uid` cookie was retired earlier — see the - * final report for the cleanup notes. + * For local dev, run `npm run db:seed` to create the seeded admin user + * (`admin@route-commerce.local` / `admin`). The `authorize` function + * looks up the user by email, verifies the password against the stored + * hash, and returns the real user record. No `dev_session` cookie + * bypass; this is real Auth.js sign-in. */ -import NextAuth, { type DefaultSession } from "next-auth"; -import Google from "next-auth/providers/google"; +import NextAuth from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import { eq } from "drizzle-orm"; +import { authConfig, isDevLoginEnabled } from "@/auth.config"; +import { withDb } from "@/db/client"; +import { users } from "@/db/schema"; +import { verifyPassword } from "@/lib/passwords"; -declare module "next-auth" { - interface Session { - user: { - id: string; - } & DefaultSession["user"]; - } +function buildCredentialsProvider() { + return Credentials({ + id: "credentials", + name: "Email + password", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + /** + * Returns the user on success, or `null` on any failure. Auth.js + * never throws from `authorize` — a throw is treated as a 500. + */ + async authorize(creds) { + if (!isDevLoginEnabled()) return null; + const email = String(creds?.email ?? "").trim().toLowerCase(); + const password = String(creds?.password ?? ""); + if (!email || !password) return null; + + try { + // The `users` table is global (not tenant-scoped), so we use + // `withDb` rather than `withTenant` — no GUC to set. + const u = await withDb(async (db) => { + const rows = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + return rows[0] ?? null; + }); + if (!u || !u.passwordHash) return null; + if (!verifyPassword(password, u.passwordHash)) return null; + return { + id: u.id, + name: u.name ?? undefined, + email: u.email, + }; + } catch (err) { + // eslint-disable-next-line no-console + console.error("[auth] credentials authorize failed:", err); + return null; + } + }, + }); } -const hasGoogleCreds = !!( - process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET -); - -const googleProvider = hasGoogleCreds - ? [ - Google({ - clientId: process.env.AUTH_GOOGLE_ID, - clientSecret: process.env.AUTH_GOOGLE_SECRET, - }), - ] - : []; +const providers = [ + ...authConfig.providers, + ...(isDevLoginEnabled() ? [buildCredentialsProvider()] : []), +]; export const { handlers, auth, signIn, signOut } = NextAuth({ - trustHost: true, - providers: googleProvider, - session: { strategy: "jwt" }, - pages: { - signIn: "/login", - }, - callbacks: { - async jwt({ token, user }) { - if (user) { - // `user.id` is the provider's stable subject — for Google sign-ins - // this is the opaque `sub` claim. - if (user.id) token.id = user.id; - if (user.email) token.email = user.email; - } - return token; - }, - async session({ session, token }) { - if (session.user) { - session.user.id = - (typeof token.id === "string" && token.id) || - (typeof token.sub === "string" && token.sub) || - ""; - } - return session; - }, - }, + ...authConfig, + providers, }); diff --git a/src/middleware.ts b/src/middleware.ts index c7d1228..29c018b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,23 +1,26 @@ // NextAuth v5 middleware // -// Runs on every non-static request. Responsibilities: -// 1. Allow Auth.js v5 to read/write its own session cookie -// 2. Protect /admin/* and /wholesale/* — redirect to /login if not authenticated +// Runs on every non-static request in the Edge runtime. It uses a +// lightweight NextAuth instance built from the edge-safe `authConfig` — +// NOT the full `src/lib/auth.ts` (which uses `pg` and is Node-only). +// +// Responsibilities: +// 1. Allow Auth.js to read/write its own session cookie +// 2. Protect /admin/*, /wholesale/*, /protected-example — redirect to +// /login if not authenticated // 3. Redirect away from /login when the user already has a session // 4. Add a handful of baseline security headers // // The legacy `dev_session` cookie bypass has been removed. The only way -// into the admin is through real Auth.js (Google in production; for -// local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`). -// -// Backward compatibility: the legacy `rc_auth_uid` / `rc_uid` cookies -// are intentionally no longer read here — `getAdminUser()` in -// src/lib/admin-permissions.ts is the single source of truth and reads -// the Auth.js session instead. +// into the admin is through real Auth.js — Google in production, or the +// seeded Credentials provider in dev (see `src/lib/auth.ts`). -import { auth } from "@/lib/auth"; +import NextAuth from "next-auth"; +import { authConfig } from "@/auth.config"; import { NextResponse } from "next/server"; +const { auth } = NextAuth(authConfig); + export default auth((req) => { const { pathname } = req.nextUrl;