mirror of
https://github.com/tale/headplane.git
synced 2026-08-08 13:23:13 +00:00
feat(auth): support reverse-proxy driven proxy auth
Closes HP-353.
This commit is contained in:
+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,
|
||||
|
||||
Reference in New Issue
Block a user