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:
@@ -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
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 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"],
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user