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",