mirror of
https://github.com/tale/headplane.git
synced 2026-08-02 18:29:16 +00:00
chore: update auth-service to not be class based
This commit is contained in:
+1
-7
@@ -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...");
|
||||
|
||||
+188
-230
@@ -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<Request, Promise<Principal>>();
|
||||
export interface AuthService {
|
||||
require(request: 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) {
|
||||
this.opts = opts;
|
||||
createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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<Principal> {
|
||||
const cached = this.requestCache.get(request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
async function decodeCookie(request: Request): Promise<CookiePayload> {
|
||||
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<Principal> {
|
||||
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<Principal> {
|
||||
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<Principal> {
|
||||
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<CookiePayload["profile"]>,
|
||||
maxAge = this.opts.cookie.maxAge,
|
||||
maxAge = opts.cookie.maxAge,
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
async function createApiKeySession(
|
||||
apiKey: string,
|
||||
displayName: string,
|
||||
maxAge: number,
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
async function destroySession(request?: Request): Promise<string> {
|
||||
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<string> {
|
||||
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<number>`count(*)` }).from(users);
|
||||
const [{ count }] = await opts.db.select({ count: sql<number>`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<boolean> {
|
||||
const [existing] = await this.opts.db
|
||||
async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
|
||||
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<void> {
|
||||
await this.opts.db
|
||||
async function unlinkHeadscaleUser(userId: string): Promise<void> {
|
||||
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<boolean> {
|
||||
const [user] = await this.opts.db
|
||||
async function linkHeadscaleUserBySubject(
|
||||
subject: string,
|
||||
headscaleUserId: string,
|
||||
): Promise<boolean> {
|
||||
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<HeadplaneUser[]> {
|
||||
return this.opts.db.select().from(users);
|
||||
async function listUsers(): Promise<HeadplaneUser[]> {
|
||||
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<Set<string>> {
|
||||
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
|
||||
async function claimedHeadscaleUserIds(): Promise<Set<string>> {
|
||||
const rows = await opts.db.select({ hsId: users.headscale_user_id }).from(users);
|
||||
|
||||
const ids = new Set<string>();
|
||||
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<Role | undefined> {
|
||||
const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
|
||||
async function roleForSubject(subject: string): Promise<Role | undefined> {
|
||||
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<Role | undefined> {
|
||||
const [user] = await this.opts.db
|
||||
async function roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined> {
|
||||
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<boolean> {
|
||||
const [current] = await this.opts.db
|
||||
async function transferOwnership(
|
||||
currentOwnerSubject: string,
|
||||
newOwnerSubject: string,
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
const currentRole = await this.roleForSubject(subject);
|
||||
async function reassignSubject(subject: string, role: Role): Promise<boolean> {
|
||||
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<void> {
|
||||
await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
|
||||
async function pruneExpiredSessions(): Promise<void> {
|
||||
await opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────
|
||||
|
||||
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}`);
|
||||
function start(): void {
|
||||
pruneTimer = setInterval(() => void pruneExpiredSessions(), 15 * 60 * 1000);
|
||||
}
|
||||
|
||||
private async decodeCookie(request: Request): Promise<CookiePayload> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user