feat: initial auth rework

This commit is contained in:
Aarnav Tale
2026-03-07 17:10:34 -05:00
parent 6d70497758
commit 0f8e192b5c
39 changed files with 2110 additions and 1684 deletions
+39 -41
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+429
View File
@@ -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);
}
+39
View File
@@ -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
View File
@@ -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];
}
-306
View File
@@ -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 });
}