chore: update auth-service to not be class based

This commit is contained in:
Aarnav Tale
2026-04-03 16:57:39 -04:00
parent 003985d192
commit 4b47b1bbed
3 changed files with 191 additions and 239 deletions
+1 -7
View File
@@ -164,13 +164,7 @@ export default createHonoServer({
}, },
}); });
// Prune expired auth sessions every 15 minutes appLoadContext.auth.start();
setInterval(
() => {
appLoadContext.auth.pruneExpiredSessions();
},
15 * 60 * 1000,
);
process.on("SIGINT", () => { process.on("SIGINT", () => {
log.info("server", "Received SIGINT, shutting down..."); log.info("server", "Received SIGINT, shutting down...");
+188 -230
View File
@@ -10,10 +10,6 @@ import type { Machine } from "~/types";
import { type HeadplaneUser, authSessions, users } from "../db/schema"; import { type HeadplaneUser, authSessions, users } from "../db/schema";
import { Capabilities, type Role, Roles, capsForRole } from "./roles"; 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 = export type Principal =
| { | {
kind: "api_key"; 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 { interface CookiePayload {
sid: string; 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; api_key?: string;
profile?: { profile?: {
name: string; name: string;
@@ -54,8 +44,6 @@ interface CookiePayload {
}; };
} }
// ── AuthService ──────────────────────────────────────────────────────
export interface AuthServiceOptions { export interface AuthServiceOptions {
secret: string; secret: string;
headscaleApiKey?: string; headscaleApiKey?: string;
@@ -68,36 +56,94 @@ export interface AuthServiceOptions {
}; };
} }
export class AuthService { export interface AuthService {
private opts: AuthServiceOptions; require(request: Request): Promise<Principal>;
private requestCache = new WeakMap<Request, Promise<Principal>>(); can(principal: Principal, capabilities: Capabilities): boolean;
canManageNode(principal: Principal, node: Machine): boolean;
getHeadscaleApiKey(principal: Principal): string;
createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge?: number,
): Promise<string>;
constructor(opts: AuthServiceOptions) { createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
this.opts = opts; destroySession(request?: Request): Promise<string>;
findOrCreateUser(
subject: string,
profile?: { name?: string; email?: string; picture?: string },
): Promise<string>;
linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean>;
unlinkHeadscaleUser(userId: string): Promise<void>;
linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean>;
listUsers(): Promise<HeadplaneUser[]>;
claimedHeadscaleUserIds(): Promise<Set<string>>;
roleForSubject(subject: string): Promise<Role | undefined>;
roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined>;
transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean>;
reassignSubject(subject: string, role: Role): Promise<boolean>;
pruneExpiredSessions(): Promise<void>;
start(): void;
stop(): void;
}
export function createAuthService(opts: AuthServiceOptions): AuthService {
const requestCache = new WeakMap<Request, Promise<Principal>>();
let pruneTimer: ReturnType<typeof setInterval> | undefined;
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
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 ───────────────────────────────────────────── async function decodeCookie(request: Request): Promise<CookiePayload> {
const cookieHeader = request.headers.get("cookie");
/** if (!cookieHeader) {
* Resolve the principal for a request. Throws if no valid session. throw new Error("No session cookie found");
* Results are cached per-request so multiple calls in the same
* loader don't hit the DB repeatedly.
*/
require(request: Request): Promise<Principal> {
const cached = this.requestCache.get(request);
if (cached) {
return cached;
} }
const promise = this.resolve(request); const cookie = createCookie(opts.cookie.name, {
this.requestCache.set(request, promise); ...opts.cookie,
return promise; 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<Principal> { function hashApiKey(key: string): string {
const payload = await this.decodeCookie(request); return createHash("sha256").update(key).digest("hex");
}
const [session] = await this.opts.db async function resolve(request: Request): Promise<Principal> {
const payload = await decodeCookie(request);
const [session] = await opts.db
.select() .select()
.from(authSessions) .from(authSessions)
.where(eq(authSessions.id, payload.sid)) .where(eq(authSessions.id, payload.sid))
@@ -108,7 +154,7 @@ export class AuthService {
} }
if (session.expires_at < new Date()) { 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"); throw new Error("Session expired");
} }
@@ -129,11 +175,7 @@ export class AuthService {
throw new Error("OIDC session missing user_id"); throw new Error("OIDC session missing user_id");
} }
const [user] = await this.opts.db const [user] = await opts.db.select().from(users).where(eq(users.id, session.user_id)).limit(1);
.select()
.from(users)
.where(eq(users.id, session.user_id))
.limit(1);
if (!user) { if (!user) {
throw new Error("User record not found"); throw new Error("User record not found");
@@ -158,13 +200,18 @@ export class AuthService {
}; };
} }
// ── Authorization ────────────────────────────────────────────── function require(request: Request): Promise<Principal> {
const cached = requestCache.get(request);
if (cached) {
return cached;
}
/** const promise = resolve(request);
* Check if a principal has a given set of capabilities. requestCache.set(request, promise);
* API key principals always have full access. return promise;
*/ }
can(principal: Principal, capabilities: Capabilities): boolean {
function can(principal: Principal, capabilities: Capabilities): boolean {
if (principal.kind === "api_key") { if (principal.kind === "api_key") {
return true; return true;
} }
@@ -173,11 +220,7 @@ export class AuthService {
return (capabilities & roleCaps) === capabilities; return (capabilities & roleCaps) === capabilities;
} }
/** function canManageNode(principal: Principal, node: Machine): boolean {
* 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 {
if (principal.kind === "api_key") { if (principal.kind === "api_key") {
return true; return true;
} }
@@ -191,100 +234,77 @@ export class AuthService {
return hsUserId !== undefined && node.user?.id === hsUserId; return hsUserId !== undefined && node.user?.id === hsUserId;
} }
// ── Session management ───────────────────────────────────────── function getHeadscaleApiKey(principal: Principal): string {
if (principal.kind === "api_key") {
return principal.apiKey;
}
/** if (!opts.headscaleApiKey) {
* Create a new OIDC session. Returns the Set-Cookie header value. throw new Error("OIDC sessions require headscale.api_key to be configured");
*/ }
async createOidcSession(
return opts.headscaleApiKey;
}
async function createOidcSession(
userId: string, userId: string,
profile: NonNullable<CookiePayload["profile"]>, profile: NonNullable<CookiePayload["profile"]>,
maxAge = this.opts.cookie.maxAge, maxAge = opts.cookie.maxAge,
): Promise<string> { ): Promise<string> {
const sid = ulid(); const sid = ulid();
await this.opts.db.insert(authSessions).values({ await opts.db.insert(authSessions).values({
id: sid, id: sid,
kind: "oidc", kind: "oidc",
user_id: userId, user_id: userId,
expires_at: new Date(Date.now() + maxAge * 1000), expires_at: new Date(Date.now() + maxAge * 1000),
}); });
return this.encodeCookie({ sid, profile }, maxAge); return encodeCookie({ sid, profile }, maxAge);
} }
/** async function createApiKeySession(
* Create a new API key session. A SHA-256 hash of the key is stored apiKey: string,
* server-side for auditing. The plaintext key is carried in the displayName: string,
* HMAC-signed cookie so it can be used for Headscale API calls. maxAge: number,
* Returns the Set-Cookie header value. ): Promise<string> {
*/
async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string> {
const sid = ulid(); const sid = ulid();
await this.opts.db.insert(authSessions).values({ await opts.db.insert(authSessions).values({
id: sid, id: sid,
kind: "api_key", kind: "api_key",
api_key_hash: this.hashApiKey(apiKey), api_key_hash: hashApiKey(apiKey),
api_key_display: displayName, api_key_display: displayName,
expires_at: new Date(Date.now() + maxAge), 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 { async function destroySession(request?: Request): Promise<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<string> {
if (request) { if (request) {
try { try {
const payload = await this.decodeCookie(request); const payload = await decodeCookie(request);
await this.opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid)); await opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid));
} catch { } catch {
// Cookie already invalid, just clear it // Cookie already invalid, just clear it
} }
} }
const cookie = createCookie(this.opts.cookie.name, { const cookie = createCookie(opts.cookie.name, {
...this.opts.cookie, ...opts.cookie,
path: __PREFIX__, path: __PREFIX__,
}); });
return cookie.serialize("", { expires: new Date(0) }); return cookie.serialize("", { expires: new Date(0) });
} }
// ── User management ──────────────────────────────────────────── async function findOrCreateUser(
/**
* 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(
subject: string, subject: string,
profile?: { name?: string; email?: string; picture?: string }, profile?: { name?: string; email?: string; picture?: string },
): Promise<string> { ): Promise<string> {
const [existing] = await this.opts.db const [existing] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (existing) { if (existing) {
await this.opts.db await opts.db
.update(users) .update(users)
.set({ .set({
name: profile?.name, name: profile?.name,
@@ -298,7 +318,7 @@ export class AuthService {
} }
const id = ulid(); const id = ulid();
await this.opts.db.insert(users).values({ await opts.db.insert(users).values({
id, id,
sub: subject, sub: subject,
name: profile?.name, name: profile?.name,
@@ -308,14 +328,10 @@ export class AuthService {
caps: capsForRole("member"), caps: capsForRole("member"),
}); });
// If this is the only user in the table, promote to owner. const [{ count }] = await opts.db.select({ count: sql<number>`count(*)` }).from(users);
// 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<number>`count(*)` }).from(users);
if (count === 1) { if (count === 1) {
await this.opts.db await opts.db
.update(users) .update(users)
.set({ role: "owner", caps: capsForRole("owner") }) .set({ role: "owner", caps: capsForRole("owner") })
.where(eq(users.id, id)); .where(eq(users.id, id));
@@ -324,12 +340,8 @@ export class AuthService {
return id; return id;
} }
/** async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
* Link a Headplane user to a Headscale user. Returns false if the const [existing] = await opts.db
* Headscale user is already claimed by another Headplane user.
*/
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
const [existing] = await this.opts.db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .from(users)
.where(eq(users.headscale_user_id, headscaleUserId)) .where(eq(users.headscale_user_id, headscaleUserId))
@@ -339,7 +351,7 @@ export class AuthService {
return false; return false;
} }
await this.opts.db await opts.db
.update(users) .update(users)
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() }) .set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
@@ -347,24 +359,18 @@ export class AuthService {
return true; return true;
} }
/** async function unlinkHeadscaleUser(userId: string): Promise<void> {
* Clear the Headscale user link for a Headplane user. Used when the await opts.db
* linked Headscale user no longer exists.
*/
async unlinkHeadscaleUser(userId: string): Promise<void> {
await this.opts.db
.update(users) .update(users)
.set({ headscale_user_id: null, updated_at: new Date() }) .set({ headscale_user_id: null, updated_at: new Date() })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
} }
/** async function linkHeadscaleUserBySubject(
* Link a Headplane user (identified by OIDC subject) to a Headscale subject: string,
* user. Used by admin UI when subjects are more accessible than headscaleUserId: string,
* internal Headplane IDs. Returns false if already claimed. ): Promise<boolean> {
*/ const [user] = await opts.db
async linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean> {
const [user] = await this.opts.db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .from(users)
.where(eq(users.sub, subject)) .where(eq(users.sub, subject))
@@ -374,23 +380,15 @@ export class AuthService {
return false; return false;
} }
return this.linkHeadscaleUser(user.id, headscaleUserId); return linkHeadscaleUser(user.id, headscaleUserId);
} }
/** async function listUsers(): Promise<HeadplaneUser[]> {
* List all Headplane user records. Used by the users overview page return opts.db.select().from(users);
* to display the primary user list independently of the Headscale API.
*/
async listUsers(): Promise<HeadplaneUser[]> {
return this.opts.db.select().from(users);
} }
/** async function claimedHeadscaleUserIds(): Promise<Set<string>> {
* Returns the set of Headscale user IDs that are already claimed const rows = await opts.db.select({ hsId: users.headscale_user_id }).from(users);
* by a Headplane user. Used to filter the link picker.
*/
async claimedHeadscaleUserIds(): Promise<Set<string>> {
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
const ids = new Set<string>(); const ids = new Set<string>();
for (const row of rows) { for (const row of rows) {
@@ -401,12 +399,8 @@ export class AuthService {
return ids; return ids;
} }
/** async function roleForSubject(subject: string): Promise<Role | undefined> {
* Get the role for a given OIDC subject. Used by the users overview const [user] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
* to display roles for Headscale users.
*/
async roleForSubject(subject: string): Promise<Role | undefined> {
const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
if (!user) { if (!user) {
return; return;
@@ -415,12 +409,8 @@ export class AuthService {
return (user.role in Roles ? user.role : "member") as Role; return (user.role in Roles ? user.role : "member") as Role;
} }
/** async function roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined> {
* Get the role for a Headplane user linked to a given Headscale user ID. const [user] = await opts.db
* Returns undefined if no Headplane user is linked to this Headscale user.
*/
async roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined> {
const [user] = await this.opts.db
.select() .select()
.from(users) .from(users)
.where(eq(users.headscale_user_id, headscaleUserId)) .where(eq(users.headscale_user_id, headscaleUserId))
@@ -433,14 +423,11 @@ export class AuthService {
return (user.role in Roles ? user.role : "member") as Role; return (user.role in Roles ? user.role : "member") as Role;
} }
/** async function transferOwnership(
* Transfer ownership from the current owner to another user. currentOwnerSubject: string,
* The current owner is demoted to admin and the target is promoted newOwnerSubject: string,
* to owner. Both users must exist. Returns false if the caller is ): Promise<boolean> {
* not actually the owner or the target doesn't exist. const [current] = await opts.db
*/
async transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean> {
const [current] = await this.opts.db
.select() .select()
.from(users) .from(users)
.where(eq(users.sub, currentOwnerSubject)) .where(eq(users.sub, currentOwnerSubject))
@@ -450,7 +437,7 @@ export class AuthService {
return false; return false;
} }
const [target] = await this.opts.db const [target] = await opts.db
.select() .select()
.from(users) .from(users)
.where(eq(users.sub, newOwnerSubject)) .where(eq(users.sub, newOwnerSubject))
@@ -460,12 +447,12 @@ export class AuthService {
return false; return false;
} }
await this.opts.db await opts.db
.update(users) .update(users)
.set({ role: "admin", caps: capsForRole("admin"), updated_at: new Date() }) .set({ role: "admin", caps: capsForRole("admin"), updated_at: new Date() })
.where(eq(users.id, current.id)); .where(eq(users.id, current.id));
await this.opts.db await opts.db
.update(users) .update(users)
.set({ role: "owner", caps: capsForRole("owner"), updated_at: new Date() }) .set({ role: "owner", caps: capsForRole("owner"), updated_at: new Date() })
.where(eq(users.id, target.id)); .where(eq(users.id, target.id));
@@ -473,17 +460,13 @@ export class AuthService {
return true; return true;
} }
/** async function reassignSubject(subject: string, role: Role): Promise<boolean> {
* Reassign the role of a user identified by their OIDC subject. const currentRole = await roleForSubject(subject);
* Cannot reassign the owner role.
*/
async reassignSubject(subject: string, role: Role): Promise<boolean> {
const currentRole = await this.roleForSubject(subject);
if (currentRole === "owner") { if (currentRole === "owner") {
return false; return false;
} }
await this.opts.db await opts.db
.insert(users) .insert(users)
.values({ .values({
id: ulid(), id: ulid(),
@@ -499,66 +482,41 @@ export class AuthService {
return true; return true;
} }
/** async function pruneExpiredSessions(): Promise<void> {
* Clean up expired sessions. Should be called periodically. await opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
*/
async pruneExpiredSessions(): Promise<void> {
await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
} }
// ── Private helpers ──────────────────────────────────────────── function start(): void {
pruneTimer = setInterval(() => void pruneExpiredSessions(), 15 * 60 * 1000);
private async encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
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}`);
} }
private async decodeCookie(request: Request): Promise<CookiePayload> { function stop(): void {
const cookieHeader = request.headers.get("cookie"); if (pruneTimer) {
if (!cookieHeader) { clearInterval(pruneTimer);
throw new Error("No session cookie found"); 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 {
return createHash("sha256").update(key).digest("hex"); require: require,
} can,
} canManageNode,
getHeadscaleApiKey,
export function createAuthService(opts: AuthServiceOptions): AuthService { createOidcSession,
return new AuthService(opts); createApiKeySession,
destroySession,
findOrCreateUser,
linkHeadscaleUser,
unlinkHeadscaleUser,
linkHeadscaleUserBySubject,
listUsers,
claimedHeadscaleUserIds,
roleForSubject,
roleForHeadscaleUser,
transferOwnership,
reassignSubject,
pruneExpiredSessions,
start,
stop,
};
} }
+2 -2
View File
@@ -1,13 +1,13 @@
import { drizzle } from "drizzle-orm/node-sqlite"; import { drizzle } from "drizzle-orm/node-sqlite";
import { migrate } from "drizzle-orm/node-sqlite/migrator"; import { migrate } from "drizzle-orm/node-sqlite/migrator";
import { AuthService } from "~/server/web/auth"; import { createAuthService } from "~/server/web/auth";
export function createTestAuth() { export function createTestAuth() {
const db = drizzle(":memory:"); const db = drizzle(":memory:");
migrate(db, { migrationsFolder: "./drizzle" }); migrate(db, { migrationsFolder: "./drizzle" });
const auth = new AuthService({ const auth = createAuthService({
secret: "test-secret-key-for-unit-tests", secret: "test-secret-key-for-unit-tests",
db, db,
cookie: { cookie: {