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>
This commit is contained in:
2026-01-03 13:59:02 -06:00
parent 788ea2ab19
commit c246e0384f
11 changed files with 898 additions and 10 deletions

View File

@@ -10,6 +10,8 @@ import { routes } from "./routes";
import { services } from "./services"; import { services } from "./services";
// import { URLPattern } from 'node:url'; // import { URLPattern } from 'node:url';
import { import {
AuthenticationRequired,
AuthorizationDenied,
type Call, type Call,
type InternalHandler, type InternalHandler,
type Method, type Method,
@@ -22,6 +24,9 @@ import {
const app = express(); const app = express();
// Parse JSON request bodies
app.use(express.json());
services.logging.log({ source: "logging", text: ["1"] }); services.logging.log({ source: "logging", text: ["1"] });
const processedRoutes: { [K in Method]: ProcessedRoute[] } = { const processedRoutes: { [K in Method]: ProcessedRoute[] } = {
GET: [], GET: [],
@@ -52,26 +57,42 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
} }
console.log("request.originalUrl", request.originalUrl); console.log("request.originalUrl", request.originalUrl);
console.log("beavis");
// const p = new URL(request.originalUrl); // Authenticate the request
// const path = p.pathname; const user = await services.auth.validateRequest(request);
// console.log("p, path", p, path)
console.log("ok");
const req: Call = { const req: Call = {
pattern: route.path, pattern: route.path,
// path,
path: request.originalUrl, path: request.originalUrl,
method, method,
parameters: { one: 1, two: 2 }, parameters: { one: 1, two: 2 },
request, request,
user,
}; };
try {
const retval = await route.handler(req); const retval = await route.handler(req);
return retval; 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()) { 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 nunjucks from "nunjucks";
import { DateTime } from "ts-luxon"; import { DateTime } from "ts-luxon";
import { authRoutes } from "./auth";
import { contentTypes } from "./content-types"; import { contentTypes } from "./content-types";
import { multiHandler } from "./handlers"; import { multiHandler } from "./handlers";
import { HttpCode, httpCodes } from "./http-codes"; import { HttpCode, httpCodes } from "./http-codes";
@@ -22,6 +23,7 @@ const okText = (result: string): Result => {
}; };
const routes: Route[] = [ const routes: Route[] = [
...authRoutes,
{ {
path: "/slow", path: "/slow",
methods: ["GET"], methods: ["GET"],

View File

@@ -1,5 +1,6 @@
// services.ts // services.ts
import { AuthService, InMemoryAuthStore } from "./auth";
import { config } from "./config"; import { config } from "./config";
import { getLogs, log } from "./logging"; 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 = { const services = {
database, database,
logging, logging,
misc, misc,
random, random,
auth,
}; };
export { services }; export { services };

View File

@@ -10,6 +10,12 @@ import type { MatchFunction } from "path-to-regexp";
import { z } from "zod"; import { z } from "zod";
import { type ContentType, contentTypes } from "./content-types"; import { type ContentType, contentTypes } from "./content-types";
import { type HttpCode, httpCodes } from "./http-codes"; import { type HttpCode, httpCodes } from "./http-codes";
import {
AnonymousUser,
type MaybeUser,
type Permission,
type User,
} from "./user";
const methodParser = z.union([ const methodParser = z.union([
z.literal("GET"), z.literal("GET"),
@@ -32,6 +38,7 @@ export type Call = {
method: Method; method: Method;
parameters: object; parameters: object;
request: ExpressRequest; request: ExpressRequest;
user: MaybeUser;
}; };
export type InternalHandler = (req: ExpressRequest) => Promise<Result>; export type InternalHandler = (req: ExpressRequest) => Promise<Result>;
@@ -56,4 +63,36 @@ export type Route = {
interruptable?: boolean; 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 }; export { methodParser, massageMethod };