← back to Dw Nextjs Admin Login
src/capabilities.ts
215 lines
/**
* @dw/nextjs-admin-login — capability layer (added v0.3.0)
*
* Reusable, config-driven capability model for the DW Next.js verticals
* (Freddy / Grant / Hub / Patty / PoppyPetitions). Lets an app define an
* admin login plus any number of lower-privilege "guest tier" logins, then:
*
* - resolve a session username → capability set (getCapabilities),
* - verify a submitted username+password against every active login
* (verify), scrypt-hashed and constant-time, and
* - gate a request by method / AI-route in middleware (capabilityGate).
*
* This module is PURE (only node:crypto) so it is safe to import from
* Next.js middleware (nodejs runtime). It is additive: existing exports
* (createAuth / createLoginHandler) are unchanged, so apps that don't opt in
* are unaffected.
*
* Each guest tier is only an active login when its `passwordEnv` is set in
* the environment — an unset password means that tier cannot authenticate.
*/
import crypto from 'node:crypto';
export type CapabilityRole = 'admin' | 'member' | 'viewer';
export interface Capabilities {
username: string;
role: CapabilityRole;
displayName: string;
/** Sees admin controls / any admin-only UI affordance. */
isAdmin: boolean;
/** May mutate data (non-AI POST/PUT/PATCH/DELETE). */
canWrite: boolean;
/** May call AI (discover/generate) routes. */
canUseAI: boolean;
}
/** A lower-privilege login tier, keyed by username in CapabilityRegistryConfig.guests. */
export interface GuestTier {
role: CapabilityRole;
displayName: string;
canWrite: boolean;
canUseAI: boolean;
/** Env var holding this tier's password. Absent/empty ⇒ tier disabled. */
passwordEnv: string;
}
export interface CapabilityRegistryConfig {
/** Env var holding the admin username. Default 'AUTH_USERNAME'. */
adminUsernameEnv?: string;
/** Fallback admin username when the env is unset. Default 'admin'. */
defaultAdminUsername?: string;
/** Env var holding the admin password. Default 'AUTH_PASSWORD'. */
adminPasswordEnv?: string;
/** Display name for the admin account. Default 'Site Admin'. */
adminDisplayName?: string;
/** Guest tiers keyed by username (e.g. { guest: {...}, viewer: {...} }). */
guests?: Record<string, GuestTier>;
}
export interface CapabilityRegistry {
/** The resolved admin username. */
readonly adminUsername: string;
/** Usernames of every active login (admin + guests with a password set). */
readonly activeUsernames: string[];
/** Resolve capabilities for a session username. Unknown ⇒ most-restrictive. */
getCapabilities(username: string): Capabilities;
/**
* Constant-time multi-credential check. Hashes the password once per active
* login regardless of match so timing does not leak which usernames exist.
* Returns the matched username, or null.
*/
verify(username: string, password: string): string | null;
}
const SCRYPT_N = 16384;
const SCRYPT_KEYLEN = 64;
function deriveHash(plaintext: string, salt: Buffer): Buffer {
return crypto.scryptSync(plaintext, salt, SCRYPT_KEYLEN, { N: SCRYPT_N });
}
function constantTimeEqualString(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) {
crypto.timingSafeEqual(ab, ab); // burn equivalent cycles
return false;
}
return crypto.timingSafeEqual(ab, bb);
}
interface CredRecord {
username: string;
salt: Buffer;
hash: Buffer;
caps: Omit<Capabilities, 'username'>;
}
/**
* Build a capability registry from config + the current environment. Call once
* at module load in the consumer; it snapshots passwords/usernames from env at
* construction time (matching how the rest of this package reads env).
*/
export function createCapabilityRegistry(cfg: CapabilityRegistryConfig = {}): CapabilityRegistry {
const adminUsername =
process.env[cfg.adminUsernameEnv ?? 'AUTH_USERNAME'] || cfg.defaultAdminUsername || 'admin';
const adminPassword = process.env[cfg.adminPasswordEnv ?? 'AUTH_PASSWORD'];
const adminDisplayName = cfg.adminDisplayName ?? 'Site Admin';
const guests = cfg.guests ?? {};
const adminCaps: Omit<Capabilities, 'username'> = {
role: 'admin',
displayName: adminDisplayName,
isAdmin: true,
canWrite: true,
canUseAI: true,
};
// Capability lookup is independent of whether a login is active, so a live
// session always resolves correctly even mid password-rotation.
const capsByUsername = new Map<string, Omit<Capabilities, 'username'>>();
capsByUsername.set(adminUsername, adminCaps);
for (const [username, tier] of Object.entries(guests)) {
if (username === adminUsername) continue; // never let a guest shadow admin
capsByUsername.set(username, {
role: tier.role,
displayName: tier.displayName,
isAdmin: false,
canWrite: tier.canWrite,
canUseAI: tier.canUseAI,
});
}
// Active credential records (only logins with a password present).
const records: CredRecord[] = [];
if (adminPassword) {
const salt = crypto.randomBytes(16);
records.push({ username: adminUsername, salt, hash: deriveHash(adminPassword, salt), caps: adminCaps });
}
for (const [username, tier] of Object.entries(guests)) {
if (username === adminUsername) continue;
const pw = process.env[tier.passwordEnv];
if (pw && pw.length > 0) {
const salt = crypto.randomBytes(16);
records.push({ username, salt, hash: deriveHash(pw, salt), caps: capsByUsername.get(username)! });
}
}
function getCapabilities(username: string): Capabilities {
const caps = capsByUsername.get(username);
if (caps) return { username, ...caps };
// Unknown username — fail safe to the most restrictive set.
return { username, role: 'viewer', displayName: username, isAdmin: false, canWrite: false, canUseAI: false };
}
function verify(username: string, password: string): string | null {
let matched: string | null = null;
for (const rec of records) {
const candidate = deriveHash(password, rec.salt);
const userOk = constantTimeEqualString(username, rec.username);
const passOk = crypto.timingSafeEqual(candidate, rec.hash);
if (userOk && passOk) matched = rec.username;
}
return matched;
}
return {
adminUsername,
activeUsernames: records.map((r) => r.username),
getCapabilities,
verify,
};
}
// ── Middleware capability gate ──────────────────────────────────────────────
export interface CapabilityGateInput {
capabilities: Capabilities;
/** HTTP method of the request. */
method: string;
/** Whether this path is an AI (paid generate/discover) route. */
isAiRoute: boolean;
}
export interface CapabilityGateResult {
/** true ⇒ allow; false ⇒ block with status/error. */
ok: boolean;
status?: number;
error?: string;
}
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
/**
* Authoritative per-request capability check for middleware:
* - AI routes require canUseAI,
* - other mutating methods require canWrite,
* - everything else (reads) is allowed.
* Returns { ok:false, status, error } to block, or { ok:true } to allow.
*/
export function capabilityGate(input: CapabilityGateInput): CapabilityGateResult {
const { capabilities, method, isAiRoute } = input;
if (isAiRoute) {
if (!capabilities.canUseAI) {
return { ok: false, status: 403, error: 'AI features are not available for this account' };
}
return { ok: true };
}
if (MUTATING.has(method) && !capabilities.canWrite) {
return { ok: false, status: 403, error: 'This account is read-only' };
}
return { ok: true };
}