mike-backend / backend /src /lib /localAuth.ts
bardd's picture
Deploy Mike backend-only Space
06671aa verified
Raw History Blame Contribute Delete
8.58 kB
import crypto from "crypto";
import type { Request } from "express";
import { getDatabasePool } from "./db";
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
const SCRYPT_KEYLEN = 32;
const PASSWORD_PREFIX = "scrypt:v1:";
const PILOT_SIGNUP_LOCK_NAMESPACE = 0x6d696b65; // "mike"
const PILOT_SIGNUP_LOCK_RESOURCE = 0x61757468; // "auth"
export type LocalAuthUser = {
id: string;
email: string;
is_operator: boolean;
disabled: boolean;
};
function sessionDays(): number {
const parsed = parseInt(process.env.LOCAL_AUTH_SESSION_DAYS ?? "14", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 14;
}
export function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16);
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, SCRYPT_KEYLEN, SCRYPT_PARAMS, (err, derived) => {
if (err) {
reject(err);
return;
}
resolve(
`${PASSWORD_PREFIX}${salt.toString("base64url")}:${derived.toString("base64url")}`,
);
});
});
}
export async function verifyPassword(
password: string,
storedHash: string,
): Promise<boolean> {
if (!storedHash.startsWith(PASSWORD_PREFIX)) {
return false;
}
const payload = storedHash.slice(PASSWORD_PREFIX.length);
const separator = payload.indexOf(":");
if (separator <= 0) {
return false;
}
const salt = Buffer.from(payload.slice(0, separator), "base64url");
const expected = Buffer.from(payload.slice(separator + 1), "base64url");
if (expected.length !== SCRYPT_KEYLEN) {
return false;
}
const derived = await new Promise<Buffer>((resolve, reject) => {
crypto.scrypt(password, salt, SCRYPT_KEYLEN, SCRYPT_PARAMS, (err, key) => {
if (err) {
reject(err);
return;
}
resolve(key);
});
});
return crypto.timingSafeEqual(expected, derived);
}
export function hashSessionToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function generateSessionToken(): string {
return crypto.randomBytes(32).toString("base64url");
}
export function generateUserId(): string {
return crypto.randomUUID();
}
export function getPilotConfig() {
const enabled = process.env.PILOT_SIGNUP_ENABLED === "1";
const limit = Math.max(
0,
parseInt(process.env.PILOT_SIGNUP_LIMIT ?? "6", 10) || 0,
);
const exemptEmails = (process.env.PILOT_SIGNUP_EXEMPT_EMAILS ?? "")
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean);
return { enabled, limit, exemptEmails };
}
export function isExemptEmail(email: string, exemptEmails: string[]): boolean {
return exemptEmails.includes(email.toLowerCase());
}
export async function deleteExpiredSessions(): Promise<void> {
await getDatabasePool().query(
`DELETE FROM auth.sessions WHERE expires_at <= now()`,
);
}
export async function countPilotUsers(exemptEmails: string[]): Promise<number> {
const result = await getDatabasePool().query<{ count: string }>(
`SELECT count(*)::text AS count
FROM auth.users
WHERE disabled = false
AND is_operator = false
AND NOT (lower(email) = ANY($1::text[]))`,
[exemptEmails],
);
return parseInt(result.rows[0]?.count ?? "0", 10) || 0;
}
export async function getSignupStatus() {
const { enabled, limit, exemptEmails } = getPilotConfig();
if (!enabled) {
return { enabled: false, limit, used: 0, remaining: 0 };
}
await deleteExpiredSessions();
const used = await countPilotUsers(exemptEmails);
const remaining = Math.max(0, limit - used);
return { enabled: true, limit, used, remaining };
}
type CreateLocalUserOptions = {
isOperator?: boolean;
skipCapCheck?: boolean;
};
export async function createLocalUser(
email: string,
password: string,
options: CreateLocalUserOptions = {},
): Promise<LocalAuthUser> {
const normalizedEmail = email.trim().toLowerCase();
const passwordHash = await hashPassword(password);
const userId = generateUserId();
const isOperator = options.isOperator === true;
const pool = getDatabasePool();
const client = await pool.connect();
try {
await client.query("BEGIN");
if (!options.skipCapCheck && !isOperator) {
const { enabled, limit, exemptEmails } = getPilotConfig();
if (!enabled) {
throw new Error("Pilot signup is closed");
}
if (isExemptEmail(normalizedEmail, exemptEmails)) {
throw new Error(
"This email is reserved for operator access and cannot use pilot signup",
);
}
await client.query(`SELECT pg_advisory_xact_lock($1::integer, $2::integer)`, [
PILOT_SIGNUP_LOCK_NAMESPACE,
PILOT_SIGNUP_LOCK_RESOURCE,
]);
const countResult = await client.query<{ count: string }>(
`SELECT count(*)::text AS count
FROM auth.users
WHERE disabled = false
AND is_operator = false
AND NOT (lower(email) = ANY($1::text[]))`,
[exemptEmails],
);
const used = parseInt(countResult.rows[0]?.count ?? "0", 10) || 0;
if (used >= limit) {
throw new Error("Pilot signup is full");
}
}
const insertResult = await client.query<LocalAuthUser>(
`INSERT INTO auth.users (id, email, password_hash, is_operator)
VALUES ($1, $2, $3, $4)
RETURNING id, email, is_operator, disabled`,
[userId, normalizedEmail, passwordHash, isOperator],
);
await client.query("COMMIT");
return insertResult.rows[0];
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
export async function findUserByEmail(
email: string,
): Promise<(LocalAuthUser & { password_hash: string | null }) | null> {
const result = await getDatabasePool().query<
LocalAuthUser & { password_hash: string | null }
>(
`SELECT id, email, password_hash, is_operator, disabled
FROM auth.users
WHERE lower(email) = lower($1)
LIMIT 1`,
[email.trim()],
);
return result.rows[0] ?? null;
}
export async function createSession(
userId: string,
reqMeta?: { userAgent?: string; ip?: string },
): Promise<{ token: string; expiresAt: Date }> {
const token = generateSessionToken();
const tokenHash = hashSessionToken(token);
const expiresAt = new Date(
Date.now() + sessionDays() * 24 * 60 * 60 * 1000,
);
await getDatabasePool().query(
`INSERT INTO auth.sessions (token_hash, user_id, expires_at, user_agent, ip)
VALUES ($1, $2, $3, $4, $5)`,
[
tokenHash,
userId,
expiresAt.toISOString(),
reqMeta?.userAgent ?? null,
reqMeta?.ip ?? null,
],
);
return { token, expiresAt };
}
export async function verifySessionToken(
token: string,
): Promise<LocalAuthUser | null> {
const tokenHash = hashSessionToken(token);
const result = await getDatabasePool().query<LocalAuthUser>(
`SELECT u.id, u.email, u.is_operator, u.disabled
FROM auth.sessions s
JOIN auth.users u ON u.id = s.user_id
WHERE s.token_hash = $1
AND s.expires_at > now()
LIMIT 1`,
[tokenHash],
);
const user = result.rows[0];
if (!user || user.disabled) {
return null;
}
return user;
}
export async function deleteSession(token: string): Promise<void> {
const tokenHash = hashSessionToken(token);
await getDatabasePool().query(`DELETE FROM auth.sessions WHERE token_hash = $1`, [
tokenHash,
]);
}
export async function deleteUserSessions(userId: string): Promise<void> {
await getDatabasePool().query(`DELETE FROM auth.sessions WHERE user_id = $1`, [
userId,
]);
}
export async function updateUserPassword(
email: string,
password: string,
): Promise<boolean> {
const passwordHash = await hashPassword(password);
const result = await getDatabasePool().query(
`UPDATE auth.users
SET password_hash = $1, updated_at = now()
WHERE lower(email) = lower($2)`,
[passwordHash, email.trim()],
);
return (result.rowCount ?? 0) > 0;
}
export function getRequestMeta(req: Request) {
const forwarded = req.headers["x-forwarded-for"];
const ip =
typeof forwarded === "string"
? forwarded.split(",")[0]?.trim()
: req.socket.remoteAddress;
return {
userAgent:
typeof req.headers["user-agent"] === "string"
? req.headers["user-agent"]
: undefined,
ip,
};
}
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}