mirror of
https://github.com/tale/headplane.git
synced 2026-08-21 10:16:37 +00:00
feat: initial auth rework
This commit is contained in:
+39
-41
@@ -1,50 +1,48 @@
|
||||
import { eq, isNotNull } from 'drizzle-orm';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from '../../layouts/+types/dashboard';
|
||||
import { ephemeralNodes } from './schema';
|
||||
import { eq, isNotNull } from "drizzle-orm";
|
||||
|
||||
export async function pruneEphemeralNodes({
|
||||
context,
|
||||
request,
|
||||
}: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const ephemerals = await context.db
|
||||
.select()
|
||||
.from(ephemeralNodes)
|
||||
.where(isNotNull(ephemeralNodes.node_key));
|
||||
import log from "~/utils/log";
|
||||
|
||||
if (ephemerals.length === 0) {
|
||||
log.debug('api', 'No ephemeral nodes to prune');
|
||||
return;
|
||||
}
|
||||
import type { Route } from "../../layouts/+types/dashboard";
|
||||
import { ephemeralNodes } from "./schema";
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const nodes = await api.getNodes();
|
||||
const toPrune = nodes.filter((node) => {
|
||||
if (node.online) {
|
||||
return false;
|
||||
}
|
||||
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const ephemerals = await context.db
|
||||
.select()
|
||||
.from(ephemeralNodes)
|
||||
.where(isNotNull(ephemeralNodes.node_key));
|
||||
|
||||
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
|
||||
});
|
||||
if (ephemerals.length === 0) {
|
||||
log.debug("api", "No ephemeral nodes to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
if (toPrune.length === 0) {
|
||||
log.debug('api', 'No SSH nodes to prune');
|
||||
return;
|
||||
}
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const nodes = await api.getNodes();
|
||||
const toPrune = nodes.filter((node) => {
|
||||
if (node.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete from the Headscale nodes list and then from the database
|
||||
const promises = toPrune.map((node) => {
|
||||
return async () => {
|
||||
log.debug('api', `Pruning node ${node.name}`);
|
||||
await api.deleteNode(node.id);
|
||||
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
|
||||
});
|
||||
|
||||
await context.db
|
||||
.delete(ephemeralNodes)
|
||||
.where(eq(ephemeralNodes.node_key, node.nodeKey));
|
||||
log.debug('api', `Node ${node.name} pruned successfully`);
|
||||
};
|
||||
});
|
||||
if (toPrune.length === 0) {
|
||||
log.debug("api", "No SSH nodes to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(promises.map((p) => p()));
|
||||
// Delete from the Headscale nodes list and then from the database
|
||||
const promises = toPrune.map((node) => {
|
||||
return async () => {
|
||||
log.debug("api", `Pruning node ${node.name}`);
|
||||
await api.deleteNode(node.id);
|
||||
|
||||
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
|
||||
log.debug("api", `Node ${node.name} pruned successfully`);
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(promises.map((p) => p()));
|
||||
}
|
||||
|
||||
+37
-18
@@ -1,31 +1,50 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { HostInfo } from '~/types';
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const ephemeralNodes = sqliteTable('ephemeral_nodes', {
|
||||
auth_key: text('auth_key').primaryKey(),
|
||||
node_key: text('node_key'),
|
||||
import { HostInfo } from "~/types";
|
||||
|
||||
export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
|
||||
auth_key: text("auth_key").primaryKey(),
|
||||
node_key: text("node_key"),
|
||||
});
|
||||
|
||||
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
|
||||
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
|
||||
|
||||
export const hostInfo = sqliteTable('host_info', {
|
||||
host_id: text('host_id').primaryKey(),
|
||||
payload: text('payload', { mode: 'json' }).$type<HostInfo>(),
|
||||
updated_at: integer('updated_at', { mode: 'timestamp' }).$default(
|
||||
() => new Date(),
|
||||
),
|
||||
export const hostInfo = sqliteTable("host_info", {
|
||||
host_id: text("host_id").primaryKey(),
|
||||
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
|
||||
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||
});
|
||||
|
||||
export type HostInfoRecord = typeof hostInfo.$inferSelect;
|
||||
export type HostInfoInsert = typeof hostInfo.$inferInsert;
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
sub: text('sub').notNull().unique(),
|
||||
caps: integer('caps').notNull().default(0),
|
||||
onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false),
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
sub: text("sub").notNull().unique(),
|
||||
role: text("role").notNull().default("member"),
|
||||
headscale_user_id: text("headscale_user_id"),
|
||||
onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false),
|
||||
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||
last_login_at: integer("last_login_at", { mode: "timestamp" }),
|
||||
|
||||
// Deprecated: kept for migration compatibility, will be removed in 1.0
|
||||
caps: integer("caps").notNull().default(0),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type UserInsert = typeof users.$inferInsert;
|
||||
export type HeadplaneUser = typeof users.$inferSelect;
|
||||
export type HeadplaneUserInsert = typeof users.$inferInsert;
|
||||
|
||||
export const authSessions = sqliteTable("auth_sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
kind: text("kind").notNull(), // 'oidc' | 'api_key'
|
||||
user_id: text("user_id"),
|
||||
api_key_hash: text("api_key_hash"),
|
||||
api_key_display: text("api_key_display"),
|
||||
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||
});
|
||||
|
||||
export type AuthSessionRecord = typeof authSessions.$inferSelect;
|
||||
export type AuthSessionInsert = typeof authSessions.$inferInsert;
|
||||
|
||||
+12
-10
@@ -1,5 +1,6 @@
|
||||
import { join } from "node:path";
|
||||
import { exit, versions } from "node:process";
|
||||
|
||||
import { createHonoServer } from "react-router-hono-server/node";
|
||||
|
||||
import log from "~/utils/log";
|
||||
@@ -10,7 +11,7 @@ import { createDbClient } from "./db/client.server";
|
||||
import { createHeadscaleInterface } from "./headscale/api";
|
||||
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
||||
import { createHeadplaneAgent } from "./hp-agent";
|
||||
import { createSessionStorage } from "./web/sessions";
|
||||
import { createAuthService } from "./web/auth";
|
||||
|
||||
declare global {
|
||||
const __PREFIX__: string;
|
||||
@@ -60,11 +61,9 @@ const appLoadContext = {
|
||||
config.headscale.dns_records_path,
|
||||
),
|
||||
|
||||
// TODO: Better cookie options in config
|
||||
sessions: await createSessionStorage({
|
||||
auth: createAuthService({
|
||||
secret: config.server.cookie_secret,
|
||||
db,
|
||||
oidcUsersFile: config.oidc?.user_storage_file,
|
||||
cookie: {
|
||||
name: "_hp_auth",
|
||||
secure: config.server.cookie_secure,
|
||||
@@ -76,13 +75,16 @@ const appLoadContext = {
|
||||
hsApi,
|
||||
agents,
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidcConnector:
|
||||
oidc:
|
||||
config.oidc && config.oidc.enabled !== false
|
||||
? createLazyOidcConnector(
|
||||
config.server.base_url,
|
||||
config.oidc,
|
||||
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
|
||||
)
|
||||
? {
|
||||
apiKey: config.oidc.headscale_api_key,
|
||||
connector: createLazyOidcConnector(
|
||||
config.server.base_url,
|
||||
config.oidc,
|
||||
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
db,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { eq, lt } from "drizzle-orm";
|
||||
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
|
||||
import { createCookie } from "react-router";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import { 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";
|
||||
sessionId: string;
|
||||
displayName: string;
|
||||
apiKey: string;
|
||||
}
|
||||
| {
|
||||
kind: "oidc";
|
||||
sessionId: string;
|
||||
user: {
|
||||
id: string;
|
||||
subject: string;
|
||||
role: Role;
|
||||
headscaleUserId: string | undefined;
|
||||
onboarded: boolean;
|
||||
};
|
||||
profile: {
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── 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;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── AuthService ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AuthServiceOptions {
|
||||
secret: string;
|
||||
db: LibSQLDatabase;
|
||||
cookie: {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
domain?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private opts: AuthServiceOptions;
|
||||
private requestCache = new WeakMap<Request, Promise<Principal>>();
|
||||
|
||||
constructor(opts: AuthServiceOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
const promise = this.resolve(request);
|
||||
this.requestCache.set(request, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async resolve(request: Request): Promise<Principal> {
|
||||
const payload = await this.decodeCookie(request);
|
||||
|
||||
const [session] = await this.opts.db
|
||||
.select()
|
||||
.from(authSessions)
|
||||
.where(eq(authSessions.id, payload.sid))
|
||||
.limit(1);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (session.expires_at < new Date()) {
|
||||
await this.opts.db.delete(authSessions).where(eq(authSessions.id, session.id));
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
if (session.kind === "api_key") {
|
||||
if (!payload.api_key) {
|
||||
throw new Error("API key session missing credential");
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "api_key",
|
||||
sessionId: session.id,
|
||||
displayName: session.api_key_display ?? "API Key",
|
||||
apiKey: payload.api_key,
|
||||
};
|
||||
}
|
||||
|
||||
if (!session.user_id) {
|
||||
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);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User record not found");
|
||||
}
|
||||
|
||||
const role = (user.role in Roles ? user.role : "member") as Role;
|
||||
return {
|
||||
kind: "oidc",
|
||||
sessionId: session.id,
|
||||
user: {
|
||||
id: user.id,
|
||||
subject: user.sub,
|
||||
role,
|
||||
headscaleUserId: user.headscale_user_id ?? undefined,
|
||||
onboarded: user.onboarded,
|
||||
},
|
||||
profile: payload.profile ?? {
|
||||
name: user.sub,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Authorization ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a principal has a given set of capabilities.
|
||||
* API key principals always have full access.
|
||||
*/
|
||||
can(principal: Principal, capabilities: Capabilities): boolean {
|
||||
if (principal.kind === "api_key") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roleCaps = Roles[principal.user.role];
|
||||
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 {
|
||||
if (principal.kind === "api_key") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const caps = Roles[principal.user.role];
|
||||
if ((caps & Capabilities.write_machines) !== 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hsUserId = principal.user.headscaleUserId;
|
||||
return hsUserId !== undefined && node.user?.id === hsUserId;
|
||||
}
|
||||
|
||||
// ── Session management ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new OIDC session. Returns the Set-Cookie header value.
|
||||
*/
|
||||
async createOidcSession(
|
||||
userId: string,
|
||||
profile: NonNullable<CookiePayload["profile"]>,
|
||||
maxAge = this.opts.cookie.maxAge,
|
||||
): Promise<string> {
|
||||
const sid = ulid();
|
||||
await this.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key session. The API key is stored server-side
|
||||
* as a SHA-256 hash — it never appears in the cookie.
|
||||
* Returns the Set-Cookie header value.
|
||||
*/
|
||||
async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string> {
|
||||
const sid = ulid();
|
||||
await this.opts.db.insert(authSessions).values({
|
||||
id: sid,
|
||||
kind: "api_key",
|
||||
api_key_hash: this.hashApiKey(apiKey),
|
||||
api_key_display: displayName,
|
||||
expires_at: new Date(Date.now() + maxAge),
|
||||
});
|
||||
|
||||
return this.encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Headscale API key for making API calls.
|
||||
* OIDC sessions use the configured oidc.headscale_api_key.
|
||||
* API key sessions use the user-provided key stored in the cookie.
|
||||
*/
|
||||
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
|
||||
if (principal.kind === "api_key") {
|
||||
return principal.apiKey;
|
||||
}
|
||||
|
||||
if (!oidcApiKey) {
|
||||
throw new Error("OIDC sessions require oidc.headscale_api_key");
|
||||
}
|
||||
|
||||
return oidcApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the current session. Returns the Set-Cookie header that
|
||||
* clears the cookie.
|
||||
*/
|
||||
async 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));
|
||||
} catch {
|
||||
// Cookie already invalid, just clear it
|
||||
}
|
||||
}
|
||||
|
||||
const cookie = createCookie(this.opts.cookie.name, {
|
||||
...this.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. Used during OIDC callback to establish identity.
|
||||
*/
|
||||
async findOrCreateUser(subject: string, defaultRole: Role): Promise<string> {
|
||||
const [existing] = await this.opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await this.opts.db
|
||||
.update(users)
|
||||
.set({ last_login_at: new Date(), updated_at: new Date() })
|
||||
.where(eq(users.id, existing.id));
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const id = ulid();
|
||||
await this.opts.db.insert(users).values({
|
||||
id,
|
||||
sub: subject,
|
||||
role: defaultRole,
|
||||
caps: capsForRole(defaultRole),
|
||||
onboarded: false,
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any users in the database (for bootstrap).
|
||||
*/
|
||||
async hasAnyUsers(): Promise<boolean> {
|
||||
const [row] = await this.opts.db.select({ id: users.id }).from(users).limit(1);
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the Headscale user link for a Headplane user.
|
||||
*/
|
||||
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<void> {
|
||||
await this.opts.db
|
||||
.update(users)
|
||||
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (user.role in Roles ? user.role : "member") as Role;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (currentRole === "owner") {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.opts.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: subject,
|
||||
role,
|
||||
caps: capsForRole(role),
|
||||
onboarded: false,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.sub,
|
||||
set: { role, caps: capsForRole(role), updated_at: new Date() },
|
||||
});
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
// ── 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 = createHash("sha256")
|
||||
.update(this.opts.secret + signed)
|
||||
.digest("base64url");
|
||||
|
||||
return cookie.serialize(`${signed}.${hmac}`);
|
||||
}
|
||||
|
||||
private async decodeCookie(request: Request): Promise<CookiePayload> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (!cookieHeader) {
|
||||
throw new Error("No session cookie found");
|
||||
}
|
||||
|
||||
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 = createHash("sha256")
|
||||
.update(this.opts.secret + 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);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { User } from "~/types/User";
|
||||
|
||||
/**
|
||||
* Extracts the OIDC subject from a Headscale user's providerId.
|
||||
* Headscale stores providerId as a URL where the last path segment
|
||||
* is the subject (e.g. "https://idp.example.com/<uuid>"). This is
|
||||
* the ONLY place this parsing should occur — all other code should
|
||||
* use the stable headscale_user_id link on the Headplane user record.
|
||||
*/
|
||||
export function getOidcSubject(user: User): string | undefined {
|
||||
if (user.provider !== "oidc" || !user.providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return user.providerId.split("/").pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the Headscale user matching the given OIDC identity.
|
||||
* Tries subject match first (providerId last segment), then falls
|
||||
* back to email match. The fallback is needed because some IDPs
|
||||
* issue different subjects per client application.
|
||||
*/
|
||||
export function findHeadscaleUserBySubject(
|
||||
users: User[],
|
||||
subject: string,
|
||||
email?: string,
|
||||
): User | undefined {
|
||||
const bySubject = users.find((u) => getOidcSubject(u) === subject);
|
||||
if (bySubject) {
|
||||
return bySubject;
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
return users.find((u) => u.email === email);
|
||||
}
|
||||
+12
-37
@@ -1,60 +1,24 @@
|
||||
export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
|
||||
export const Capabilities = {
|
||||
// Can access the admin console
|
||||
ui_access: 1 << 0,
|
||||
|
||||
// Read tailnet policy file (unimplemented)
|
||||
read_policy: 1 << 1,
|
||||
|
||||
// Write tailnet policy file (unimplemented)
|
||||
write_policy: 1 << 2,
|
||||
|
||||
// Read network configurations
|
||||
read_network: 1 << 3,
|
||||
|
||||
// Write network configurations, for example, enable MagicDNS, split DNS,
|
||||
// make subnet, or allow a node to be an exit node, enable HTTPS
|
||||
write_network: 1 << 4,
|
||||
|
||||
// Read feature configuration (unimplemented)
|
||||
read_feature: 1 << 5,
|
||||
|
||||
// Write feature configuration, for example, enable Taildrop (unimplemented)
|
||||
write_feature: 1 << 6,
|
||||
|
||||
// Configure user & group provisioning
|
||||
configure_iam: 1 << 7,
|
||||
|
||||
// Read machines, for example, see machine names and status
|
||||
read_machines: 1 << 8,
|
||||
|
||||
// Write machines, for example, approve, rename, and remove machines
|
||||
write_machines: 1 << 9,
|
||||
|
||||
// Read users and user roles
|
||||
read_users: 1 << 10,
|
||||
|
||||
// Write users and user roles, for example, remove users,
|
||||
// approve users, make Admin
|
||||
write_users: 1 << 11,
|
||||
|
||||
// Can generate authkeys for any user
|
||||
generate_authkeys: 1 << 12,
|
||||
|
||||
// Can generate authkeys for own user only
|
||||
generate_own_authkeys: 1 << 16,
|
||||
|
||||
// Can use any tag (without being tag owner) (unimplemented)
|
||||
use_tags: 1 << 13,
|
||||
|
||||
// Write tailnet name (unimplemented)
|
||||
write_tailnet: 1 << 14,
|
||||
|
||||
// Owner flag
|
||||
owner: 1 << 15,
|
||||
} as const;
|
||||
|
||||
export type Roles = [keyof typeof Roles];
|
||||
export const Roles = {
|
||||
owner:
|
||||
Capabilities.ui_access |
|
||||
@@ -126,12 +90,19 @@ export const Roles = {
|
||||
Capabilities.read_users |
|
||||
Capabilities.generate_own_authkeys,
|
||||
|
||||
// Default role for new users with 0 capabilities on the UI side of things
|
||||
viewer:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.generate_own_authkeys,
|
||||
|
||||
// No access — user exists but has not been granted any role
|
||||
member: 0,
|
||||
} as const;
|
||||
|
||||
export type Role = keyof typeof Roles;
|
||||
export type Capability = keyof typeof Capabilities;
|
||||
|
||||
export function hasCapability(role: Role, capability: Capability): boolean {
|
||||
return (Roles[role] & Capabilities[capability]) !== 0;
|
||||
}
|
||||
@@ -146,3 +117,7 @@ export function getRoleFromCapabilities(capabilities: Capabilities): Role {
|
||||
|
||||
return "member";
|
||||
}
|
||||
|
||||
export function capsForRole(role: Role): number {
|
||||
return Roles[role];
|
||||
}
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
|
||||
import { EncryptJWT, jwtDecrypt } from "jose";
|
||||
import { createHash } from "node:crypto";
|
||||
import { open, readFile, rm } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { createCookie } from "react-router";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { users } from "../db/schema";
|
||||
import { Capabilities, Roles } from "./roles";
|
||||
|
||||
export interface AuthSession {
|
||||
state: "auth";
|
||||
api_key: string;
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface JWTSession {
|
||||
api_key: string;
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OidcFlowSession {
|
||||
state: "flow";
|
||||
oidc: {
|
||||
state: string;
|
||||
nonce: string;
|
||||
code_verifier: string;
|
||||
redirect_uri: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthSessionOptions {
|
||||
secret: string;
|
||||
db: LibSQLDatabase;
|
||||
oidcUsersFile?: string;
|
||||
cookie: {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
domain?: string;
|
||||
};
|
||||
}
|
||||
|
||||
class Sessionizer {
|
||||
private options: AuthSessionOptions;
|
||||
|
||||
constructor(options: AuthSessionOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
// This throws on the assumption that auth is already checked correctly
|
||||
// on something that wraps the route calling auth. The top-level routes
|
||||
// that call this are wrapped with try/catch to handle the error.
|
||||
async auth(request: Request) {
|
||||
return decodeSession(request, this.options);
|
||||
}
|
||||
|
||||
async createSession(payload: JWTSession, maxAge = this.options.cookie.maxAge) {
|
||||
// TODO: What the hell is this garbage
|
||||
return createSession(payload, {
|
||||
...this.options,
|
||||
cookie: {
|
||||
...this.options.cookie,
|
||||
maxAge,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async destroySession() {
|
||||
return destroySession(this.options);
|
||||
}
|
||||
|
||||
async roleForSubject(subject: string): Promise<keyof typeof Roles | undefined> {
|
||||
const [user] = await this.options.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need this in string form based on Object.keys of the roles
|
||||
for (const [key, value] of Object.entries(Roles)) {
|
||||
if (value === user.caps) {
|
||||
return key as keyof typeof Roles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given an OR of capabilities, check if the session has the required
|
||||
// capabilities. If not, return false. Can throw since it calls auth()
|
||||
async check(request: Request, capabilities: Capabilities) {
|
||||
const session = await this.auth(request);
|
||||
|
||||
// This is the subject we set on API key based sessions. API keys
|
||||
// inherently imply admin access so we return true for all checks.
|
||||
if (session.user.subject === "unknown-non-oauth") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [user] = await this.options.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (capabilities & user.caps) === capabilities;
|
||||
}
|
||||
|
||||
// Updates the capabilities and roles of a subject
|
||||
// Creates the user record if it doesn't exist yet
|
||||
async reassignSubject(subject: string, role: keyof typeof Roles) {
|
||||
// Check if we are owner
|
||||
const subjectRole = await this.roleForSubject(subject);
|
||||
if (subjectRole === "owner") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use upsert to handle users who exist in Headscale but haven't
|
||||
// logged into Headplane yet (no DB record)
|
||||
await this.options.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: subject,
|
||||
caps: Roles[role],
|
||||
onboarded: false,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.sub,
|
||||
set: { caps: Roles[role] },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const secret = createHash("sha256").update(options.secret, "utf8").digest();
|
||||
const jwt = await new EncryptJWT({
|
||||
...payload,
|
||||
})
|
||||
.setProtectedHeader({ alg: "dir", enc: "A256GCM", typ: "JWT" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(now + options.cookie.maxAge)
|
||||
.setIssuer("urn:tale:headplane")
|
||||
.setAudience("urn:tale:headplane")
|
||||
.setJti(ulid())
|
||||
.encrypt(secret);
|
||||
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return cookie.serialize(jwt);
|
||||
}
|
||||
|
||||
async function decodeSession(request: Request, options: AuthSessionOptions) {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (cookieHeader === null) {
|
||||
throw new Error("No session cookie found");
|
||||
}
|
||||
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
|
||||
if (cookieValue === null) {
|
||||
throw new Error("Session cookie is empty");
|
||||
}
|
||||
|
||||
const secret = createHash("sha256").update(options.secret, "utf8").digest();
|
||||
const { payload } = await jwtDecrypt(cookieValue, secret, {
|
||||
issuer: "urn:tale:headplane",
|
||||
audience: "urn:tale:headplane",
|
||||
});
|
||||
|
||||
// Safe since we encode the session directly into the JWT
|
||||
return payload as unknown as JWTSession;
|
||||
}
|
||||
|
||||
async function destroySession(options: AuthSessionOptions) {
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return cookie.serialize("", {
|
||||
expires: new Date(0),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSessionStorage(options: AuthSessionOptions) {
|
||||
if (options.oidcUsersFile) {
|
||||
await migrateUserDatabase(options.oidcUsersFile, options.db);
|
||||
}
|
||||
|
||||
return new Sessionizer(options);
|
||||
}
|
||||
|
||||
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
|
||||
const realPath = resolve(path);
|
||||
|
||||
try {
|
||||
const handle = await open(realPath, "a+");
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
if (error != null && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
log.debug("config", "No old user database file found at %s", realPath);
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn("config", "Failed to migrate old user database at %s", realPath);
|
||||
log.warn("config", "This is not an error, but existing users will not be migrated");
|
||||
log.warn("config", "Unable to open user database file: %s", String(error));
|
||||
log.debug("config", "Error details: %s", error);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("config", "Found old user database file at %s", realPath);
|
||||
log.info("config", "Migrating user database to the new SQL database");
|
||||
|
||||
let migratableUsers: {
|
||||
u: string;
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
|
||||
try {
|
||||
const data = await readFile(realPath, "utf8");
|
||||
if (data.trim().length === 0) {
|
||||
log.info("config", "Old user database file is empty, nothing to migrate");
|
||||
log.info("config", "You SHOULD remove oidc.user_storage_file from your config!");
|
||||
await rm(realPath, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const users = JSON.parse(data.trim()) as {
|
||||
u?: string;
|
||||
c?: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
|
||||
migratableUsers = users.filter((user) => user.u !== undefined && user.c !== undefined) as {
|
||||
u: string;
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
} catch (error) {
|
||||
log.warn("config", "Error reading old user database file: %s", error);
|
||||
log.warn("config", "Not migrating any users");
|
||||
return;
|
||||
}
|
||||
|
||||
if (migratableUsers.length === 0) {
|
||||
log.info("config", "No users found in the old database to migrate");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("config", "Migrating %d users from the old database", migratableUsers.length);
|
||||
|
||||
const updated = await db
|
||||
.insert(users)
|
||||
.values(
|
||||
migratableUsers.map((user) => ({
|
||||
id: ulid(),
|
||||
sub: user.u,
|
||||
caps: user.c,
|
||||
onboarded: user.oo ?? false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: users.sub,
|
||||
})
|
||||
.returning();
|
||||
|
||||
log.info("config", "Migrated %d users successfully", updated.length);
|
||||
log.info("config", "Removed old user database file %s", realPath);
|
||||
await rm(realPath, { force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user