Compare commits
2 Commits
6297a95d3c
...
c246e0384f
| Author | SHA1 | Date | |
|---|---|---|---|
| c246e0384f | |||
| 788ea2ab19 |
3
.beads/issues.jsonl
Normal file
3
.beads/issues.jsonl
Normal 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"}
|
||||
@@ -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
10
express/auth/index.ts
Normal 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
67
express/auth/password.ts
Normal 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
231
express/auth/routes.ts
Normal 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
248
express/auth/service.ts
Normal 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
160
express/auth/store.ts
Normal 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
40
express/auth/token.ts
Normal 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
64
express/auth/types.ts
Normal 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
|
||||
};
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
188
express/user.ts
Normal 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;
|
||||
Reference in New Issue
Block a user