mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 00:08:14 +00:00
feat(auth): support reverse-proxy driven proxy auth
Closes HP-353.
This commit is contained in:
@@ -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)).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-13
@@ -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)) {
|
||||
|
||||
+6
-9
@@ -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("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<string | null>(availableUsers[0]?.id);
|
||||
const [tags, setTags] = useState("");
|
||||
|
||||
@@ -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<Status>("active");
|
||||
@@ -199,6 +210,7 @@ export default function Page({
|
||||
</Link>
|
||||
</p>
|
||||
<AddAuthKey
|
||||
currentHeadscaleUserId={currentHeadscaleUserId}
|
||||
currentSubject={currentSubject}
|
||||
selfServiceOnly={selfServiceOnly}
|
||||
url={url}
|
||||
|
||||
@@ -66,7 +66,9 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
|
||||
// The user must exist within Headscale to generate a pre-auth key
|
||||
const users = await api.users.list();
|
||||
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
const hsUser = principal.user.headscaleUserId
|
||||
? users.find((u) => u.id === principal.user.headscaleUserId)
|
||||
: findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
|
||||
if (!hsUser) {
|
||||
throw data(sshErrors.user_not_linked, 404);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -63,5 +63,8 @@ export async function dispose(): Promise<void> {
|
||||
export default createRequestListener({
|
||||
build,
|
||||
mode: import.meta.env.MODE,
|
||||
getLoadContext: () => ctx,
|
||||
getLoadContext: (request, client) => {
|
||||
ctx.auth.registerRequestClientAddress(request, client.address);
|
||||
return ctx;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+316
-36
@@ -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<Principal>;
|
||||
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<string>(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<Request, Promise<Principal>>();
|
||||
const clientAddresses = new WeakMap<Request, string>();
|
||||
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<typeof setInterval> | undefined;
|
||||
|
||||
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
|
||||
@@ -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<UserPrincipal> {
|
||||
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<Principal | undefined> {
|
||||
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<Principal> {
|
||||
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<Principal> {
|
||||
@@ -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,
|
||||
|
||||
+32
-1
@@ -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: "<your-api-key>"
|
||||
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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" ]`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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: "<your-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:<value>`. `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. |
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user