From 96f2721272cb697bea4cbe34e70e5d2e032e03d0 Mon Sep 17 00:00:00 2001
From: Aarnav Tale
Date: Sat, 20 Jun 2026 11:40:46 -0400
Subject: [PATCH] feat(auth): support reverse-proxy driven proxy auth
Closes HP-353.
---
CHANGELOG.md | 1 +
app/layout/app.tsx | 27 +-
app/routes/home.tsx | 15 +-
app/routes/machines/overview.tsx | 3 +-
app/routes/settings/auth-keys/actions.ts | 6 +-
.../auth-keys/dialogs/add-auth-key.tsx | 19 +-
app/routes/settings/auth-keys/overview.tsx | 16 +-
app/routes/ssh/page.tsx | 4 +-
app/routes/users/overview.tsx | 5 +-
app/routes/users/user-actions.ts | 3 +-
app/server/app.ts | 5 +-
app/server/config/config-schema.ts | 22 ++
app/server/context.ts | 12 +
app/server/db/schema.ts | 2 +-
app/server/web/auth.ts | 352 ++++++++++++++++--
config.example.yaml | 33 +-
docs/.vitepress/config.ts | 6 +-
docs/NixOS-options.md | 79 ++++
docs/configuration/index.md | 4 +-
docs/features/proxy-auth.md | 99 +++++
docs/features/sso.md | 3 +
nix/options.nix | 71 ++++
tests/unit/auth/auth-service.test.ts | 107 ++++++
tests/unit/auth/create-auth.ts | 18 +-
24 files changed, 837 insertions(+), 75 deletions(-)
create mode 100644 docs/features/proxy-auth.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a72c336..db3f6ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
- Fixed Browser SSH pre-auth key handling by increasing the temporary key expiry window and showing key creation errors in the UI (closes [#565](https://github.com/tale/headplane/issues/565)).
- Fixed machine rename submission by validating names before sending the rename request (closes [#564](https://github.com/tale/headplane/issues/564)).
- Fixed OIDC token exchange fallback when retrying with `client_secret_basic` (closes [#493](https://github.com/tale/headplane/issues/493)).
+- Added support for proxy authentication via `server.proxy_auth` (closes [#353](https://github.com/tale/headplane/issues/353)).
---
diff --git a/app/layout/app.tsx b/app/layout/app.tsx
index 6c0ac05..cb6e027 100644
--- a/app/layout/app.tsx
+++ b/app/layout/app.tsx
@@ -4,6 +4,7 @@ import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
+import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import log from "~/utils/log";
@@ -33,16 +34,15 @@ export async function loader({ request, context }: Route.LoaderArgs) {
try {
const { principal, api } = await context.apiForRequest(request);
- const user =
- principal.kind === "oidc"
- ? {
- email: principal.profile.email,
- name: principal.profile.name,
- picture: principal.profile.picture,
- subject: principal.user.subject,
- username: principal.profile.username,
- }
- : { name: principal.displayName, subject: "api_key" };
+ const user = isUserPrincipal(principal)
+ ? {
+ email: principal.profile.email,
+ name: principal.profile.name,
+ picture: principal.profile.picture,
+ subject: principal.user.subject,
+ username: principal.profile.username,
+ }
+ : { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await context.headscale.health();
@@ -51,8 +51,9 @@ export async function loader({ request, context }: Route.LoaderArgs) {
await api.apiKeys.list();
} catch (error) {
if (isDataUnauthorizedError(error)) {
- const displayName =
- principal.kind === "oidc" ? principal.profile.name : principal.displayName;
+ const displayName = isUserPrincipal(principal)
+ ? principal.profile.name
+ : principal.displayName;
log.warn("auth", "Logging out %s due to expired API key", displayName);
return redirect("/login", {
headers: {
@@ -64,7 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Self-heal: if the linked Headscale user was deleted, clear the
// stale link so the user gets prompted to re-link.
- if (principal.kind === "oidc" && principal.user.headscaleUserId) {
+ if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try {
const usersSnap = await context.hsLive.get(usersResource, api);
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 99430e1..72b8ab8 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -11,6 +11,7 @@ import CodeBlock from "~/components/code-block";
import Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
import { usersResource } from "~/server/headscale/live-store";
+import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { getUserDisplayName } from "~/utils/user";
@@ -20,14 +21,10 @@ import type { Route } from "./+types/home";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
- // If the OIDC user has no linked Headscale user, check for
- // Unclaimed users they can pick from before anything else.
+ // If the signed-in Headplane user has no linked Headscale user,
+ // check for unclaimed users they can pick from before anything else.
let unlinked = false;
- if (
- context.oidc.state === "enabled" &&
- principal.kind === "oidc" &&
- !principal.user.headscaleUserId
- ) {
+ if (isUserPrincipal(principal) && !principal.user.headscaleUserId) {
const { api } = await context.apiForRequest(request);
let headscaleUsers: { id: string; name: string }[] = [];
@@ -66,7 +63,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const { api } = await context.apiForRequest(request);
let linkedUserName: string | undefined;
- if (principal.kind === "oidc" && principal.user.headscaleUserId) {
+ if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try {
const usersSnap = await context.hsLive.get(usersResource, api);
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
@@ -81,7 +78,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export async function action({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
- if (principal.kind !== "oidc") {
+ if (!isUserPrincipal(principal)) {
return redirect("/");
}
diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx
index 70e72a0..4dc1a32 100644
--- a/app/routes/machines/overview.tsx
+++ b/app/routes/machines/overview.tsx
@@ -8,6 +8,7 @@ import Link from "~/components/link";
import PageError from "~/components/page-error";
import Tooltip from "~/components/tooltip";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
+import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
@@ -64,7 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
nodeKey: agents?.agentNodeKey(),
}
: undefined,
- headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
+ headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
existingTags: sortAssignableTags(nodes, policy),
magic,
nodes,
diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts
index 24b7d97..358c000 100644
--- a/app/routes/settings/auth-keys/actions.ts
+++ b/app/routes/settings/auth-keys/actions.ts
@@ -1,5 +1,6 @@
import { data } from "react-router";
+import { isUserPrincipal } from "~/server/web/auth";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
@@ -25,7 +26,10 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
throw data("User not found.", { status: 404 });
}
const targetSubject = getOidcSubject(targetUser);
- if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
+ const ownsTarget =
+ isUserPrincipal(principal) &&
+ (principal.user.headscaleUserId === userId || targetSubject === principal.user.subject);
+ if (!ownsTarget) {
throw data("You do not have permission to manage this user's pre-auth keys", {
status: 403,
});
diff --git a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx
index a3682b0..e3ee144 100644
--- a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx
+++ b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx
@@ -18,10 +18,22 @@ interface AddAuthKeyProps {
users: User[];
url: string;
selfServiceOnly: boolean;
+ currentHeadscaleUserId?: string;
currentSubject?: string;
}
-function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
+function findCurrentUser(
+ users: User[],
+ headscaleUserId: string | undefined,
+ subject: string | undefined,
+): User | undefined {
+ if (headscaleUserId) {
+ const linked = users.find((u) => u.id === headscaleUserId);
+ if (linked) {
+ return linked;
+ }
+ }
+
if (!subject) {
return undefined;
}
@@ -38,6 +50,7 @@ export default function AddAuthKey({
users,
url,
selfServiceOnly,
+ currentHeadscaleUserId,
currentSubject,
}: AddAuthKeyProps) {
const fetcher = useFetcher();
@@ -46,7 +59,9 @@ export default function AddAuthKey({
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [tagOnly, setTagOnly] = useState(false);
- const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
+ const currentUser = selfServiceOnly
+ ? findCurrentUser(users, currentHeadscaleUserId, currentSubject)
+ : null;
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
const [userId, setUserId] = useState(availableUsers[0]?.id);
const [tags, setTags] = useState("");
diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx
index 5186618..a0faa33 100644
--- a/app/routes/settings/auth-keys/overview.tsx
+++ b/app/routes/settings/auth-keys/overview.tsx
@@ -7,6 +7,7 @@ import Notice from "~/components/notice";
import Select from "~/components/select";
import TableList from "~/components/table-list";
import { usersResource } from "~/server/headscale/live-store";
+import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
@@ -90,7 +91,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return {
access: canGenerateAny || canGenerateOwn,
- currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
+ currentHeadscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
+ currentSubject: isUserPrincipal(principal) ? principal.user.subject : undefined,
keys,
missing,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
@@ -103,7 +105,16 @@ export const action = authKeysAction;
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({
- loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
+ loaderData: {
+ keys,
+ missing,
+ users,
+ url,
+ access,
+ selfServiceOnly,
+ currentHeadscaleUserId,
+ currentSubject,
+ },
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState("active");
@@ -199,6 +210,7 @@ export default function Page({
u.id === principal.user.headscaleUserId)
+ : findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!hsUser) {
throw data(sshErrors.user_not_linked, 404);
diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx
index a4a9f87..5bdae72 100644
--- a/app/routes/users/overview.tsx
+++ b/app/routes/users/overview.tsx
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import PageError from "~/components/page-error";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
+import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities, Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Machine, User } from "~/types";
@@ -131,11 +132,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
}
- const isOwner = principal.kind === "oidc" && principal.user.role === "owner";
+ const isOwner = isUserPrincipal(principal) && principal.user.role === "owner";
return {
writable: writablePermission,
- currentUserId: principal.kind === "oidc" ? principal.user.id : undefined,
+ currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
isOwner,
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
magic,
diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts
index 9f0869f..106e8c7 100644
--- a/app/routes/users/user-actions.ts
+++ b/app/routes/users/user-actions.ts
@@ -1,6 +1,7 @@
import { data } from "react-router";
import { usersResource } from "~/server/headscale/live-store";
+import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
@@ -93,7 +94,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "User reassigned successfully" };
}
case "transfer_ownership": {
- if (principal.kind !== "oidc" || principal.user.role !== "owner") {
+ if (!isUserPrincipal(principal) || principal.user.role !== "owner") {
throw data("Only the owner can transfer ownership.", { status: 403 });
}
diff --git a/app/server/app.ts b/app/server/app.ts
index 42fc480..7d5bc91 100644
--- a/app/server/app.ts
+++ b/app/server/app.ts
@@ -63,5 +63,8 @@ export async function dispose(): Promise {
export default createRequestListener({
build,
mode: import.meta.env.MODE,
- getLoadContext: () => ctx,
+ getLoadContext: (request, client) => {
+ ctx.auth.registerRequestClientAddress(request, client.address);
+ return ctx;
+ },
});
diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts
index 11637cb..a646ebf 100644
--- a/app/server/config/config-schema.ts
+++ b/app/server/config/config-schema.ts
@@ -48,6 +48,17 @@ const serverConfig = type({
// either is set, `cookie_secure` is forced to `true`.
tls_cert_path: "string?",
tls_key_path: "string?",
+
+ "proxy_auth?": {
+ enabled: "boolean",
+ allowed_cidrs: "string[]?",
+ trusted_proxy_cidrs: "string[]?",
+ ip_header: "string?",
+ user_header: "string?",
+ email_header: "string?",
+ name_header: "string?",
+ picture_header: "string?",
+ },
});
const partialServerConfig = type({
@@ -64,6 +75,17 @@ const partialServerConfig = type({
tls_cert_path: "string?",
tls_key_path: "string?",
+
+ "proxy_auth?": {
+ enabled: "boolean?",
+ allowed_cidrs: "string[]?",
+ trusted_proxy_cidrs: "string[]?",
+ ip_header: "string?",
+ user_header: "string?",
+ email_header: "string?",
+ name_header: "string?",
+ picture_header: "string?",
+ },
});
const headscaleConfig = type({
diff --git a/app/server/context.ts b/app/server/context.ts
index cd51171..f1e3696 100644
--- a/app/server/context.ts
+++ b/app/server/context.ts
@@ -40,6 +40,18 @@ export async function createAppContext(config: HeadplaneConfig) {
const auth = createAuthService({
secret: config.server.cookie_secret,
headscaleApiKey,
+ proxyAuth: config.server.proxy_auth
+ ? {
+ enabled: config.server.proxy_auth.enabled,
+ allowedCidrs: config.server.proxy_auth.allowed_cidrs,
+ trustedProxyCidrs: config.server.proxy_auth.trusted_proxy_cidrs,
+ ipHeader: config.server.proxy_auth.ip_header,
+ userHeader: config.server.proxy_auth.user_header,
+ emailHeader: config.server.proxy_auth.email_header,
+ nameHeader: config.server.proxy_auth.name_header,
+ pictureHeader: config.server.proxy_auth.picture_header,
+ }
+ : undefined,
db,
cookie: {
name: "_hp_auth",
diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts
index 7eb0928..80efed8 100644
--- a/app/server/db/schema.ts
+++ b/app/server/db/schema.ts
@@ -32,7 +32,7 @@ export type HeadplaneUserInsert = typeof users.$inferInsert;
export const authSessions = sqliteTable("auth_sessions", {
id: text("id").primaryKey(),
- kind: text("kind").notNull(), // 'oidc' | 'api_key'
+ kind: text("kind").notNull(), // 'oidc' | 'api_key' (proxy auth is request-scoped)
user_id: text("user_id"),
api_key_hash: text("api_key_hash"),
api_key_display: text("api_key_display"),
diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts
index d194e4d..758fc95 100644
--- a/app/server/web/auth.ts
+++ b/app/server/web/auth.ts
@@ -1,4 +1,5 @@
import { createHash, createHmac } from "node:crypto";
+import { isIP } from "node:net";
import { eq, lt, sql } from "drizzle-orm";
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
@@ -17,23 +18,36 @@ export type Principal =
displayName: string;
apiKey: string;
}
- | {
- kind: "oidc";
- sessionId: string;
- idToken?: string;
- user: {
- id: string;
- subject: string;
- role: Role;
- headscaleUserId: string | undefined;
- };
- profile: {
- name: string;
- email?: string;
- username?: string;
- picture?: string;
- };
- };
+ | UserPrincipal;
+
+export type UserPrincipal = {
+ kind: "oidc" | "proxy";
+ sessionId: string;
+ idToken?: string;
+ user: {
+ id: string;
+ subject: string;
+ role: Role;
+ headscaleUserId: string | undefined;
+ };
+ profile: {
+ name: string;
+ email?: string;
+ username?: string;
+ picture?: string;
+ };
+};
+
+interface ProxyAuthOptions {
+ enabled: boolean;
+ allowedCidrs?: string[];
+ trustedProxyCidrs?: string[];
+ ipHeader?: string;
+ userHeader?: string;
+ emailHeader?: string;
+ nameHeader?: string;
+ pictureHeader?: string;
+}
interface CookiePayload {
sid: string;
@@ -48,6 +62,7 @@ interface CookiePayload {
export interface AuthServiceOptions {
secret: string;
headscaleApiKey?: string;
+ proxyAuth?: ProxyAuthOptions;
db: NodeSQLiteDatabase;
cookie: {
name: string;
@@ -58,6 +73,7 @@ export interface AuthServiceOptions {
}
export interface AuthService {
+ registerRequestClientAddress(request: Request, address: string | undefined): void;
require(request: Request): Promise;
can(principal: Principal, capabilities: Capabilities): boolean;
canManageNode(principal: Principal, node: Machine): boolean;
@@ -88,8 +104,152 @@ export interface AuthService {
stop(): void;
}
+export function isUserPrincipal(principal: Principal): principal is UserPrincipal {
+ return principal.kind === "oidc" || principal.kind === "proxy";
+}
+
+interface CidrRange {
+ family: 4 | 6;
+ base: bigint;
+ mask: bigint;
+}
+
+const DEFAULT_PROXY_AUTH_CIDRS = ["127.0.0.1/32", "::1/128"];
+const DEFAULT_PROXY_AUTH_USER_HEADER = "Remote-User";
+
+function normalizeIpAddress(address: string): string {
+ if (address.startsWith("::ffff:")) {
+ const mapped = address.slice("::ffff:".length);
+ if (isIP(mapped) === 4) {
+ return mapped;
+ }
+ }
+
+ return address;
+}
+
+function parseIpv4(address: string): bigint | undefined {
+ const parts = address.split(".");
+ if (parts.length !== 4) {
+ return;
+ }
+
+ let value = 0n;
+ for (const part of parts) {
+ if (!/^\d+$/.test(part)) {
+ return;
+ }
+
+ const byte = Number(part);
+ if (byte < 0 || byte > 255) {
+ return;
+ }
+
+ value = (value << 8n) + BigInt(byte);
+ }
+
+ return value;
+}
+
+function parseIpv6(address: string): bigint | undefined {
+ const sections = address.split("::");
+ if (sections.length > 2) {
+ return;
+ }
+
+ const head = sections[0] ? sections[0].split(":") : [];
+ const tail = sections.length === 2 && sections[1] ? sections[1].split(":") : [];
+ const missing = 8 - head.length - tail.length;
+ if (missing < 0 || (sections.length === 1 && missing !== 0)) {
+ return;
+ }
+
+ const groups = [...head, ...Array(missing).fill("0"), ...tail];
+ if (groups.length !== 8) {
+ return;
+ }
+
+ let value = 0n;
+ for (const group of groups) {
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) {
+ return;
+ }
+
+ value = (value << 16n) + BigInt(parseInt(group, 16));
+ }
+
+ return value;
+}
+
+function parseIpAddress(address: string): { family: 4 | 6; value: bigint } | undefined {
+ const normalized = normalizeIpAddress(address);
+ const family = isIP(normalized);
+ if (family === 4) {
+ const value = parseIpv4(normalized);
+ return value === undefined ? undefined : { family, value };
+ }
+ if (family === 6) {
+ const value = parseIpv6(normalized);
+ return value === undefined ? undefined : { family, value };
+ }
+
+ return;
+}
+
+function parseCidr(cidr: string): CidrRange {
+ const parts = cidr.trim().split("/");
+ if (parts.length > 2) {
+ throw new Error(`Invalid proxy auth CIDR: ${cidr}`);
+ }
+
+ const [rawAddress, rawPrefix] = parts;
+ const address = parseIpAddress(rawAddress);
+ if (!address) {
+ throw new Error(`Invalid proxy auth CIDR address: ${cidr}`);
+ }
+
+ const maxBits = address.family === 4 ? 32 : 128;
+ const prefix = rawPrefix === undefined ? maxBits : Number(rawPrefix);
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxBits) {
+ throw new Error(`Invalid proxy auth CIDR prefix: ${cidr}`);
+ }
+
+ const bits = BigInt(maxBits);
+ const hostBits = BigInt(maxBits - prefix);
+ const allOnes = (1n << bits) - 1n;
+ const mask = prefix === 0 ? 0n : (allOnes << hostBits) & allOnes;
+
+ return {
+ family: address.family,
+ base: address.value & mask,
+ mask,
+ };
+}
+
+function cidrContains(range: CidrRange, address: string): boolean {
+ const parsed = parseIpAddress(address);
+ if (!parsed || parsed.family !== range.family) {
+ return false;
+ }
+
+ return (parsed.value & range.mask) === range.base;
+}
+
export function createAuthService(opts: AuthServiceOptions): AuthService {
const requestCache = new WeakMap>();
+ const clientAddresses = new WeakMap();
+ const proxyAuthCidrs = opts.proxyAuth?.enabled
+ ? (opts.proxyAuth.allowedCidrs?.length
+ ? opts.proxyAuth.allowedCidrs
+ : DEFAULT_PROXY_AUTH_CIDRS
+ ).map(parseCidr)
+ : [];
+ const trustedProxyCidrs = opts.proxyAuth?.enabled
+ ? (opts.proxyAuth.trustedProxyCidrs?.length
+ ? opts.proxyAuth.trustedProxyCidrs
+ : DEFAULT_PROXY_AUTH_CIDRS
+ ).map(parseCidr)
+ : [];
let pruneTimer: ReturnType | undefined;
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise {
@@ -140,7 +300,139 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return createHash("sha256").update(key).digest("hex");
}
+ function registerRequestClientAddress(request: Request, address: string | undefined): void {
+ if (address) {
+ clientAddresses.set(request, address);
+ }
+ }
+
+ function getForwardedClientAddress(request: Request): string | undefined {
+ const headerName = opts.proxyAuth?.ipHeader;
+ if (!headerName) {
+ return;
+ }
+
+ const value = request.headers.get(headerName)?.trim();
+ if (!value) {
+ return;
+ }
+
+ const first = value.split(",")[0]?.trim();
+ if (!first) {
+ return;
+ }
+
+ return parseIpAddress(first) ? first : undefined;
+ }
+
+ function getProxyAuthClientAddress(request: Request): string | undefined {
+ const directAddress = clientAddresses.get(request);
+ if (!directAddress) {
+ return;
+ }
+
+ if (!opts.proxyAuth?.ipHeader) {
+ return directAddress;
+ }
+
+ const directPeerTrusted = trustedProxyCidrs.some((cidr) => cidrContains(cidr, directAddress));
+ if (!directPeerTrusted) {
+ return;
+ }
+
+ return getForwardedClientAddress(request);
+ }
+
+ async function resolveUserPrincipal(options: {
+ kind: UserPrincipal["kind"];
+ sessionId: string;
+ userId: string;
+ idToken?: string;
+ profile?: {
+ name?: string;
+ email?: string;
+ username?: string;
+ };
+ }): Promise {
+ const [user] = await opts.db.select().from(users).where(eq(users.id, options.userId)).limit(1);
+
+ if (!user) {
+ throw new Error("User record not found");
+ }
+
+ const role = (user.role in Roles ? user.role : "member") as Role;
+ return {
+ kind: options.kind,
+ sessionId: options.sessionId,
+ idToken: options.idToken,
+ user: {
+ id: user.id,
+ subject: user.sub,
+ role,
+ headscaleUserId: user.headscale_user_id ?? undefined,
+ },
+ profile: {
+ name: options.profile?.name ?? user.name ?? user.sub,
+ email: options.profile?.email ?? user.email ?? undefined,
+ username: options.profile?.username,
+ picture: user.picture ?? undefined,
+ },
+ };
+ }
+
+ async function resolveProxyAuthPrincipal(request: Request): Promise {
+ if (!opts.proxyAuth?.enabled) {
+ return;
+ }
+ if (!opts.headscaleApiKey) {
+ throw new Error("Proxy authentication requires headscale.api_key to be configured");
+ }
+
+ const clientAddress = getProxyAuthClientAddress(request);
+ if (!clientAddress || !proxyAuthCidrs.some((cidr) => cidrContains(cidr, clientAddress))) {
+ return;
+ }
+
+ const userHeader = opts.proxyAuth.userHeader ?? DEFAULT_PROXY_AUTH_USER_HEADER;
+ const proxyUser = request.headers.get(userHeader)?.trim();
+ if (!proxyUser) {
+ return;
+ }
+
+ const email = opts.proxyAuth.emailHeader
+ ? request.headers.get(opts.proxyAuth.emailHeader)?.trim()
+ : undefined;
+ const name = opts.proxyAuth.nameHeader
+ ? request.headers.get(opts.proxyAuth.nameHeader)?.trim()
+ : undefined;
+ const picture = opts.proxyAuth.pictureHeader
+ ? request.headers.get(opts.proxyAuth.pictureHeader)?.trim()
+ : undefined;
+ const subject = `proxy:${proxyUser}`;
+ const userId = await findOrCreateUser(subject, {
+ name: name || proxyUser,
+ email: email || undefined,
+ picture: picture || undefined,
+ });
+
+ return resolveUserPrincipal({
+ kind: "proxy",
+ sessionId: "proxy-auth",
+ userId,
+ profile: {
+ name: name || proxyUser,
+ email: email || undefined,
+ username: proxyUser,
+ },
+ });
+ }
+
async function resolve(request: Request): Promise {
+ const proxyPrincipal = await resolveProxyAuthPrincipal(request);
+ if (proxyPrincipal) {
+ return proxyPrincipal;
+ }
+
const payload = await decodeCookie(request);
const [session] = await opts.db
@@ -175,30 +467,17 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
throw new Error("OIDC session missing user_id");
}
- 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");
- }
-
- const role = (user.role in Roles ? user.role : "member") as Role;
- return {
+ return resolveUserPrincipal({
kind: "oidc",
sessionId: session.id,
idToken: session.oidc_id_token ?? undefined,
- user: {
- id: user.id,
- subject: user.sub,
- role,
- headscaleUserId: user.headscale_user_id ?? undefined,
- },
+ userId: session.user_id,
profile: {
- name: payload.profile?.name ?? user.name ?? user.sub,
- email: payload.profile?.email ?? user.email ?? undefined,
+ name: payload.profile?.name,
+ email: payload.profile?.email,
username: payload.profile?.username,
- picture: user.picture ?? undefined,
},
- };
+ });
}
function require(request: Request): Promise {
@@ -241,7 +520,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
}
if (!opts.headscaleApiKey) {
- throw new Error("OIDC sessions require headscale.api_key to be configured");
+ throw new Error("User sessions require headscale.api_key to be configured");
}
return opts.headscaleApiKey;
@@ -480,6 +759,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
}
return {
+ registerRequestClientAddress,
require: require,
can,
canManageNode,
diff --git a/config.example.yaml b/config.example.yaml
index 5eecad8..9e5176b 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -38,6 +38,36 @@ server:
# This may not work as expected if not using a reverse proxy.
# cookie_domain: ""
+ # Optional proxy authentication mode. When enabled, Headplane trusts identity
+ # headers from requests that come directly from one of `allowed_cidrs` and
+ # uses `headscale.api_key` for Headscale API access.
+ #
+ # Only enable this when a trusted reverse proxy in front of Headplane already
+ # performs authentication (for example, nginx basic auth, Authelia, Authentik,
+ # etc.). The source IP check uses the direct client address connected to
+ # Headplane by default, not X-Forwarded-For headers.
+ #
+ # If `allowed_cidrs` is omitted, only localhost is trusted.
+ # proxy_auth:
+ # enabled: false
+ #
+ # # Optional. If set, Headplane checks allowed_cidrs against the first IP in
+ # # this header (for example, "X-Forwarded-For" or "X-Real-IP") instead of
+ # # the direct client address. The direct client must still match
+ # # trusted_proxy_cidrs before this header is used.
+ # ip_header: "X-Forwarded-For"
+ # trusted_proxy_cidrs:
+ # - "127.0.0.1/32"
+ # - "::1/128"
+ #
+ # user_header: "Remote-User"
+ # email_header: "Remote-Email"
+ # name_header: "Remote-Name"
+ # picture_header: "Remote-Picture"
+ # allowed_cidrs:
+ # - "127.0.0.1/32"
+ # - "::1/128"
+
# The path to persist Headplane specific data. All data going forward
# is stored in this directory, including the internal database and
# any cache related files.
@@ -87,7 +117,8 @@ headscale:
config_path: "/etc/headscale/config.yaml"
# The API key used by Headplane for server-side operations.
- # This is required for OIDC authentication and the Headplane agent.
+ # This is required for OIDC authentication, proxy authentication, and the
+ # Headplane agent.
# Generate one with: headscale apikeys create
# api_key: ""
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index d04a606..504e861 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -50,7 +50,11 @@ export default defineConfig({
{
text: "Features",
items: [
- { text: "Single Sign-On (SSO)", link: "/features/sso" },
+ {
+ text: "Single Sign-On (SSO)",
+ link: "/features/sso",
+ items: [{ text: "Proxy Authentication", link: "/features/proxy-auth" }],
+ },
{ text: "Headplane Agent", link: "/features/agent" },
{ text: "Browser SSH", link: "/features/ssh" },
],
diff --git a/docs/NixOS-options.md b/docs/NixOS-options.md
index 41777ba..ea9316b 100644
--- a/docs/NixOS-options.md
+++ b/docs/NixOS-options.md
@@ -350,3 +350,82 @@ _Description:_ The port to listen on.
_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive)
_Default:_ `3000`
+
+## settings.server.proxy_auth
+
+_Description:_ Proxy authentication configuration.
+
+_Type:_ submodule
+
+_Default:_ `{ }`
+
+## settings.server.proxy_auth.allowed_cidrs
+
+_Description:_ Direct client CIDR ranges allowed to bypass Headplane's login flow.
+These should be the addresses your trusted reverse proxy uses to connect
+to Headplane. Requires headscale.api_key_path.
+
+_Type:_ list of string
+
+_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
+
+_Example:_ `[ "10.0.0.0/24" ]`
+
+## settings.server.proxy_auth.email_header
+
+_Description:_ Optional header containing the authenticated user's email address.
+
+_Type:_ null or string
+
+_Default:_ `null`
+
+## settings.server.proxy_auth.enabled
+
+_Description:_ Whether to trust reverse proxy authentication for allowed client CIDRs.
+
+_Type:_ boolean
+
+_Default:_ `false`
+
+## settings.server.proxy_auth.ip_header
+
+_Description:_ Optional header containing the original client IP, such as X-Forwarded-For or X-Real-IP.
+
+_Type:_ null or string
+
+_Default:_ `null`
+
+## settings.server.proxy_auth.name_header
+
+_Description:_ Optional header containing the authenticated user's display name.
+
+_Type:_ null or string
+
+_Default:_ `null`
+
+## settings.server.proxy_auth.picture_header
+
+_Description:_ Optional header containing the authenticated user's profile picture URL.
+
+_Type:_ null or string
+
+_Default:_ `null`
+
+## settings.server.proxy_auth.user_header
+
+_Description:_ Header containing the stable authenticated proxy user identity.
+
+_Type:_ string
+
+_Default:_ `"Remote-User"`
+
+## settings.server.proxy_auth.trusted_proxy_cidrs
+
+_Description:_ Direct proxy CIDR ranges trusted to supply ip_header.
+Only used when ip_header is set.
+
+_Type:_ list of string
+
+_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
+
+_Example:_ `[ "127.0.0.1/32" ]`
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
index 33b8759..8c7cc6f 100644
--- a/docs/configuration/index.md
+++ b/docs/configuration/index.md
@@ -58,8 +58,8 @@ The following configuration options in Headplane are treated as secret paths:
- _Note:_ Either `cookie_secret` or `cookie_secret_path` must be provided for web session security.
- **Headscale Connection Settings (`headscale.*`):**
- - `api_key_path` (Headscale API key for server-side operations like OIDC and agent sync)
- - _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC or the agent.
+ - `api_key_path` (Headscale API key for server-side operations like OIDC, proxy authentication, and agent sync)
+ - _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC, proxy authentication, or the agent.
- `tls_cert_path` (custom TLS certificate for connecting to Headscale)
- _Note:_ This is treated as a regular path, not a secret path, so it will not have its content loaded.
diff --git a/docs/features/proxy-auth.md b/docs/features/proxy-auth.md
new file mode 100644
index 0000000..40c6a3e
--- /dev/null
+++ b/docs/features/proxy-auth.md
@@ -0,0 +1,99 @@
+---
+title: Proxy Authentication
+description: Delegate Headplane authentication to a trusted reverse proxy.
+outline: [2, 3]
+---
+
+:::warning
+Proxy authentication is **dangerously powerful**. If misconfigured, it can allow
+anyone to impersonate users and gain access to Headplane.
+
+It is recommended to use Headplane's built-in SSO integrations over proxy
+authentication if possible. No guarantees are made about the security of proxy
+authentication.
+:::
+
+# Proxy Authentication
+
+Proxy authentication lets Headplane delegate user authentication to a trusted
+reverse proxy. This is useful when Headplane is already protected by middleware
+such as nginx basic auth, Authelia, Authentik, or another SSO-aware proxy and
+you do not want users to log in to Headplane separately.
+
+Proxy authentication is intentionally opt-in and requires `headscale.api_key`.
+When enabled, Headplane trusts identity headers only on requests whose client IP
+matches `server.proxy_auth.allowed_cidrs`; all Headscale API calls then use the
+configured `headscale.api_key`.
+
+## Basic Configuration
+
+```yaml
+headscale:
+ api_key: ""
+
+server:
+ proxy_auth:
+ enabled: true
+ user_header: "Remote-User"
+ email_header: "Remote-Email"
+ name_header: "Remote-Name"
+ allowed_cidrs:
+ - "127.0.0.1/32"
+ - "::1/128"
+```
+
+`user_header` is required for a request to authenticate and defaults to
+`Remote-User`. The value becomes the stable proxy identity in Headplane as
+`proxy:`. `email_header`, `name_header`, and `picture_header` are
+optional profile metadata headers.
+
+The first proxy-authenticated user is created as the Headplane owner, matching
+the normal SSO first-user behavior. Subsequent users are created as members and
+can be reassigned from the Users page.
+
+## Client IP Checks
+
+If `allowed_cidrs` is omitted, Headplane trusts only localhost. By default, this
+CIDR check uses the socket address connected to Headplane, not
+`X-Forwarded-For`, `X-Real-IP`, or other forwarded headers. Configure
+`allowed_cidrs` for the direct address range your proxy uses to connect to
+Headplane.
+
+## Forwarded Client IP Headers
+
+If you need to check the original client IP from a proxy header, set `ip_header`
+to `X-Forwarded-For`, `X-Real-IP`, or another header your proxy controls. When
+`ip_header` is set, Headplane only reads that header if the direct socket peer
+matches `trusted_proxy_cidrs` (default localhost). The first IP in the header is
+then checked against `allowed_cidrs`:
+
+```yaml
+server:
+ proxy_auth:
+ enabled: true
+ ip_header: "X-Forwarded-For"
+ trusted_proxy_cidrs:
+ - "127.0.0.1/32"
+ allowed_cidrs:
+ - "10.0.0.0/8"
+```
+
+::: warning
+Only enable proxy authentication when Headplane is not directly reachable by
+untrusted clients. Anyone who can connect to Headplane from an allowed CIDR will
+be able to spoof the configured identity headers. Only configure `ip_header`
+for headers set or overwritten by your trusted reverse proxy.
+:::
+
+## Header Reference
+
+| Field | Description |
+| --------------------------------------- | ------------------------------------------------------------------------------------ |
+| `server.proxy_auth.enabled` | Enables proxy authentication. |
+| `server.proxy_auth.user_header` | Header containing the stable authenticated user identity. Defaults to `Remote-User`. |
+| `server.proxy_auth.email_header` | Optional header containing the authenticated user's email address. |
+| `server.proxy_auth.name_header` | Optional header containing the authenticated user's display name. |
+| `server.proxy_auth.picture_header` | Optional header containing the authenticated user's profile picture URL. |
+| `server.proxy_auth.allowed_cidrs` | Client CIDRs allowed to authenticate. Defaults to localhost. |
+| `server.proxy_auth.ip_header` | Optional original-client-IP header such as `X-Forwarded-For` or `X-Real-IP`. |
+| `server.proxy_auth.trusted_proxy_cidrs` | Direct proxy CIDRs trusted to supply `ip_header`. Defaults to localhost. |
diff --git a/docs/features/sso.md b/docs/features/sso.md
index f5b54f0..41a5b81 100644
--- a/docs/features/sso.md
+++ b/docs/features/sso.md
@@ -17,6 +17,9 @@ Identity Provider (IdP) using the OpenID Connect (OIDC) protocol. When enabled,
users sign in through your IdP and Headplane automatically links them to their
Headscale identity, assigns a role, and manages their session.
+If your reverse proxy already performs authentication and can pass trusted user
+headers to Headplane, see [Proxy Authentication](./proxy-auth.md) instead.
+
## Getting Started
### Requirements
diff --git a/nix/options.nix b/nix/options.nix
index 35df7dc..33581d1 100644
--- a/nix/options.nix
+++ b/nix/options.nix
@@ -90,6 +90,77 @@ in {
example = "headscale.example.com";
};
+ proxy_auth = mkOption {
+ type = types.submodule {
+ options = {
+ enabled = mkOption {
+ type = types.bool;
+ default = false;
+ description = "Whether to trust reverse proxy authentication for allowed client CIDRs.";
+ };
+
+ user_header = mkOption {
+ type = types.str;
+ default = "Remote-User";
+ description = "Header containing the stable authenticated proxy user identity.";
+ };
+
+ ip_header = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Optional header containing the original client IP, such as X-Forwarded-For or X-Real-IP.";
+ };
+
+ trusted_proxy_cidrs = mkOption {
+ type = types.listOf types.str;
+ default = [
+ "127.0.0.1/32"
+ "::1/128"
+ ];
+ description = ''
+ Direct proxy CIDR ranges trusted to supply ip_header.
+ Only used when ip_header is set.
+ '';
+ example = ["127.0.0.1/32"];
+ };
+
+ email_header = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Optional header containing the authenticated user's email address.";
+ };
+
+ name_header = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Optional header containing the authenticated user's display name.";
+ };
+
+ picture_header = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Optional header containing the authenticated user's profile picture URL.";
+ };
+
+ allowed_cidrs = mkOption {
+ type = types.listOf types.str;
+ default = [
+ "127.0.0.1/32"
+ "::1/128"
+ ];
+ description = ''
+ Direct client CIDR ranges allowed to bypass Headplane's login flow.
+ These should be the addresses your trusted reverse proxy uses to connect
+ to Headplane. Requires headscale.api_key_path.
+ '';
+ example = ["10.0.0.0/24"];
+ };
+ };
+ };
+ default = {};
+ description = "Proxy authentication configuration.";
+ };
+
data_path = mkOption {
type = types.path;
default = "/var/lib/headplane";
diff --git a/tests/unit/auth/auth-service.test.ts b/tests/unit/auth/auth-service.test.ts
index 18a685b..246bcf8 100644
--- a/tests/unit/auth/auth-service.test.ts
+++ b/tests/unit/auth/auth-service.test.ts
@@ -196,6 +196,113 @@ describe("session round-trip", () => {
});
});
+describe("proxy authentication", () => {
+ test("creates a user-backed principal from trusted proxy headers", async () => {
+ const { auth } = createTestAuth({
+ headscaleApiKey: "configured-headscale-key",
+ proxyAuth: {
+ enabled: true,
+ allowedCidrs: ["10.10.0.0/16"],
+ emailHeader: "X-Forwarded-Email",
+ nameHeader: "X-Forwarded-Name",
+ },
+ });
+ const request = new Request("http://localhost/test", {
+ headers: {
+ "Remote-User": "alice",
+ "X-Forwarded-Email": "alice@example.com",
+ "X-Forwarded-Name": "Alice Example",
+ },
+ });
+
+ auth.registerRequestClientAddress(request, "10.10.42.9");
+
+ const principal = await auth.require(request);
+ expect(principal.kind).toBe("proxy");
+ if (principal.kind === "proxy") {
+ expect(principal.user.subject).toBe("proxy:alice");
+ expect(principal.user.role).toBe("owner");
+ expect(principal.profile.name).toBe("Alice Example");
+ expect(principal.profile.email).toBe("alice@example.com");
+ expect(principal.profile.username).toBe("alice");
+ }
+ expect(auth.getHeadscaleApiKey(principal)).toBe("configured-headscale-key");
+ });
+
+ test("uses localhost CIDRs by default when no allowed CIDRs are configured", async () => {
+ const { auth } = createTestAuth({
+ headscaleApiKey: "configured-headscale-key",
+ proxyAuth: { enabled: true },
+ });
+ const request = new Request("http://localhost/test", {
+ headers: { "Remote-User": "alice" },
+ });
+
+ auth.registerRequestClientAddress(request, "::ffff:127.0.0.1");
+
+ const principal = await auth.require(request);
+ expect(principal.kind).toBe("proxy");
+ expect(auth.getHeadscaleApiKey(principal)).toBe("configured-headscale-key");
+ });
+
+ test("falls back to normal session auth outside allowed CIDRs", async () => {
+ const { auth } = createTestAuth({
+ headscaleApiKey: "configured-headscale-key",
+ proxyAuth: { enabled: true, allowedCidrs: ["10.10.0.0/16"] },
+ });
+ const request = new Request("http://localhost/test");
+
+ auth.registerRequestClientAddress(request, "10.11.42.9");
+
+ await expect(auth.require(request)).rejects.toThrow("No session cookie found");
+ });
+
+ test("can check allowed CIDRs against a forwarded IP from a trusted proxy", async () => {
+ const { auth } = createTestAuth({
+ headscaleApiKey: "configured-headscale-key",
+ proxyAuth: {
+ enabled: true,
+ allowedCidrs: ["203.0.113.0/24"],
+ trustedProxyCidrs: ["10.0.0.0/24"],
+ ipHeader: "X-Forwarded-For",
+ },
+ });
+ const request = new Request("http://localhost/test", {
+ headers: {
+ "Remote-User": "alice",
+ "X-Forwarded-For": "203.0.113.42, 10.0.0.10",
+ },
+ });
+
+ auth.registerRequestClientAddress(request, "10.0.0.10");
+
+ const principal = await auth.require(request);
+ expect(principal.kind).toBe("proxy");
+ });
+
+ test("does not trust forwarded IP headers from untrusted direct peers", async () => {
+ const { auth } = createTestAuth({
+ headscaleApiKey: "configured-headscale-key",
+ proxyAuth: {
+ enabled: true,
+ allowedCidrs: ["203.0.113.0/24"],
+ trustedProxyCidrs: ["10.0.0.0/24"],
+ ipHeader: "X-Real-IP",
+ },
+ });
+ const request = new Request("http://localhost/test", {
+ headers: {
+ "Remote-User": "alice",
+ "X-Real-IP": "203.0.113.42",
+ },
+ });
+
+ auth.registerRequestClientAddress(request, "198.51.100.10");
+
+ await expect(auth.require(request)).rejects.toThrow("No session cookie found");
+ });
+});
+
describe("authorization", () => {
let auth: AuthService;
diff --git a/tests/unit/auth/create-auth.ts b/tests/unit/auth/create-auth.ts
index 9d463e3..ea2bb6f 100644
--- a/tests/unit/auth/create-auth.ts
+++ b/tests/unit/auth/create-auth.ts
@@ -3,12 +3,28 @@ import { migrate } from "drizzle-orm/node-sqlite/migrator";
import { createAuthService } from "~/server/web/auth";
-export function createTestAuth() {
+export function createTestAuth(
+ options: {
+ headscaleApiKey?: string;
+ proxyAuth?: {
+ enabled: boolean;
+ allowedCidrs?: string[];
+ trustedProxyCidrs?: string[];
+ ipHeader?: string;
+ userHeader?: string;
+ emailHeader?: string;
+ nameHeader?: string;
+ pictureHeader?: string;
+ };
+ } = {},
+) {
const db = drizzle(":memory:");
migrate(db, { migrationsFolder: "./drizzle" });
const auth = createAuthService({
secret: "test-secret-key-for-unit-tests",
+ headscaleApiKey: options.headscaleApiKey,
+ proxyAuth: options.proxyAuth,
db,
cookie: {
name: "_hp_test",