// service.ts // // Core authentication service providing login, logout, registration, // password reset, and email verification. import type { Request as ExpressRequest } from "express"; import { AnonymousUser, 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 SessionData, 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 }; // Result of validating a request/token - contains both user and session export type AuthResult = | { authenticated: true; user: User; session: SessionData } | { authenticated: false; user: typeof AnonymousUser; session: null }; export class AuthService { constructor(private store: AuthStore) {} // === Login === async login( email: string, password: string, authMethod: "cookie" | "bearer", metadata?: { userAgent?: string; ipAddress?: string }, ): Promise { 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 { // 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 { authenticated: false, user: AnonymousUser, session: null }; } return this.validateToken(token); } async validateToken(token: string): Promise { const tokenId = hashToken(token) as TokenId; const session = await this.store.getSession(tokenId); if (!session) { return { authenticated: false, user: AnonymousUser, session: null }; } if (session.tokenType !== "session") { return { authenticated: false, user: AnonymousUser, session: null }; } const user = await this.store.getUserById(session.userId as UserId); if (!user || !user.isActive()) { return { authenticated: false, user: AnonymousUser, session: null }; } // Update last used (fire and forget) this.store.updateLastUsed(tokenId).catch(() => {}); return { authenticated: true, user, session }; } 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 { const tokenId = hashToken(token) as TokenId; await this.store.deleteSession(tokenId); } async logoutAllSessions(userId: UserId): Promise { return this.store.deleteUserSessions(userId); } // === Registration === async register( email: string, password: string, displayName?: string, ): Promise { 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 { 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 { 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); } }