feat(auth): wire Credentials provider + edge-safe authConfig
- src/auth.config.ts: edge-safe Auth.js config (no pg, no Credentials) imported by the middleware; exports authConfig + isDevLoginEnabled - src/lib/auth.ts: extends authConfig with the Credentials provider (Node-only) for email + password sign-in via users.password_hash - src/middleware.ts: uses authConfig (edge-safe) instead of the full src/lib/auth.ts so the edge bundle stays small - src/actions/auth-actions.ts: adds signInWithCredentials that calls the Credentials provider; signInWithGoogle stays the production path
This commit is contained in:
@@ -2,20 +2,40 @@
|
|||||||
|
|
||||||
import "server-only";
|
import "server-only";
|
||||||
import { signIn, signOut } from "@/lib/auth";
|
import { signIn, signOut } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kick off the Google OAuth flow. Auth.js will redirect to Google's
|
* 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
|
* consent screen and then back to /api/auth/callback/google, which sets
|
||||||
* the session cookie and redirects to /admin.
|
* 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<void> {
|
export async function signInWithGoogle(): Promise<void> {
|
||||||
await signIn("google", { redirectTo: "/admin" });
|
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<void> {
|
||||||
|
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.
|
* Sign out and clear the Auth.js session cookie.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
+75
-70
@@ -1,84 +1,89 @@
|
|||||||
import "server-only";
|
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:
|
* Providers:
|
||||||
* - Google OAuth — only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET
|
* - Google OAuth — active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set.
|
||||||
* 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.
|
* For local dev, run `npm run db:seed` to create the seeded admin user
|
||||||
* The historical Supabase-backed Credentials provider was removed in the
|
* (`admin@route-commerce.local` / `admin`). The `authorize` function
|
||||||
* cleanup pass. New admin users are provisioned manually by an existing
|
* looks up the user by email, verifies the password against the stored
|
||||||
* platform admin via /admin/users (the action creates an `admin_users`
|
* hash, and returns the real user record. No `dev_session` cookie
|
||||||
* row linked to the Google `sub` after the user signs in for the first
|
* bypass; this is real Auth.js sign-in.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import NextAuth, { type DefaultSession } from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import Google from "next-auth/providers/google";
|
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" {
|
function buildCredentialsProvider() {
|
||||||
interface Session {
|
return Credentials({
|
||||||
user: {
|
id: "credentials",
|
||||||
id: string;
|
name: "Email + password",
|
||||||
} & DefaultSession["user"];
|
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 = !!(
|
const providers = [
|
||||||
process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
|
...authConfig.providers,
|
||||||
);
|
...(isDevLoginEnabled() ? [buildCredentialsProvider()] : []),
|
||||||
|
];
|
||||||
const googleProvider = hasGoogleCreds
|
|
||||||
? [
|
|
||||||
Google({
|
|
||||||
clientId: process.env.AUTH_GOOGLE_ID,
|
|
||||||
clientSecret: process.env.AUTH_GOOGLE_SECRET,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
trustHost: true,
|
...authConfig,
|
||||||
providers: googleProvider,
|
providers,
|
||||||
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-11
@@ -1,23 +1,26 @@
|
|||||||
// NextAuth v5 middleware
|
// NextAuth v5 middleware
|
||||||
//
|
//
|
||||||
// Runs on every non-static request. Responsibilities:
|
// Runs on every non-static request in the Edge runtime. It uses a
|
||||||
// 1. Allow Auth.js v5 to read/write its own session cookie
|
// lightweight NextAuth instance built from the edge-safe `authConfig` —
|
||||||
// 2. Protect /admin/* and /wholesale/* — redirect to /login if not authenticated
|
// 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
|
// 3. Redirect away from /login when the user already has a session
|
||||||
// 4. Add a handful of baseline security headers
|
// 4. Add a handful of baseline security headers
|
||||||
//
|
//
|
||||||
// The legacy `dev_session` cookie bypass has been removed. The only way
|
// The legacy `dev_session` cookie bypass has been removed. The only way
|
||||||
// into the admin is through real Auth.js (Google in production; for
|
// into the admin is through real Auth.js — Google in production, or the
|
||||||
// local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
|
// seeded Credentials provider in dev (see `src/lib/auth.ts`).
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import NextAuth from "next-auth";
|
||||||
|
import { authConfig } from "@/auth.config";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const { auth } = NextAuth(authConfig);
|
||||||
|
|
||||||
export default auth((req) => {
|
export default auth((req) => {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user