import type { FastifyReply, FastifyRequest } from "fastify" import path from "path" import type { Logger } from "../logger" import { AuthStore } from "./auth-store" import { TokenManager } from "./token-manager" import { SessionManager } from "./session-manager" import { isLoopbackAddress, parseCookies } from "./http-auth" export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const export const DEFAULT_AUTH_USERNAME = "codenomad" as const export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const export interface AuthManagerInit { configPath: string username: string password?: string generateToken: boolean dangerouslySkipAuth?: boolean } export class AuthManager { private readonly authStore: AuthStore | null private readonly tokenManager: TokenManager | null private readonly sessionManager = new SessionManager() private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME private readonly authEnabled: boolean constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { this.authEnabled = !Boolean(init.dangerouslySkipAuth) if (!this.authEnabled) { this.authStore = null this.tokenManager = null return } const authFilePath = resolveAuthFilePath(init.configPath) this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" })) // Startup: password comes from CLI/env, auth.json, or bootstrap-only mode. this.authStore.ensureInitialized({ username: init.username, password: init.password, allowBootstrapWithoutPassword: init.generateToken, }) this.tokenManager = init.generateToken ? new TokenManager(60_000) : null } isAuthEnabled(): boolean { return this.authEnabled } getCookieName(): string { return this.cookieName } isTokenBootstrapEnabled(): boolean { return Boolean(this.tokenManager) } issueBootstrapToken(): string | null { if (!this.tokenManager) return null return this.tokenManager.generate() } consumeBootstrapToken(token: string): boolean { if (!this.tokenManager) return false return this.tokenManager.consume(token) } validateLogin(username: string, password: string): boolean { if (!this.authEnabled) { return true } return this.requireAuthStore().validateCredentials(username, password) } createSession(username: string) { if (!this.authEnabled) { return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username } } return this.sessionManager.createSession(username) } getStatus() { if (!this.authEnabled) { return { username: this.init.username, passwordUserProvided: false } } return this.requireAuthStore().getStatus() } setPassword(password: string) { if (!this.authEnabled) { throw new Error("Internal authentication is disabled") } return this.requireAuthStore().setPassword({ password, markUserProvided: true }) } isLoopbackRequest(request: FastifyRequest): boolean { return isLoopbackAddress(request.socket.remoteAddress) } getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null { if (!this.authEnabled) { // When auth is disabled, treat all requests as authenticated. // We still return a stable username so callers can display it. return { username: this.init.username, sessionId: "auth-disabled" } } const cookies = parseCookies(request.headers.cookie) const sessionId = cookies[this.cookieName] const session = this.sessionManager.getSession(sessionId) if (!session) return null return { username: session.username, sessionId: session.id } } setSessionCookie(reply: FastifyReply, sessionId: string) { reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId)) } setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) { reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options)) } clearSessionCookie(reply: FastifyReply) { reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 })) } clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) { reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options })) } private requireAuthStore(): AuthStore { if (!this.authStore) { throw new Error("Auth store is unavailable") } return this.authStore } } function resolveAuthFilePath(configPath: string) { const resolvedConfigPath = resolvePath(configPath) return path.join(path.dirname(resolvedConfigPath), "auth.json") } function resolvePath(filePath: string) { if (filePath.startsWith("~/")) { return path.join(process.env.HOME ?? "", filePath.slice(2)) } return path.resolve(filePath) } function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) { const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"] if (options?.secure) { parts.push("Secure") } if (options?.maxAgeSeconds !== undefined) { parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`) } return parts.join("; ") }