← back to Dw Nextjs Admin Login
src/index.ts
455 lines
/**
* @dw/nextjs-admin-login
*
* Shared admin auth for DW Next.js verticals (Freddy / Grant / Hub / Patty / PoppyPetitions).
* Collapses 5 byte-identical lib/auth.ts files + 5 near-identical login route handlers.
*
* Token format: base64(username:timestamp:sha256(username:timestamp:secret))
* This is the canonical format already live across all 5 verticals — existing sessions
* remain valid after consumers adopt this package.
*/
import { createHash, timingSafeEqual } from 'node:crypto';
// v0.3.0 — capability layer (guest tiers + middleware gate). Additive; opt-in.
export * from './capabilities.js';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface AuthConfig {
/** Required. Cookie name, e.g. 'freddy-auth'. */
cookieName: string;
/** Env var holding the session secret. Default: 'SESSION_SECRET'. */
sessionSecretEnv?: string;
/** Env var holding the expected username. Default: 'AUTH_USERNAME'. */
usernameEnv?: string;
/** Env var holding the expected password. Default: 'AUTH_PASSWORD'. */
passwordEnv?: string;
/** Default username when usernameEnv is unset in env. Default: 'admin'. */
defaultUsername?: string;
/** Session lifetime in seconds. Default: 86400 (24 h). */
maxAgeSeconds?: number;
/**
* Accept Authorization: Basic <b64(user:pass)> in addition to cookie auth.
* Only enable for Poppy / API-to-API callers. Default: false.
*/
enableBasicAuthFallback?: boolean;
/**
* Append Secure flag to Set-Cookie when NODE_ENV === 'production'.
* Default: true (fixes Freddy regression where the flag was absent).
*/
enableSecureCookie?: boolean;
/** Prefix for console.error / console.log lines. Default: '[auth/login]'. */
logTag?: string;
}
/**
* 2026-05-05 (v0.2.0): payload shape for org-scoped sessions.
* orgId is null for legacy 3-part tokens minted before v0.2.0.
*/
export interface AuthSession {
username: string;
orgId: string | null;
}
export interface AuthHelpers {
/** The cookie name passed to createAuth. */
readonly COOKIE_NAME: string;
/**
* Mint a new signed session token for username.
* Format: base64(username:timestamp:sha256(username:timestamp:secret))
*/
createSession(username: string): string;
/**
* Validate a cookie/header on an incoming request.
* Returns the authenticated username, or null if invalid / expired.
*/
verifyAuth(req: Request): string | null;
/** Build the Set-Cookie header value for a new session. */
buildAuthCookie(token: string): string;
/** Build the Set-Cookie header value that clears the session (logout). */
buildLogoutCookie(): string;
/**
* SHA-256 hex digest. Matches the hashPassword implementation
* used by all 5 verticals (not bcrypt — none of the 5 used it).
*/
hashPassword(pw: string): string;
/**
* v0.2.0 — Mint a new signed session token that embeds orgId.
* Format: base64(username:timestamp:orgId:sha256(username:timestamp:orgId:secret))
* Verified by verifyAuthWithOrg. Backward compatible: old verifyAuth
* rejects the 4-part format (returns null), so always issue v2 tokens
* AFTER the consumer has migrated to verifyAuthWithOrg.
*/
createSessionWithOrg(username: string, orgId: string | null): string;
/**
* v0.2.0 — Validate a cookie on an incoming request and return both
* the username AND the embedded orgId. Accepts both v1 (3-part, returns
* orgId: null) and v2 (4-part, returns orgId: string) tokens — so
* existing logged-in browsers don't get bumped at upgrade time.
* Returns null if invalid / expired.
*/
verifyAuthWithOrg(req: Request): AuthSession | null;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Read an env var at call-time (not module-load time) so tests can set
* process.env after import.
*/
function env(name: string): string | undefined {
return process.env[name];
}
function hmac(username: string, timestamp: string, secret: string): string {
return createHash('sha256')
.update(`${username}:${timestamp}:${secret}`)
.digest('hex');
}
// v0.2.0 — HMAC for the org-scoped 4-part token format.
function hmacWithOrg(
username: string,
timestamp: string,
orgId: string,
secret: string,
): string {
return createHash('sha256')
.update(`${username}:${timestamp}:${orgId}:${secret}`)
.digest('hex');
}
function constantTimeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a, 'utf8');
const bb = Buffer.from(b, 'utf8');
// Buffers must be the same length for timingSafeEqual.
// If lengths differ we still do a comparison against a same-length buffer to
// prevent short-circuit timing, then return false.
if (ab.length !== bb.length) {
timingSafeEqual(ab, ab); // burn time
return false;
}
return timingSafeEqual(ab, bb);
}
// ---------------------------------------------------------------------------
// createAuth factory
// ---------------------------------------------------------------------------
export function createAuth(cfg: AuthConfig): AuthHelpers {
const {
cookieName,
sessionSecretEnv = 'SESSION_SECRET',
usernameEnv = 'AUTH_USERNAME',
passwordEnv = 'AUTH_PASSWORD',
defaultUsername = 'admin',
maxAgeSeconds = 86400,
enableBasicAuthFallback = false,
enableSecureCookie = true,
} = cfg;
// ---- session helpers ----
function getSecret(): string {
const s = env(sessionSecretEnv);
if (!s) {
throw new Error(
`${sessionSecretEnv} env var is required (no fallback for production safety)`,
);
}
return s;
}
function _createSession(username: string): string {
const secret = getSecret();
const timestamp = Date.now().toString();
const sig = hmac(username, timestamp, secret);
const payload = `${username}:${timestamp}:${sig}`;
return Buffer.from(payload).toString('base64');
}
function _verifySessionToken(token: string): string | null {
try {
const secret = env(sessionSecretEnv);
if (!secret) return null;
const decoded = Buffer.from(token, 'base64').toString('utf-8');
const parts = decoded.split(':');
// username may itself contain colons only if someone passed a weird
// username, but our createSession always splits into exactly 3 parts
// (username, timestamp, 64-char hex sig). Guard strictly.
if (parts.length !== 3) return null;
const [username, timestamp, signature] = parts as [string, string, string];
const age = Date.now() - parseInt(timestamp, 10);
if (isNaN(age) || age > maxAgeSeconds * 1000) return null;
const expected = hmac(username, timestamp, secret);
if (!constantTimeEqual(signature, expected)) return null;
return username;
} catch {
return null;
}
}
function _verifyAuth(req: Request): string | null {
// Basic auth fallback (opt-in, Poppy only)
if (enableBasicAuthFallback) {
const authHeader = req.headers.get('authorization');
if (authHeader?.startsWith('Basic ')) {
try {
const expectedUser = env(usernameEnv) ?? defaultUsername;
const expectedPass = env(passwordEnv);
if (expectedPass) {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
const colonIdx = decoded.indexOf(':');
if (colonIdx !== -1) {
const user = decoded.slice(0, colonIdx);
const pass = decoded.slice(colonIdx + 1);
if (constantTimeEqual(user, expectedUser) && constantTimeEqual(pass, expectedPass)) {
return user;
}
}
}
} catch {
// fall through to cookie auth
}
}
}
// Cookie auth
const cookieHeader = req.headers.get('cookie');
if (!cookieHeader) return null;
const cookies = Object.fromEntries(
cookieHeader.split(';').map((c) => {
const [key, ...rest] = c.trim().split('=');
return [key ?? '', rest.join('=')];
}),
);
const token = cookies[cookieName];
if (!token) return null;
return _verifySessionToken(token);
}
function _buildAuthCookie(token: string): string {
const useSecure =
enableSecureCookie && process.env['NODE_ENV'] === 'production';
const parts = [
`${cookieName}=${token}`,
`Path=/`,
`HttpOnly`,
`SameSite=Lax`,
`Max-Age=${maxAgeSeconds}`,
];
if (useSecure) parts.push('Secure');
return parts.join('; ');
}
function _buildLogoutCookie(): string {
return `${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
function _hashPassword(pw: string): string {
return createHash('sha256').update(pw).digest('hex');
}
// -------------------------------------------------------------------------
// v0.2.0 — org-scoped session helpers
// -------------------------------------------------------------------------
function _createSessionWithOrg(username: string, orgId: string | null): string {
// If orgId is null we deliberately mint a v1 (3-part) token so a future
// verifyAuthWithOrg call returns orgId: null — same behavior, smaller
// token, no cross-format edge cases. Only emit v2 when orgId is real.
if (orgId === null) {
return _createSession(username);
}
const secret = getSecret();
const timestamp = Date.now().toString();
const sig = hmacWithOrg(username, timestamp, orgId, secret);
const payload = `${username}:${timestamp}:${orgId}:${sig}`;
return Buffer.from(payload).toString('base64');
}
function _verifySessionTokenWithOrg(token: string): AuthSession | null {
try {
const secret = env(sessionSecretEnv);
if (!secret) return null;
const decoded = Buffer.from(token, 'base64').toString('utf-8');
const parts = decoded.split(':');
// v1 (3-part): username:timestamp:sig — orgId unknown, return null orgId.
if (parts.length === 3) {
const username = _verifySessionToken(token);
return username ? { username, orgId: null } : null;
}
// v2 (4-part): username:timestamp:orgId:sig
if (parts.length !== 4) return null;
const [username, timestamp, orgId, signature] = parts as [string, string, string, string];
const age = Date.now() - parseInt(timestamp, 10);
if (isNaN(age) || age > maxAgeSeconds * 1000) return null;
const expected = hmacWithOrg(username, timestamp, orgId, secret);
if (!constantTimeEqual(signature, expected)) return null;
return { username, orgId };
} catch {
return null;
}
}
function _verifyAuthWithOrg(req: Request): AuthSession | null {
// Basic auth fallback (opt-in, Poppy only). Returns no orgId since
// basic-auth carries no embedded session payload.
if (enableBasicAuthFallback) {
const authHeader = req.headers.get('authorization');
if (authHeader?.startsWith('Basic ')) {
try {
const expectedUser = env(usernameEnv) ?? defaultUsername;
const expectedPass = env(passwordEnv);
if (expectedPass) {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
const colonIdx = decoded.indexOf(':');
if (colonIdx !== -1) {
const user = decoded.slice(0, colonIdx);
const pass = decoded.slice(colonIdx + 1);
if (constantTimeEqual(user, expectedUser) && constantTimeEqual(pass, expectedPass)) {
return { username: user, orgId: null };
}
}
}
} catch {
// fall through to cookie auth
}
}
}
const cookieHeader = req.headers.get('cookie');
if (!cookieHeader) return null;
const cookies = Object.fromEntries(
cookieHeader.split(';').map((c) => {
const [key, ...rest] = c.trim().split('=');
return [key ?? '', rest.join('=')];
}),
);
const token = cookies[cookieName];
if (!token) return null;
return _verifySessionTokenWithOrg(token);
}
return {
COOKIE_NAME: cookieName,
createSession: _createSession,
verifyAuth: _verifyAuth,
buildAuthCookie: _buildAuthCookie,
buildLogoutCookie: _buildLogoutCookie,
hashPassword: _hashPassword,
createSessionWithOrg: _createSessionWithOrg,
verifyAuthWithOrg: _verifyAuthWithOrg,
};
}
// ---------------------------------------------------------------------------
// createLoginHandler — replaces app/api/auth/login/route.ts in each project
// ---------------------------------------------------------------------------
/**
* NextRequest / NextResponse are peer-dep types from 'next/server'.
* We use the global Request/Response interfaces here and cast at the boundary
* so this package has no hard runtime dependency on Next.js itself.
*
* In the consumer's route.ts:
* import { NextRequest, NextResponse } from 'next/server';
* export const POST = createLoginHandler(helpers, cfg) as (req: NextRequest) => Promise<NextResponse>;
*/
type NextRequestLike = Request & {
json(): Promise<unknown>;
};
type NextResponseLike = Response;
interface NextResponseConstructor {
json(body: unknown, init?: ResponseInit): NextResponseLike;
}
/**
* Resolve NextResponse at runtime from 'next/server' so we don't
* import Next.js at module-load time (keeps the package installable outside a
* Next.js project and lets unit tests stub the peer dep via a loader hook).
*
* Using new Function('specifier', 'return import(specifier)') prevents
* TypeScript from statically resolving 'next/server' at compile time, so the
* build succeeds even without next installed in this package's node_modules.
*/
const dynamicImport = new Function('specifier', 'return import(specifier)') as
(s: string) => Promise<unknown>;
async function getNR(): Promise<NextResponseConstructor> {
const mod = (await dynamicImport('next/server')) as { NextResponse: NextResponseConstructor };
return mod.NextResponse;
}
export function createLoginHandler(
helpers: AuthHelpers,
cfg: AuthConfig,
): (req: Request) => Promise<Response> {
const tag = cfg.logTag ?? '[auth/login]';
const usernameEnv = cfg.usernameEnv ?? 'AUTH_USERNAME';
const passwordEnv = cfg.passwordEnv ?? 'AUTH_PASSWORD';
const defaultUsername = cfg.defaultUsername ?? 'admin';
return async function POST(req: NextRequestLike): Promise<NextResponseLike> {
const NR = await getNR();
try {
const body = (await req.json()) as Record<string, unknown>;
const { username, password } = body as { username?: string; password?: string };
if (!username || !password) {
return NR.json({ error: 'Username and password are required' }, { status: 400 });
}
const expectedUsername = process.env[usernameEnv] ?? defaultUsername;
const expectedPassword = process.env[passwordEnv];
if (!expectedPassword) {
console.error(`${tag} ${passwordEnv} env unset — refusing all logins (fail-closed)`);
return NR.json({ error: 'Server misconfigured' }, { status: 500 });
}
// Constant-time compare for both fields.
const userOk = constantTimeEqual(username, expectedUsername);
const passOk = constantTimeEqual(password, expectedPassword);
if (!userOk || !passOk) {
return NR.json({ error: 'Invalid credentials' }, { status: 401 });
}
const token = helpers.createSession(username);
const cookieValue = helpers.buildAuthCookie(token);
const response = NR.json({ success: true });
response.headers.set('Set-Cookie', cookieValue);
return response;
} catch (err) {
console.error(`${tag} Error:`, (err as Error).message);
return NR.json({ error: 'Internal server error' }, { status: 500 });
}
};
}