From c246e0384f7d7a539c0ef6abfd92494ddc56d723 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 3 Jan 2026 13:59:02 -0600 Subject: [PATCH] Add authentication system with session-based auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full auth flows with opaque tokens (not JWT) for easy revocation: - Login/logout with cookie or bearer token support - Registration with email verification - Password reset with one-time tokens - scrypt password hashing (no external deps) New files in express/auth/: - token.ts: 256-bit token generation, SHA-256 hashing - password.ts: scrypt hashing with timing-safe verification - types.ts: Session schemas, token types, input validation - store.ts: AuthStore interface + InMemoryAuthStore - service.ts: AuthService with all auth operations - routes.ts: 6 auth endpoints Modified: - types.ts: Added user field to Call, requireAuth/requirePermission helpers - app.ts: JSON body parsing, populates call.user, handles auth errors - services.ts: Added services.auth - routes.ts: Includes auth routes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- express/app.ts | 41 +++++-- express/auth/index.ts | 10 ++ express/auth/password.ts | 67 +++++++++++ express/auth/routes.ts | 231 ++++++++++++++++++++++++++++++++++++ express/auth/service.ts | 248 +++++++++++++++++++++++++++++++++++++++ express/auth/store.ts | 160 +++++++++++++++++++++++++ express/auth/token.ts | 40 +++++++ express/auth/types.ts | 64 ++++++++++ express/routes.ts | 2 + express/services.ts | 6 + express/types.ts | 39 ++++++ 11 files changed, 898 insertions(+), 10 deletions(-) create mode 100644 express/auth/index.ts create mode 100644 express/auth/password.ts create mode 100644 express/auth/routes.ts create mode 100644 express/auth/service.ts create mode 100644 express/auth/store.ts create mode 100644 express/auth/token.ts create mode 100644 express/auth/types.ts diff --git a/express/app.ts b/express/app.ts index 6117023..8349013 100644 --- a/express/app.ts +++ b/express/app.ts @@ -10,6 +10,8 @@ import { routes } from "./routes"; import { services } from "./services"; // import { URLPattern } from 'node:url'; import { + AuthenticationRequired, + AuthorizationDenied, type Call, type InternalHandler, type Method, @@ -22,6 +24,9 @@ import { const app = express(); +// Parse JSON request bodies +app.use(express.json()); + services.logging.log({ source: "logging", text: ["1"] }); const processedRoutes: { [K in Method]: ProcessedRoute[] } = { GET: [], @@ -52,26 +57,42 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { } console.log("request.originalUrl", request.originalUrl); - console.log("beavis"); - // const p = new URL(request.originalUrl); - // const path = p.pathname; - - // console.log("p, path", p, path) - - console.log("ok"); + // Authenticate the request + const user = await services.auth.validateRequest(request); const req: Call = { pattern: route.path, - // path, path: request.originalUrl, method, parameters: { one: 1, two: 2 }, request, + user, }; - const retval = await route.handler(req); - return retval; + try { + const retval = await route.handler(req); + return retval; + } catch (error) { + // Handle authentication errors + if (error instanceof AuthenticationRequired) { + return { + code: httpCodes.clientErrors.Unauthorized, + contentType: contentTypes.application.json, + result: JSON.stringify({ + error: "Authentication required", + }), + }; + } + if (error instanceof AuthorizationDenied) { + return { + code: httpCodes.clientErrors.Forbidden, + contentType: contentTypes.application.json, + result: JSON.stringify({ error: "Access denied" }), + }; + } + throw error; + } }; for (const [_idx, method] of methodList.entries()) { diff --git a/express/auth/index.ts b/express/auth/index.ts new file mode 100644 index 0000000..383fd8e --- /dev/null +++ b/express/auth/index.ts @@ -0,0 +1,10 @@ +// index.ts +// +// Barrel export for auth module. + +export { hashPassword, verifyPassword } from "./password"; +export { authRoutes } from "./routes"; +export { AuthService } from "./service"; +export { type AuthStore, InMemoryAuthStore } from "./store"; +export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token"; +export * from "./types"; diff --git a/express/auth/password.ts b/express/auth/password.ts new file mode 100644 index 0000000..6e7b93e --- /dev/null +++ b/express/auth/password.ts @@ -0,0 +1,67 @@ +// password.ts +// +// Password hashing using Node.js scrypt (no external dependencies). +// Format: $scrypt$N$r$p$salt$hash (all base64) + +import { + randomBytes, + type ScryptOptions, + scrypt, + timingSafeEqual, +} from "node:crypto"; + +// Configuration +const SALT_LENGTH = 32; +const KEY_LENGTH = 64; +const SCRYPT_PARAMS: ScryptOptions = { + N: 16384, // CPU/memory cost parameter (2^14) + r: 8, // Block size + p: 1, // Parallelization +}; + +// Promisified scrypt with options support +function scryptAsync( + password: string, + salt: Buffer, + keylen: number, + options: ScryptOptions, +): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, keylen, options, (err, derivedKey) => { + if (err) reject(err); + else resolve(derivedKey); + }); + }); +} + +async function hashPassword(password: string): Promise { + const salt = randomBytes(SALT_LENGTH); + const hash = await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS); + + const { N, r, p } = SCRYPT_PARAMS; + return `$scrypt$${N}$${r}$${p}$${salt.toString("base64")}$${hash.toString("base64")}`; +} + +async function verifyPassword( + password: string, + stored: string, +): Promise { + const parts = stored.split("$"); + if (parts[1] !== "scrypt" || parts.length !== 7) { + throw new Error("Invalid password hash format"); + } + + const [, , nStr, rStr, pStr, saltB64, hashB64] = parts; + const salt = Buffer.from(saltB64, "base64"); + const storedHash = Buffer.from(hashB64, "base64"); + + const computedHash = await scryptAsync(password, salt, storedHash.length, { + N: parseInt(nStr, 10), + r: parseInt(rStr, 10), + p: parseInt(pStr, 10), + }); + + return timingSafeEqual(storedHash, computedHash); +} + +export { hashPassword, verifyPassword }; diff --git a/express/auth/routes.ts b/express/auth/routes.ts new file mode 100644 index 0000000..ae93c8f --- /dev/null +++ b/express/auth/routes.ts @@ -0,0 +1,231 @@ +// routes.ts +// +// Authentication route handlers. + +import { z } from "zod"; +import { contentTypes } from "../content-types"; +import { httpCodes } from "../http-codes"; +import { services } from "../services"; +import type { Call, Result, Route } from "../types"; +import { + forgotPasswordInputParser, + loginInputParser, + registerInputParser, + resetPasswordInputParser, +} from "./types"; + +// Helper for JSON responses +const jsonResponse = ( + code: (typeof httpCodes.success)[keyof typeof httpCodes.success], + data: object, +): Result => ({ + code, + contentType: contentTypes.application.json, + result: JSON.stringify(data), +}); + +const errorResponse = ( + code: (typeof httpCodes.clientErrors)[keyof typeof httpCodes.clientErrors], + error: string, +): Result => ({ + code, + contentType: contentTypes.application.json, + result: JSON.stringify({ error }), +}); + +// POST /auth/login +const loginHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { email, password } = loginInputParser.parse(body); + + const result = await services.auth.login(email, password, "cookie", { + userAgent: call.request.get("User-Agent"), + ipAddress: call.request.ip, + }); + + if (!result.success) { + return errorResponse( + httpCodes.clientErrors.Unauthorized, + result.error, + ); + } + + return jsonResponse(httpCodes.success.OK, { + token: result.token, + user: { + id: result.user.id, + email: result.user.email, + displayName: result.user.displayName, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// POST /auth/logout +const logoutHandler = async (call: Call): Promise => { + const token = services.auth.extractToken(call.request); + if (token) { + await services.auth.logout(token); + } + + return jsonResponse(httpCodes.success.OK, { message: "Logged out" }); +}; + +// POST /auth/register +const registerHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { email, password, displayName } = + registerInputParser.parse(body); + + const result = await services.auth.register( + email, + password, + displayName, + ); + + if (!result.success) { + return errorResponse(httpCodes.clientErrors.Conflict, result.error); + } + + // TODO: Send verification email with result.verificationToken + // For now, log it for development + console.log( + `[AUTH] Verification token for ${email}: ${result.verificationToken}`, + ); + + return jsonResponse(httpCodes.success.Created, { + message: + "Registration successful. Please check your email to verify your account.", + user: { + id: result.user.id, + email: result.user.email, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// POST /auth/forgot-password +const forgotPasswordHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { email } = forgotPasswordInputParser.parse(body); + + const result = await services.auth.createPasswordResetToken(email); + + // Always return success (don't reveal if email exists) + if (result) { + // TODO: Send password reset email + console.log( + `[AUTH] Password reset token for ${email}: ${result.token}`, + ); + } + + return jsonResponse(httpCodes.success.OK, { + message: + "If an account exists with that email, a password reset link has been sent.", + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// POST /auth/reset-password +const resetPasswordHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { token, password } = resetPasswordInputParser.parse(body); + + const result = await services.auth.resetPassword(token, password); + + if (!result.success) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + result.error, + ); + } + + return jsonResponse(httpCodes.success.OK, { + message: + "Password has been reset. You can now log in with your new password.", + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// GET /auth/verify-email?token=xxx +const verifyEmailHandler = async (call: Call): Promise => { + const url = new URL(call.path, "http://localhost"); + const token = url.searchParams.get("token"); + + if (!token) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Missing token", + ); + } + + const result = await services.auth.verifyEmail(token); + + if (!result.success) { + return errorResponse(httpCodes.clientErrors.BadRequest, result.error); + } + + return jsonResponse(httpCodes.success.OK, { + message: "Email verified successfully. You can now log in.", + }); +}; + +// Export routes +const authRoutes: Route[] = [ + { path: "/auth/login", methods: ["POST"], handler: loginHandler }, + { path: "/auth/logout", methods: ["POST"], handler: logoutHandler }, + { path: "/auth/register", methods: ["POST"], handler: registerHandler }, + { + path: "/auth/forgot-password", + methods: ["POST"], + handler: forgotPasswordHandler, + }, + { + path: "/auth/reset-password", + methods: ["POST"], + handler: resetPasswordHandler, + }, + { + path: "/auth/verify-email", + methods: ["GET"], + handler: verifyEmailHandler, + }, +]; + +export { authRoutes }; diff --git a/express/auth/service.ts b/express/auth/service.ts new file mode 100644 index 0000000..7064173 --- /dev/null +++ b/express/auth/service.ts @@ -0,0 +1,248 @@ +// service.ts +// +// Core authentication service providing login, logout, registration, +// password reset, and email verification. + +import type { Request as ExpressRequest } from "express"; +import { AnonymousUser, type MaybeUser, type User, type UserId } from "../user"; +import { hashPassword, verifyPassword } from "./password"; +import type { AuthStore } from "./store"; +import { + hashToken, + parseAuthorizationHeader, + SESSION_COOKIE_NAME, +} from "./token"; +import { type TokenId, tokenLifetimes } from "./types"; + +type LoginResult = + | { success: true; token: string; user: User } + | { success: false; error: string }; + +type RegisterResult = + | { success: true; user: User; verificationToken: string } + | { success: false; error: string }; + +type SimpleResult = { success: true } | { success: false; error: string }; + +export class AuthService { + constructor(private store: AuthStore) {} + + // === Login === + + async login( + email: string, + password: string, + authMethod: "cookie" | "bearer", + metadata?: { userAgent?: string; ipAddress?: string }, + ): Promise { + const user = await this.store.getUserByEmail(email); + if (!user) { + return { success: false, error: "Invalid credentials" }; + } + + if (!user.isActive()) { + return { success: false, error: "Account is not active" }; + } + + const passwordHash = await this.store.getUserPasswordHash(user.id); + if (!passwordHash) { + return { success: false, error: "Invalid credentials" }; + } + + const valid = await verifyPassword(password, passwordHash); + if (!valid) { + return { success: false, error: "Invalid credentials" }; + } + + const { token } = await this.store.createSession({ + userId: user.id, + tokenType: "session", + authMethod, + expiresAt: new Date(Date.now() + tokenLifetimes.session), + userAgent: metadata?.userAgent, + ipAddress: metadata?.ipAddress, + }); + + return { success: true, token, user }; + } + + // === Session Validation === + + async validateRequest(request: ExpressRequest): Promise { + // Try cookie first (for web requests) + let token = this.extractCookieToken(request); + + // Fall back to Authorization header (for API requests) + if (!token) { + token = parseAuthorizationHeader(request.get("Authorization")); + } + + if (!token) { + return AnonymousUser; + } + + return this.validateToken(token); + } + + async validateToken(token: string): Promise { + const tokenId = hashToken(token) as TokenId; + const session = await this.store.getSession(tokenId); + + if (!session) { + return AnonymousUser; + } + + if (session.tokenType !== "session") { + return AnonymousUser; + } + + const user = await this.store.getUserById(session.userId as UserId); + if (!user || !user.isActive()) { + return AnonymousUser; + } + + // Update last used (fire and forget) + this.store.updateLastUsed(tokenId).catch(() => {}); + + return user; + } + + private extractCookieToken(request: ExpressRequest): string | null { + const cookies = request.get("Cookie"); + if (!cookies) return null; + + for (const cookie of cookies.split(";")) { + const [name, ...valueParts] = cookie.trim().split("="); + if (name === SESSION_COOKIE_NAME) { + return valueParts.join("="); // Handle = in token value + } + } + return null; + } + + // === Logout === + + async logout(token: string): Promise { + const tokenId = hashToken(token) as TokenId; + await this.store.deleteSession(tokenId); + } + + async logoutAllSessions(userId: UserId): Promise { + return this.store.deleteUserSessions(userId); + } + + // === Registration === + + async register( + email: string, + password: string, + displayName?: string, + ): Promise { + const existing = await this.store.getUserByEmail(email); + if (existing) { + return { success: false, error: "Email already registered" }; + } + + const passwordHash = await hashPassword(password); + const user = await this.store.createUser({ + email, + passwordHash, + displayName, + }); + + // Create email verification token + const { token: verificationToken } = await this.store.createSession({ + userId: user.id, + tokenType: "email_verify", + authMethod: "bearer", + expiresAt: new Date(Date.now() + tokenLifetimes.email_verify), + }); + + return { success: true, user, verificationToken }; + } + + // === Email Verification === + + async verifyEmail(token: string): Promise { + const tokenId = hashToken(token) as TokenId; + const session = await this.store.getSession(tokenId); + + if (!session || session.tokenType !== "email_verify") { + return { + success: false, + error: "Invalid or expired verification token", + }; + } + + if (session.isUsed) { + return { success: false, error: "Token already used" }; + } + + await this.store.updateUserEmailVerified(session.userId as UserId); + await this.store.deleteSession(tokenId); + + return { success: true }; + } + + // === Password Reset === + + async createPasswordResetToken( + email: string, + ): Promise<{ token: string } | null> { + const user = await this.store.getUserByEmail(email); + if (!user) { + // Don't reveal whether email exists + return null; + } + + const { token } = await this.store.createSession({ + userId: user.id, + tokenType: "password_reset", + authMethod: "bearer", + expiresAt: new Date(Date.now() + tokenLifetimes.password_reset), + }); + + return { token }; + } + + async resetPassword( + token: string, + newPassword: string, + ): Promise { + const tokenId = hashToken(token) as TokenId; + const session = await this.store.getSession(tokenId); + + if (!session || session.tokenType !== "password_reset") { + return { success: false, error: "Invalid or expired reset token" }; + } + + if (session.isUsed) { + return { success: false, error: "Token already used" }; + } + + const passwordHash = await hashPassword(newPassword); + await this.store.setUserPassword( + session.userId as UserId, + passwordHash, + ); + + // Invalidate all existing sessions (security: password changed) + await this.store.deleteUserSessions(session.userId as UserId); + + // Delete the reset token + await this.store.deleteSession(tokenId); + + return { success: true }; + } + + // === Token Extraction Helper (for routes) === + + extractToken(request: ExpressRequest): string | null { + // Try Authorization header first + const token = parseAuthorizationHeader(request.get("Authorization")); + if (token) return token; + + // Try cookie + return this.extractCookieToken(request); + } +} diff --git a/express/auth/store.ts b/express/auth/store.ts new file mode 100644 index 0000000..f92dba1 --- /dev/null +++ b/express/auth/store.ts @@ -0,0 +1,160 @@ +// store.ts +// +// Authentication storage interface and in-memory implementation. +// The interface allows easy migration to PostgreSQL later. + +import { User, type UserId } from "../user"; +import { generateToken, hashToken } from "./token"; +import type { AuthMethod, SessionData, TokenId, TokenType } from "./types"; + +// Data for creating a new session (tokenId generated internally) +export type CreateSessionData = { + userId: string; + tokenType: TokenType; + authMethod: AuthMethod; + expiresAt: Date; + userAgent?: string; + ipAddress?: string; +}; + +// Data for creating a new user +export type CreateUserData = { + email: string; + passwordHash: string; + displayName?: string; +}; + +// Abstract interface for auth storage - implement for PostgreSQL later +export interface AuthStore { + // Session operations + createSession( + data: CreateSessionData, + ): Promise<{ token: string; session: SessionData }>; + getSession(tokenId: TokenId): Promise; + updateLastUsed(tokenId: TokenId): Promise; + deleteSession(tokenId: TokenId): Promise; + deleteUserSessions(userId: UserId): Promise; + + // User operations + getUserByEmail(email: string): Promise; + getUserById(userId: UserId): Promise; + createUser(data: CreateUserData): Promise; + getUserPasswordHash(userId: UserId): Promise; + setUserPassword(userId: UserId, passwordHash: string): Promise; + updateUserEmailVerified(userId: UserId): Promise; +} + +// In-memory implementation for development +export class InMemoryAuthStore implements AuthStore { + private sessions: Map = new Map(); + private users: Map = new Map(); + private usersByEmail: Map = new Map(); + private passwordHashes: Map = new Map(); + private emailVerified: Map = new Map(); + + async createSession( + data: CreateSessionData, + ): Promise<{ token: string; session: SessionData }> { + const token = generateToken(); + const tokenId = hashToken(token); + + const session: SessionData = { + tokenId, + userId: data.userId, + tokenType: data.tokenType, + authMethod: data.authMethod, + createdAt: new Date(), + expiresAt: data.expiresAt, + userAgent: data.userAgent, + ipAddress: data.ipAddress, + }; + + this.sessions.set(tokenId, session); + return { token, session }; + } + + async getSession(tokenId: TokenId): Promise { + const session = this.sessions.get(tokenId); + if (!session) return null; + + // Check expiration + if (new Date() > session.expiresAt) { + this.sessions.delete(tokenId); + return null; + } + + return session; + } + + async updateLastUsed(tokenId: TokenId): Promise { + const session = this.sessions.get(tokenId); + if (session) { + session.lastUsedAt = new Date(); + } + } + + async deleteSession(tokenId: TokenId): Promise { + this.sessions.delete(tokenId); + } + + async deleteUserSessions(userId: UserId): Promise { + let count = 0; + for (const [tokenId, session] of this.sessions) { + if (session.userId === userId) { + this.sessions.delete(tokenId); + count++; + } + } + return count; + } + + async getUserByEmail(email: string): Promise { + const userId = this.usersByEmail.get(email.toLowerCase()); + if (!userId) return null; + return this.users.get(userId) ?? null; + } + + async getUserById(userId: UserId): Promise { + return this.users.get(userId) ?? null; + } + + async createUser(data: CreateUserData): Promise { + const user = User.create(data.email, { + displayName: data.displayName, + status: "pending", // Pending until email verified + }); + + this.users.set(user.id, user); + this.usersByEmail.set(data.email.toLowerCase(), user.id); + this.passwordHashes.set(user.id, data.passwordHash); + this.emailVerified.set(user.id, false); + + return user; + } + + async getUserPasswordHash(userId: UserId): Promise { + return this.passwordHashes.get(userId) ?? null; + } + + async setUserPassword(userId: UserId, passwordHash: string): Promise { + this.passwordHashes.set(userId, passwordHash); + } + + async updateUserEmailVerified(userId: UserId): Promise { + this.emailVerified.set(userId, true); + + // Update user status to active + const user = this.users.get(userId); + if (user) { + // Create new user with active status + const updatedUser = User.create(user.email, { + id: user.id, + displayName: user.displayName, + status: "active", + roles: [...user.roles], + permissions: [...user.permissions], + }); + this.users.set(userId, updatedUser); + } + } +} diff --git a/express/auth/token.ts b/express/auth/token.ts new file mode 100644 index 0000000..babe227 --- /dev/null +++ b/express/auth/token.ts @@ -0,0 +1,40 @@ +// token.ts +// +// Token generation and hashing utilities for authentication. +// Raw tokens are never stored - only their SHA-256 hashes. + +import { createHash, randomBytes } from "node:crypto"; + +const TOKEN_BYTES = 32; // 256 bits of entropy + +// Generate a cryptographically secure random token +function generateToken(): string { + return randomBytes(TOKEN_BYTES).toString("base64url"); +} + +// Hash token for storage (never store raw tokens) +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +// Parse token from Authorization header +function parseAuthorizationHeader(header: string | undefined): string | null { + if (!header) return null; + + const parts = header.split(" "); + if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") { + return null; + } + + return parts[1]; +} + +// Cookie name for web sessions +const SESSION_COOKIE_NAME = "diachron_session"; + +export { + generateToken, + hashToken, + parseAuthorizationHeader, + SESSION_COOKIE_NAME, +}; diff --git a/express/auth/types.ts b/express/auth/types.ts new file mode 100644 index 0000000..bcc90f6 --- /dev/null +++ b/express/auth/types.ts @@ -0,0 +1,64 @@ +// types.ts +// +// Authentication types and Zod schemas. + +import { z } from "zod"; + +// Branded type for token IDs (the hash, not the raw token) +export type TokenId = string & { readonly __brand: "TokenId" }; + +// Token types for different purposes +export const tokenTypeParser = z.enum([ + "session", + "password_reset", + "email_verify", +]); +export type TokenType = z.infer; + +// Authentication method - how the token was delivered +export const authMethodParser = z.enum(["cookie", "bearer"]); +export type AuthMethod = z.infer; + +// Session data schema - what gets stored +export const sessionDataParser = z.object({ + tokenId: z.string().min(1), + userId: z.string().min(1), + tokenType: tokenTypeParser, + authMethod: authMethodParser, + createdAt: z.coerce.date(), + expiresAt: z.coerce.date(), + lastUsedAt: z.coerce.date().optional(), + userAgent: z.string().optional(), + ipAddress: z.string().optional(), + isUsed: z.boolean().optional(), // For one-time tokens +}); + +export type SessionData = z.infer; + +// Input validation schemas for auth endpoints +export const loginInputParser = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export const registerInputParser = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().optional(), +}); + +export const forgotPasswordInputParser = z.object({ + email: z.string().email(), +}); + +export const resetPasswordInputParser = z.object({ + token: z.string().min(1), + password: z.string().min(8), +}); + +// Token lifetimes in milliseconds +export const tokenLifetimes: Record = { + session: 30 * 24 * 60 * 60 * 1000, // 30 days + password_reset: 1 * 60 * 60 * 1000, // 1 hour + email_verify: 24 * 60 * 60 * 1000, // 24 hours +}; diff --git a/express/routes.ts b/express/routes.ts index 65b9312..3ef0093 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -2,6 +2,7 @@ import nunjucks from "nunjucks"; import { DateTime } from "ts-luxon"; +import { authRoutes } from "./auth"; import { contentTypes } from "./content-types"; import { multiHandler } from "./handlers"; import { HttpCode, httpCodes } from "./http-codes"; @@ -22,6 +23,7 @@ const okText = (result: string): Result => { }; const routes: Route[] = [ + ...authRoutes, { path: "/slow", methods: ["GET"], diff --git a/express/services.ts b/express/services.ts index cc04dcd..e8e4457 100644 --- a/express/services.ts +++ b/express/services.ts @@ -1,5 +1,6 @@ // services.ts +import { AuthService, InMemoryAuthStore } from "./auth"; import { config } from "./config"; import { getLogs, log } from "./logging"; @@ -26,11 +27,16 @@ const misc = { }, }; +// Initialize auth with in-memory store +const authStore = new InMemoryAuthStore(); +const auth = new AuthService(authStore); + const services = { database, logging, misc, random, + auth, }; export { services }; diff --git a/express/types.ts b/express/types.ts index 2b6c79e..d595c48 100644 --- a/express/types.ts +++ b/express/types.ts @@ -10,6 +10,12 @@ import type { MatchFunction } from "path-to-regexp"; import { z } from "zod"; import { type ContentType, contentTypes } from "./content-types"; import { type HttpCode, httpCodes } from "./http-codes"; +import { + AnonymousUser, + type MaybeUser, + type Permission, + type User, +} from "./user"; const methodParser = z.union([ z.literal("GET"), @@ -32,6 +38,7 @@ export type Call = { method: Method; parameters: object; request: ExpressRequest; + user: MaybeUser; }; export type InternalHandler = (req: ExpressRequest) => Promise; @@ -56,4 +63,36 @@ export type Route = { interruptable?: boolean; }; +// Authentication error classes +export class AuthenticationRequired extends Error { + constructor() { + super("Authentication required"); + this.name = "AuthenticationRequired"; + } +} + +export class AuthorizationDenied extends Error { + constructor() { + super("Authorization denied"); + this.name = "AuthorizationDenied"; + } +} + +// Helper for handlers to require authentication +export function requireAuth(call: Call): User { + if (call.user === AnonymousUser) { + throw new AuthenticationRequired(); + } + return call.user; +} + +// Helper for handlers to require specific permission +export function requirePermission(call: Call, permission: Permission): User { + const user = requireAuth(call); + if (!user.hasPermission(permission)) { + throw new AuthorizationDenied(); + } + return user; +} + export { methodParser, massageMethod };