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
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
|
||||
};
|
||||
Reference in New Issue
Block a user