2 Commits

Author SHA1 Message Date
c246e0384f Add authentication system with session-based auth
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 <noreply@anthropic.com>
2026-01-03 13:59:02 -06:00
788ea2ab19 Add User class with role and permission-based authorization
Foundation for authentication/authorization with:
- Stable UUID id for database keys, email as human identifier
- Account status (active/suspended/pending)
- Role-based auth with role-to-permission mappings
- Direct permissions in resource:action format
- Methods: hasRole(), hasPermission(), can(), effectivePermissions()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 12:59:47 -06:00
13 changed files with 1089 additions and 10 deletions

3
.beads/issues.jsonl Normal file
View File

@@ -0,0 +1,3 @@
{"id":"diachron-2vh","title":"Add unit testing to golang programs","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:41.281891462-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:41.281891462-06:00"}
{"id":"diachron-64w","title":"Add unit testing to express backend","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:30.439206099-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:30.439206099-06:00"}
{"id":"diachron-fzd","title":"Add generic 'user' functionality","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:35:53.73213604-06:00","created_by":"mw","updated_at":"2026-01-03T12:35:53.73213604-06:00"}

View File

@@ -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,
};
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()) {

10
express/auth/index.ts Normal file
View File

@@ -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";

67
express/auth/password.ts Normal file
View File

@@ -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<Buffer> {
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<string> {
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<boolean> {
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 };

231
express/auth/routes.ts Normal file
View File

@@ -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<Result> => {
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<Result> => {
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<Result> => {
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<Result> => {
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<Result> => {
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<Result> => {
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 };

248
express/auth/service.ts Normal file
View File

@@ -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<LoginResult> {
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<MaybeUser> {
// 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<MaybeUser> {
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<void> {
const tokenId = hashToken(token) as TokenId;
await this.store.deleteSession(tokenId);
}
async logoutAllSessions(userId: UserId): Promise<number> {
return this.store.deleteUserSessions(userId);
}
// === Registration ===
async register(
email: string,
password: string,
displayName?: string,
): Promise<RegisterResult> {
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<SimpleResult> {
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<SimpleResult> {
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);
}
}

160
express/auth/store.ts Normal file
View File

@@ -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<SessionData | null>;
updateLastUsed(tokenId: TokenId): Promise<void>;
deleteSession(tokenId: TokenId): Promise<void>;
deleteUserSessions(userId: UserId): Promise<number>;
// User operations
getUserByEmail(email: string): Promise<User | null>;
getUserById(userId: UserId): Promise<User | null>;
createUser(data: CreateUserData): Promise<User>;
getUserPasswordHash(userId: UserId): Promise<string | null>;
setUserPassword(userId: UserId, passwordHash: string): Promise<void>;
updateUserEmailVerified(userId: UserId): Promise<void>;
}
// In-memory implementation for development
export class InMemoryAuthStore implements AuthStore {
private sessions: Map<string, SessionData> = new Map();
private users: Map<string, User> = new Map();
private usersByEmail: Map<string, string> = new Map();
private passwordHashes: Map<string, string> = new Map();
private emailVerified: Map<string, boolean> = 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<SessionData | null> {
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<void> {
const session = this.sessions.get(tokenId);
if (session) {
session.lastUsedAt = new Date();
}
}
async deleteSession(tokenId: TokenId): Promise<void> {
this.sessions.delete(tokenId);
}
async deleteUserSessions(userId: UserId): Promise<number> {
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<User | null> {
const userId = this.usersByEmail.get(email.toLowerCase());
if (!userId) return null;
return this.users.get(userId) ?? null;
}
async getUserById(userId: UserId): Promise<User | null> {
return this.users.get(userId) ?? null;
}
async createUser(data: CreateUserData): Promise<User> {
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<string | null> {
return this.passwordHashes.get(userId) ?? null;
}
async setUserPassword(userId: UserId, passwordHash: string): Promise<void> {
this.passwordHashes.set(userId, passwordHash);
}
async updateUserEmailVerified(userId: UserId): Promise<void> {
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);
}
}
}

40
express/auth/token.ts Normal file
View File

@@ -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,
};

64
express/auth/types.ts Normal file
View File

@@ -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<typeof tokenTypeParser>;
// Authentication method - how the token was delivered
export const authMethodParser = z.enum(["cookie", "bearer"]);
export type AuthMethod = z.infer<typeof authMethodParser>;
// 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<typeof sessionDataParser>;
// 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<TokenType, number> = {
session: 30 * 24 * 60 * 60 * 1000, // 30 days
password_reset: 1 * 60 * 60 * 1000, // 1 hour
email_verify: 24 * 60 * 60 * 1000, // 24 hours
};

View File

@@ -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"],

View File

@@ -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 };

View File

@@ -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<Result>;
@@ -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 };

188
express/user.ts Normal file
View File

@@ -0,0 +1,188 @@
// user.ts
//
// User model for authentication and authorization.
//
// Design notes:
// - `id` is the stable internal identifier (UUID when database-backed)
// - `email` is the primary human-facing identifier
// - Roles provide coarse-grained authorization (admin, editor, etc.)
// - Permissions provide fine-grained authorization (posts:create, etc.)
// - Users can have both roles (which grant permissions) and direct permissions
import { z } from "zod";
// Branded type for user IDs to prevent accidental mixing with other strings
export type UserId = string & { readonly __brand: "UserId" };
// User account status
const userStatusParser = z.enum(["active", "suspended", "pending"]);
export type UserStatus = z.infer<typeof userStatusParser>;
// Role - simple string identifier
const roleParser = z.string().min(1);
export type Role = z.infer<typeof roleParser>;
// Permission format: "resource:action" e.g. "posts:create", "users:delete"
const permissionParser = z.string().regex(/^[a-z_]+:[a-z_]+$/, {
message: "Permission must be in format 'resource:action'",
});
export type Permission = z.infer<typeof permissionParser>;
// Core user data schema - this is what gets stored/serialized
const userDataParser = z.object({
id: z.string().min(1),
email: z.email(),
displayName: z.string().optional(),
status: userStatusParser,
roles: z.array(roleParser),
permissions: z.array(permissionParser),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type UserData = z.infer<typeof userDataParser>;
// Role-to-permission mappings
// In a real system this might be database-driven or configurable
type RolePermissionMap = Map<Role, Permission[]>;
const defaultRolePermissions: RolePermissionMap = new Map([
["admin", ["users:read", "users:create", "users:update", "users:delete"]],
["user", ["users:read"]],
]);
export class User {
private readonly data: UserData;
private rolePermissions: RolePermissionMap;
constructor(data: UserData, rolePermissions?: RolePermissionMap) {
this.data = userDataParser.parse(data);
this.rolePermissions = rolePermissions ?? defaultRolePermissions;
}
// Factory for creating new users with sensible defaults
static create(
email: string,
options?: {
id?: string;
displayName?: string;
status?: UserStatus;
roles?: Role[];
permissions?: Permission[];
},
): User {
const now = new Date();
return new User({
id: options?.id ?? crypto.randomUUID(),
email,
displayName: options?.displayName,
status: options?.status ?? "active",
roles: options?.roles ?? [],
permissions: options?.permissions ?? [],
createdAt: now,
updatedAt: now,
});
}
// Identity
get id(): UserId {
return this.data.id as UserId;
}
get email(): string {
return this.data.email;
}
get displayName(): string | undefined {
return this.data.displayName;
}
// Status
get status(): UserStatus {
return this.data.status;
}
isActive(): boolean {
return this.data.status === "active";
}
// Roles
get roles(): readonly Role[] {
return this.data.roles;
}
hasRole(role: Role): boolean {
return this.data.roles.includes(role);
}
hasAnyRole(roles: Role[]): boolean {
return roles.some((role) => this.hasRole(role));
}
hasAllRoles(roles: Role[]): boolean {
return roles.every((role) => this.hasRole(role));
}
// Permissions
get permissions(): readonly Permission[] {
return this.data.permissions;
}
// Get all permissions: direct + role-derived
effectivePermissions(): Set<Permission> {
const perms = new Set<Permission>(this.data.permissions);
for (const role of this.data.roles) {
const rolePerms = this.rolePermissions.get(role);
if (rolePerms) {
for (const p of rolePerms) {
perms.add(p);
}
}
}
return perms;
}
// Check if user has a specific permission (direct or via role)
hasPermission(permission: Permission): boolean {
// Check direct permissions first
if (this.data.permissions.includes(permission)) {
return true;
}
// Check role-derived permissions
for (const role of this.data.roles) {
const rolePerms = this.rolePermissions.get(role);
if (rolePerms?.includes(permission)) {
return true;
}
}
return false;
}
// Convenience method: can user perform action on resource?
can(action: string, resource: string): boolean {
const permission = `${resource}:${action}` as Permission;
return this.hasPermission(permission);
}
// Timestamps
get createdAt(): Date {
return this.data.createdAt;
}
get updatedAt(): Date {
return this.data.updatedAt;
}
// Serialization - returns plain object for storage/transmission
toJSON(): UserData {
return { ...this.data };
}
}
// For representing "no user" in contexts where user is optional
export const AnonymousUser = Symbol("AnonymousUser");
export type MaybeUser = User | typeof AnonymousUser;