chore: remove deprecated oidc.user_storage_file option

The flat-file user store has been deprecated for several versions.
All user data now lives in the SQL database. Removes the config field,
nix option, docs, and migration logic.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9
This commit is contained in:
Aarnav Tale
2026-03-07 17:31:34 -05:00
parent 0f8e192b5c
commit 45984ec639
4 changed files with 162 additions and 198 deletions
-2
View File
@@ -100,7 +100,6 @@ const oidcConfig = type({
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
});
@@ -123,7 +122,6 @@ const partialOidcConfig = type({
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
user_storage_file: "string.lower?",
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
});
+25 -22
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { createHash, createHmac } from "node:crypto";
import { eq, lt } from "drizzle-orm";
import { eq, lt, sql } from "drizzle-orm";
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
import { createCookie } from "react-router";
import { ulid } from "ulidx";
@@ -212,8 +212,9 @@ export class AuthService {
}
/**
* Create a new API key session. The API key is stored server-side
* as a SHA-256 hash — it never appears in the cookie.
* 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> {
@@ -272,9 +273,10 @@ export class AuthService {
/**
* Find or create a Headplane user by OIDC subject. Returns the
* user ID. Used during OIDC callback to establish identity.
* user ID. The first user ever created is automatically granted
* the owner role (bootstrap). Uses upsert to avoid race conditions.
*/
async findOrCreateUser(subject: string, defaultRole: Role): Promise<string> {
async findOrCreateUser(subject: string): Promise<string> {
const [existing] = await this.opts.db
.select()
.from(users)
@@ -293,20 +295,25 @@ export class AuthService {
await this.opts.db.insert(users).values({
id,
sub: subject,
role: defaultRole,
caps: capsForRole(defaultRole),
role: "member",
caps: capsForRole("member"),
onboarded: false,
});
return id;
}
// 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);
/**
* 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;
if (count === 1) {
await this.opts.db
.update(users)
.set({ role: "owner", caps: capsForRole("owner") })
.where(eq(users.id, id));
}
return id;
}
/**
@@ -377,9 +384,7 @@ export class AuthService {
});
const signed = Buffer.from(JSON.stringify(payload)).toString("base64url");
const hmac = createHash("sha256")
.update(this.opts.secret + signed)
.digest("base64url");
const hmac = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
return cookie.serialize(`${signed}.${hmac}`);
}
@@ -408,9 +413,7 @@ export class AuthService {
const signed = raw.slice(0, dotIndex);
const hmac = raw.slice(dotIndex + 1);
const expected = createHash("sha256")
.update(this.opts.secret + signed)
.digest("base64url");
const expected = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
if (hmac !== expected) {
throw new Error("Invalid session cookie signature");