From 4b47b1bbedcaa075c79296b67d5915d772734e50 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Fri, 3 Apr 2026 16:57:39 -0400 Subject: [PATCH] chore: update auth-service to not be class based --- app/server/index.ts | 8 +- app/server/web/auth.ts | 418 +++++++++++++++------------------ tests/unit/auth/create-auth.ts | 4 +- 3 files changed, 191 insertions(+), 239 deletions(-) diff --git a/app/server/index.ts b/app/server/index.ts index 20d87fc..4d803b1 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -164,13 +164,7 @@ export default createHonoServer({ }, }); -// Prune expired auth sessions every 15 minutes -setInterval( - () => { - appLoadContext.auth.pruneExpiredSessions(); - }, - 15 * 60 * 1000, -); +appLoadContext.auth.start(); process.on("SIGINT", () => { log.info("server", "Received SIGINT, shutting down..."); diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index 8e489c7..6a3f27f 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -10,10 +10,6 @@ import type { Machine } from "~/types"; import { type HeadplaneUser, authSessions, users } from "../db/schema"; import { Capabilities, type Role, Roles, capsForRole } from "./roles"; -// ── Principal ──────────────────────────────────────────────────────── -// The per-request identity object. Discriminated on `kind` so routes -// can branch structurally instead of checking magic strings. - export type Principal = | { kind: "api_key"; @@ -38,14 +34,8 @@ export type Principal = }; }; -// ── Cookie payload ─────────────────────────────────────────────────── -// The cookie contains only a session ID + minimal profile data for -// SSR rendering. Credentials never leave the server. - interface CookiePayload { sid: string; - // API key is stored in the cookie ONLY for api_key sessions. - // OIDC sessions use the server-side oidc.headscale_api_key. api_key?: string; profile?: { name: string; @@ -54,8 +44,6 @@ interface CookiePayload { }; } -// ── AuthService ────────────────────────────────────────────────────── - export interface AuthServiceOptions { secret: string; headscaleApiKey?: string; @@ -68,36 +56,94 @@ export interface AuthServiceOptions { }; } -export class AuthService { - private opts: AuthServiceOptions; - private requestCache = new WeakMap>(); +export interface AuthService { + require(request: Request): Promise; + can(principal: Principal, capabilities: Capabilities): boolean; + canManageNode(principal: Principal, node: Machine): boolean; + getHeadscaleApiKey(principal: Principal): string; + createOidcSession( + userId: string, + profile: NonNullable, + maxAge?: number, + ): Promise; - constructor(opts: AuthServiceOptions) { - this.opts = opts; + createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise; + destroySession(request?: Request): Promise; + findOrCreateUser( + subject: string, + profile?: { name?: string; email?: string; picture?: string }, + ): Promise; + + linkHeadscaleUser(userId: string, headscaleUserId: string): Promise; + unlinkHeadscaleUser(userId: string): Promise; + linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise; + listUsers(): Promise; + claimedHeadscaleUserIds(): Promise>; + roleForSubject(subject: string): Promise; + roleForHeadscaleUser(headscaleUserId: string): Promise; + transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise; + reassignSubject(subject: string, role: Role): Promise; + pruneExpiredSessions(): Promise; + start(): void; + stop(): void; +} + +export function createAuthService(opts: AuthServiceOptions): AuthService { + const requestCache = new WeakMap>(); + let pruneTimer: ReturnType | undefined; + + async function encodeCookie(payload: CookiePayload, maxAge: number): Promise { + const cookie = createCookie(opts.cookie.name, { + ...opts.cookie, + path: __PREFIX__, + maxAge, + }); + + const signed = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const hmac = createHmac("sha256", opts.secret).update(signed).digest("base64url"); + return cookie.serialize(`${signed}.${hmac}`); } - // ── Authentication ───────────────────────────────────────────── - - /** - * Resolve the principal for a request. Throws if no valid session. - * Results are cached per-request so multiple calls in the same - * loader don't hit the DB repeatedly. - */ - require(request: Request): Promise { - const cached = this.requestCache.get(request); - if (cached) { - return cached; + async function decodeCookie(request: Request): Promise { + const cookieHeader = request.headers.get("cookie"); + if (!cookieHeader) { + throw new Error("No session cookie found"); } - const promise = this.resolve(request); - this.requestCache.set(request, promise); - return promise; + const cookie = createCookie(opts.cookie.name, { + ...opts.cookie, + path: __PREFIX__, + }); + + const raw = (await cookie.parse(cookieHeader)) as string | null; + if (!raw) { + throw new Error("Session cookie is empty"); + } + + const dotIndex = raw.lastIndexOf("."); + if (dotIndex === -1) { + throw new Error("Malformed session cookie"); + } + + const signed = raw.slice(0, dotIndex); + const hmac = raw.slice(dotIndex + 1); + const expected = createHmac("sha256", opts.secret).update(signed).digest("base64url"); + + if (hmac !== expected) { + throw new Error("Invalid session cookie signature"); + } + + return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload; } - private async resolve(request: Request): Promise { - const payload = await this.decodeCookie(request); + function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); + } - const [session] = await this.opts.db + async function resolve(request: Request): Promise { + const payload = await decodeCookie(request); + + const [session] = await opts.db .select() .from(authSessions) .where(eq(authSessions.id, payload.sid)) @@ -108,7 +154,7 @@ export class AuthService { } if (session.expires_at < new Date()) { - await this.opts.db.delete(authSessions).where(eq(authSessions.id, session.id)); + await opts.db.delete(authSessions).where(eq(authSessions.id, session.id)); throw new Error("Session expired"); } @@ -129,11 +175,7 @@ export class AuthService { throw new Error("OIDC session missing user_id"); } - const [user] = await this.opts.db - .select() - .from(users) - .where(eq(users.id, session.user_id)) - .limit(1); + const [user] = await opts.db.select().from(users).where(eq(users.id, session.user_id)).limit(1); if (!user) { throw new Error("User record not found"); @@ -158,13 +200,18 @@ export class AuthService { }; } - // ── Authorization ────────────────────────────────────────────── + function require(request: Request): Promise { + const cached = requestCache.get(request); + if (cached) { + return cached; + } - /** - * Check if a principal has a given set of capabilities. - * API key principals always have full access. - */ - can(principal: Principal, capabilities: Capabilities): boolean { + const promise = resolve(request); + requestCache.set(request, promise); + return promise; + } + + function can(principal: Principal, capabilities: Capabilities): boolean { if (principal.kind === "api_key") { return true; } @@ -173,11 +220,7 @@ export class AuthService { return (capabilities & roleCaps) === capabilities; } - /** - * Check if a principal can act on a machine. Owners of the machine - * can act on it even without write_machines capability. - */ - canManageNode(principal: Principal, node: Machine): boolean { + function canManageNode(principal: Principal, node: Machine): boolean { if (principal.kind === "api_key") { return true; } @@ -191,100 +234,77 @@ export class AuthService { return hsUserId !== undefined && node.user?.id === hsUserId; } - // ── Session management ───────────────────────────────────────── + function getHeadscaleApiKey(principal: Principal): string { + if (principal.kind === "api_key") { + return principal.apiKey; + } - /** - * Create a new OIDC session. Returns the Set-Cookie header value. - */ - async createOidcSession( + if (!opts.headscaleApiKey) { + throw new Error("OIDC sessions require headscale.api_key to be configured"); + } + + return opts.headscaleApiKey; + } + + async function createOidcSession( userId: string, profile: NonNullable, - maxAge = this.opts.cookie.maxAge, + maxAge = opts.cookie.maxAge, ): Promise { const sid = ulid(); - await this.opts.db.insert(authSessions).values({ + await opts.db.insert(authSessions).values({ id: sid, kind: "oidc", user_id: userId, expires_at: new Date(Date.now() + maxAge * 1000), }); - return this.encodeCookie({ sid, profile }, maxAge); + return encodeCookie({ sid, profile }, maxAge); } - /** - * Create a new API key session. A SHA-256 hash of the key is stored - * server-side for auditing. The plaintext key is carried in the - * HMAC-signed cookie so it can be used for Headscale API calls. - * Returns the Set-Cookie header value. - */ - async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise { + async function createApiKeySession( + apiKey: string, + displayName: string, + maxAge: number, + ): Promise { const sid = ulid(); - await this.opts.db.insert(authSessions).values({ + await opts.db.insert(authSessions).values({ id: sid, kind: "api_key", - api_key_hash: this.hashApiKey(apiKey), + api_key_hash: hashApiKey(apiKey), api_key_display: displayName, expires_at: new Date(Date.now() + maxAge), }); - return this.encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000)); + return encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000)); } - getHeadscaleApiKey(principal: Principal): string { - if (principal.kind === "api_key") { - return principal.apiKey; - } - - if (!this.opts.headscaleApiKey) { - throw new Error("OIDC sessions require headscale.api_key to be configured"); - } - - return this.opts.headscaleApiKey; - } - - /** - * Destroy the current session. Returns the Set-Cookie header that - * clears the cookie. - */ - async destroySession(request?: Request): Promise { + async function destroySession(request?: Request): Promise { if (request) { try { - const payload = await this.decodeCookie(request); - await this.opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid)); + const payload = await decodeCookie(request); + await opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid)); } catch { // Cookie already invalid, just clear it } } - const cookie = createCookie(this.opts.cookie.name, { - ...this.opts.cookie, + const cookie = createCookie(opts.cookie.name, { + ...opts.cookie, path: __PREFIX__, }); return cookie.serialize("", { expires: new Date(0) }); } - // ── User management ──────────────────────────────────────────── - - /** - * Find or create a Headplane user by OIDC subject. Returns the - * user ID. The first user ever created is automatically granted - * the owner role (bootstrap). Profile data (name, email) is - * refreshed on every login. - */ - async findOrCreateUser( + async function findOrCreateUser( subject: string, profile?: { name?: string; email?: string; picture?: string }, ): Promise { - const [existing] = await this.opts.db - .select() - .from(users) - .where(eq(users.sub, subject)) - .limit(1); + const [existing] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1); if (existing) { - await this.opts.db + await opts.db .update(users) .set({ name: profile?.name, @@ -298,7 +318,7 @@ export class AuthService { } const id = ulid(); - await this.opts.db.insert(users).values({ + await opts.db.insert(users).values({ id, sub: subject, name: profile?.name, @@ -308,14 +328,10 @@ export class AuthService { caps: capsForRole("member"), }); - // If this is the only user in the table, promote to owner. - // The unique constraint on `sub` prevents two concurrent inserts - // for the same subject; for different subjects, COUNT atomically - // reflects all committed rows so at most one will see count === 1. - const [{ count }] = await this.opts.db.select({ count: sql`count(*)` }).from(users); + const [{ count }] = await opts.db.select({ count: sql`count(*)` }).from(users); if (count === 1) { - await this.opts.db + await opts.db .update(users) .set({ role: "owner", caps: capsForRole("owner") }) .where(eq(users.id, id)); @@ -324,12 +340,8 @@ export class AuthService { return id; } - /** - * Link a Headplane user to a Headscale user. Returns false if the - * Headscale user is already claimed by another Headplane user. - */ - async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise { - const [existing] = await this.opts.db + async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise { + const [existing] = await opts.db .select({ id: users.id }) .from(users) .where(eq(users.headscale_user_id, headscaleUserId)) @@ -339,7 +351,7 @@ export class AuthService { return false; } - await this.opts.db + await opts.db .update(users) .set({ headscale_user_id: headscaleUserId, updated_at: new Date() }) .where(eq(users.id, userId)); @@ -347,24 +359,18 @@ export class AuthService { return true; } - /** - * Clear the Headscale user link for a Headplane user. Used when the - * linked Headscale user no longer exists. - */ - async unlinkHeadscaleUser(userId: string): Promise { - await this.opts.db + async function unlinkHeadscaleUser(userId: string): Promise { + await opts.db .update(users) .set({ headscale_user_id: null, updated_at: new Date() }) .where(eq(users.id, userId)); } - /** - * Link a Headplane user (identified by OIDC subject) to a Headscale - * user. Used by admin UI when subjects are more accessible than - * internal Headplane IDs. Returns false if already claimed. - */ - async linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise { - const [user] = await this.opts.db + async function linkHeadscaleUserBySubject( + subject: string, + headscaleUserId: string, + ): Promise { + const [user] = await opts.db .select({ id: users.id }) .from(users) .where(eq(users.sub, subject)) @@ -374,23 +380,15 @@ export class AuthService { return false; } - return this.linkHeadscaleUser(user.id, headscaleUserId); + return linkHeadscaleUser(user.id, headscaleUserId); } - /** - * List all Headplane user records. Used by the users overview page - * to display the primary user list independently of the Headscale API. - */ - async listUsers(): Promise { - return this.opts.db.select().from(users); + async function listUsers(): Promise { + return opts.db.select().from(users); } - /** - * Returns the set of Headscale user IDs that are already claimed - * by a Headplane user. Used to filter the link picker. - */ - async claimedHeadscaleUserIds(): Promise> { - const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users); + async function claimedHeadscaleUserIds(): Promise> { + const rows = await opts.db.select({ hsId: users.headscale_user_id }).from(users); const ids = new Set(); for (const row of rows) { @@ -401,12 +399,8 @@ export class AuthService { return ids; } - /** - * Get the role for a given OIDC subject. Used by the users overview - * to display roles for Headscale users. - */ - async roleForSubject(subject: string): Promise { - const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1); + async function roleForSubject(subject: string): Promise { + const [user] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1); if (!user) { return; @@ -415,12 +409,8 @@ export class AuthService { return (user.role in Roles ? user.role : "member") as Role; } - /** - * Get the role for a Headplane user linked to a given Headscale user ID. - * Returns undefined if no Headplane user is linked to this Headscale user. - */ - async roleForHeadscaleUser(headscaleUserId: string): Promise { - const [user] = await this.opts.db + async function roleForHeadscaleUser(headscaleUserId: string): Promise { + const [user] = await opts.db .select() .from(users) .where(eq(users.headscale_user_id, headscaleUserId)) @@ -433,14 +423,11 @@ export class AuthService { return (user.role in Roles ? user.role : "member") as Role; } - /** - * Transfer ownership from the current owner to another user. - * The current owner is demoted to admin and the target is promoted - * to owner. Both users must exist. Returns false if the caller is - * not actually the owner or the target doesn't exist. - */ - async transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise { - const [current] = await this.opts.db + async function transferOwnership( + currentOwnerSubject: string, + newOwnerSubject: string, + ): Promise { + const [current] = await opts.db .select() .from(users) .where(eq(users.sub, currentOwnerSubject)) @@ -450,7 +437,7 @@ export class AuthService { return false; } - const [target] = await this.opts.db + const [target] = await opts.db .select() .from(users) .where(eq(users.sub, newOwnerSubject)) @@ -460,12 +447,12 @@ export class AuthService { return false; } - await this.opts.db + await opts.db .update(users) .set({ role: "admin", caps: capsForRole("admin"), updated_at: new Date() }) .where(eq(users.id, current.id)); - await this.opts.db + await opts.db .update(users) .set({ role: "owner", caps: capsForRole("owner"), updated_at: new Date() }) .where(eq(users.id, target.id)); @@ -473,17 +460,13 @@ export class AuthService { return true; } - /** - * Reassign the role of a user identified by their OIDC subject. - * Cannot reassign the owner role. - */ - async reassignSubject(subject: string, role: Role): Promise { - const currentRole = await this.roleForSubject(subject); + async function reassignSubject(subject: string, role: Role): Promise { + const currentRole = await roleForSubject(subject); if (currentRole === "owner") { return false; } - await this.opts.db + await opts.db .insert(users) .values({ id: ulid(), @@ -499,66 +482,41 @@ export class AuthService { return true; } - /** - * Clean up expired sessions. Should be called periodically. - */ - async pruneExpiredSessions(): Promise { - await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date())); + async function pruneExpiredSessions(): Promise { + await opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date())); } - // ── Private helpers ──────────────────────────────────────────── - - private async encodeCookie(payload: CookiePayload, maxAge: number): Promise { - const cookie = createCookie(this.opts.cookie.name, { - ...this.opts.cookie, - path: __PREFIX__, - maxAge, - }); - - const signed = Buffer.from(JSON.stringify(payload)).toString("base64url"); - const hmac = createHmac("sha256", this.opts.secret).update(signed).digest("base64url"); - - return cookie.serialize(`${signed}.${hmac}`); + function start(): void { + pruneTimer = setInterval(() => void pruneExpiredSessions(), 15 * 60 * 1000); } - private async decodeCookie(request: Request): Promise { - const cookieHeader = request.headers.get("cookie"); - if (!cookieHeader) { - throw new Error("No session cookie found"); + function stop(): void { + if (pruneTimer) { + clearInterval(pruneTimer); + pruneTimer = undefined; } - - const cookie = createCookie(this.opts.cookie.name, { - ...this.opts.cookie, - path: __PREFIX__, - }); - - const raw = (await cookie.parse(cookieHeader)) as string | null; - if (!raw) { - throw new Error("Session cookie is empty"); - } - - const dotIndex = raw.lastIndexOf("."); - if (dotIndex === -1) { - throw new Error("Malformed session cookie"); - } - - const signed = raw.slice(0, dotIndex); - const hmac = raw.slice(dotIndex + 1); - - const expected = createHmac("sha256", this.opts.secret).update(signed).digest("base64url"); - - if (hmac !== expected) { - throw new Error("Invalid session cookie signature"); - } - - return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload; } - private hashApiKey(key: string): string { - return createHash("sha256").update(key).digest("hex"); - } -} - -export function createAuthService(opts: AuthServiceOptions): AuthService { - return new AuthService(opts); + return { + require: require, + can, + canManageNode, + getHeadscaleApiKey, + createOidcSession, + createApiKeySession, + destroySession, + findOrCreateUser, + linkHeadscaleUser, + unlinkHeadscaleUser, + linkHeadscaleUserBySubject, + listUsers, + claimedHeadscaleUserIds, + roleForSubject, + roleForHeadscaleUser, + transferOwnership, + reassignSubject, + pruneExpiredSessions, + start, + stop, + }; } diff --git a/tests/unit/auth/create-auth.ts b/tests/unit/auth/create-auth.ts index 0c05322..9d463e3 100644 --- a/tests/unit/auth/create-auth.ts +++ b/tests/unit/auth/create-auth.ts @@ -1,13 +1,13 @@ import { drizzle } from "drizzle-orm/node-sqlite"; import { migrate } from "drizzle-orm/node-sqlite/migrator"; -import { AuthService } from "~/server/web/auth"; +import { createAuthService } from "~/server/web/auth"; export function createTestAuth() { const db = drizzle(":memory:"); migrate(db, { migrationsFolder: "./drizzle" }); - const auth = new AuthService({ + const auth = createAuthService({ secret: "test-secret-key-for-unit-tests", db, cookie: {