Default to HTTPS with optional loopback HTTP, generate/rotate self-signed certs via node-forge, and surface Local/Remote connection URLs. Update /api/meta schema, UI remote access overlay, and desktop shells to follow the new startup output.
164 lines
5.2 KiB
TypeScript
164 lines
5.2 KiB
TypeScript
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("; ")
|
|
}
|