mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import type { AuthService, Principal } from "~/server/web/auth";
|
|
import { Capabilities } from "~/server/web/roles";
|
|
import type { Machine } from "~/types";
|
|
|
|
import { createTestAuth } from "./create-auth";
|
|
|
|
vi.mock("~/utils/log", () => ({
|
|
default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
|
|
}));
|
|
|
|
describe("findOrCreateUser", () => {
|
|
let auth: AuthService;
|
|
|
|
beforeEach(() => {
|
|
({ auth } = createTestAuth());
|
|
});
|
|
|
|
test("first created user becomes owner", async () => {
|
|
await auth.findOrCreateUser("sub-owner", { name: "Owner" });
|
|
const role = await auth.roleForSubject("sub-owner");
|
|
expect(role).toBe("owner");
|
|
});
|
|
|
|
test("second distinct user stays member", async () => {
|
|
await auth.findOrCreateUser("sub-owner", { name: "Owner" });
|
|
await auth.findOrCreateUser("sub-member", { name: "Member" });
|
|
const role = await auth.roleForSubject("sub-member");
|
|
expect(role).toBe("member");
|
|
});
|
|
|
|
test("second distinct user can receive a configured initial role", async () => {
|
|
await auth.findOrCreateUser("sub-owner", { name: "Owner" });
|
|
await auth.findOrCreateUser("sub-admin", { name: "Admin" }, { initialRole: "admin" });
|
|
const role = await auth.roleForSubject("sub-admin");
|
|
expect(role).toBe("admin");
|
|
});
|
|
|
|
test("first created user becomes owner even with a configured initial role", async () => {
|
|
await auth.findOrCreateUser("sub-owner", { name: "Owner" }, { initialRole: "viewer" });
|
|
const role = await auth.roleForSubject("sub-owner");
|
|
expect(role).toBe("owner");
|
|
});
|
|
|
|
test("configured initial roles cannot grant owner", async () => {
|
|
await auth.findOrCreateUser("sub-owner", { name: "Owner" });
|
|
await auth.findOrCreateUser("sub-other", { name: "Other" }, { initialRole: "owner" });
|
|
const role = await auth.roleForSubject("sub-other");
|
|
expect(role).toBe("member");
|
|
});
|
|
|
|
test("existing subject returns same id (idempotent)", async () => {
|
|
const id1 = await auth.findOrCreateUser("sub-1", { name: "Alice" });
|
|
const id2 = await auth.findOrCreateUser("sub-1", { name: "Alice" });
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
test("updates name and email on re-login", async () => {
|
|
await auth.findOrCreateUser("sub-1", { name: "Old", email: "old@test.com" });
|
|
await auth.findOrCreateUser("sub-1", { name: "New", email: "new@test.com" });
|
|
|
|
const users = await auth.listUsers();
|
|
const user = users.find((u) => u.sub === "sub-1");
|
|
expect(user?.name).toBe("New");
|
|
expect(user?.email).toBe("new@test.com");
|
|
});
|
|
|
|
test("syncs an existing non-owner role when explicitly provided", async () => {
|
|
await auth.findOrCreateUser("sub-owner");
|
|
await auth.findOrCreateUser("sub-1", { name: "User" });
|
|
|
|
await auth.findOrCreateUser("sub-1", { name: "User" }, { syncRole: "admin" });
|
|
|
|
const role = await auth.roleForSubject("sub-1");
|
|
expect(role).toBe("admin");
|
|
});
|
|
|
|
test("does not apply initial role to an existing user", async () => {
|
|
await auth.findOrCreateUser("sub-owner");
|
|
await auth.findOrCreateUser("sub-1", { name: "User" });
|
|
|
|
await auth.findOrCreateUser("sub-1", { name: "User" }, { initialRole: "admin" });
|
|
|
|
const role = await auth.roleForSubject("sub-1");
|
|
expect(role).toBe("member");
|
|
});
|
|
|
|
test("does not sync the owner role", async () => {
|
|
await auth.findOrCreateUser("sub-owner");
|
|
|
|
await auth.findOrCreateUser("sub-owner", { name: "Owner" }, { syncRole: "admin" });
|
|
|
|
const role = await auth.roleForSubject("sub-owner");
|
|
expect(role).toBe("owner");
|
|
});
|
|
});
|
|
|
|
describe("linkHeadscaleUser", () => {
|
|
let auth: AuthService;
|
|
|
|
beforeEach(() => {
|
|
({ auth } = createTestAuth());
|
|
});
|
|
|
|
test("links unclaimed Headscale user successfully", async () => {
|
|
const userId = await auth.findOrCreateUser("sub-1");
|
|
const result = await auth.linkHeadscaleUser(userId, "hs-1");
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
test("returns false if another Headplane user already claimed that headscale_user_id", async () => {
|
|
const id1 = await auth.findOrCreateUser("sub-1");
|
|
const id2 = await auth.findOrCreateUser("sub-2");
|
|
await auth.linkHeadscaleUser(id1, "hs-1");
|
|
|
|
const result = await auth.linkHeadscaleUser(id2, "hs-1");
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("unlinkHeadscaleUser clears the link", async () => {
|
|
const userId = await auth.findOrCreateUser("sub-1");
|
|
await auth.linkHeadscaleUser(userId, "hs-1");
|
|
await auth.unlinkHeadscaleUser(userId);
|
|
|
|
const users = await auth.listUsers();
|
|
const user = users.find((u) => u.id === userId);
|
|
expect(user?.headscale_user_id).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("reassignUser", () => {
|
|
let auth: AuthService;
|
|
|
|
beforeEach(() => {
|
|
({ auth } = createTestAuth());
|
|
});
|
|
|
|
test("updates an existing non-owner role", async () => {
|
|
await auth.findOrCreateUser("sub-owner");
|
|
const userId = await auth.findOrCreateUser("sub-user");
|
|
|
|
const result = await auth.reassignUser(userId, "admin");
|
|
expect(result).toBe(true);
|
|
|
|
const role = await auth.roleForSubject("sub-user");
|
|
expect(role).toBe("admin");
|
|
});
|
|
|
|
test("returns false for owner demotion attempt", async () => {
|
|
const ownerId = await auth.findOrCreateUser("sub-owner");
|
|
|
|
const result = await auth.reassignUser(ownerId, "member");
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("returns false when user does not exist", async () => {
|
|
const result = await auth.reassignUser("01ARZ3NDEKTSV4RRFFQ69G5FAV", "auditor");
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("transferOwnership", () => {
|
|
let auth: AuthService;
|
|
|
|
beforeEach(() => {
|
|
({ auth } = createTestAuth());
|
|
});
|
|
|
|
test("swaps roles: owner→admin, target→owner", async () => {
|
|
const ownerId = await auth.findOrCreateUser("sub-owner");
|
|
const targetId = await auth.findOrCreateUser("sub-target");
|
|
|
|
const result = await auth.transferOwnership(ownerId, targetId);
|
|
expect(result).toBe(true);
|
|
|
|
expect(await auth.roleForSubject("sub-owner")).toBe("admin");
|
|
expect(await auth.roleForSubject("sub-target")).toBe("owner");
|
|
});
|
|
|
|
test("returns false if caller is not owner", async () => {
|
|
const ownerId = await auth.findOrCreateUser("sub-owner");
|
|
const otherId = await auth.findOrCreateUser("sub-other");
|
|
|
|
const result = await auth.transferOwnership(otherId, ownerId);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("returns false if target doesn't exist", async () => {
|
|
const ownerId = await auth.findOrCreateUser("sub-owner");
|
|
|
|
const result = await auth.transferOwnership(ownerId, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("returns false if target is same user as owner", async () => {
|
|
const ownerId = await auth.findOrCreateUser("sub-owner");
|
|
|
|
const result = await auth.transferOwnership(ownerId, ownerId);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("session round-trip", () => {
|
|
let auth: AuthService;
|
|
|
|
beforeEach(() => {
|
|
({ auth } = createTestAuth());
|
|
});
|
|
|
|
test("createOidcSession + require resolves back to OIDC principal with correct user data", async () => {
|
|
const userId = await auth.findOrCreateUser("sub-1", { name: "Alice", email: "alice@test.com" });
|
|
|
|
const cookieHeader = await auth.createOidcSession(userId, {
|
|
name: "Alice",
|
|
email: "alice@test.com",
|
|
});
|
|
|
|
const cookieValue = cookieHeader.split(";")[0];
|
|
const request = new Request("http://localhost/test", {
|
|
headers: { cookie: cookieValue },
|
|
});
|
|
|
|
const principal = await auth.require(request);
|
|
expect(principal.kind).toBe("oidc");
|
|
if (principal.kind === "oidc") {
|
|
expect(principal.user.id).toBe(userId);
|
|
expect(principal.user.subject).toBe("sub-1");
|
|
expect(principal.profile.name).toBe("Alice");
|
|
expect(principal.profile.email).toBe("alice@test.com");
|
|
}
|
|
});
|
|
|
|
test("expired session throws", async () => {
|
|
const userId = await auth.findOrCreateUser("sub-1", { name: "Alice" });
|
|
|
|
const cookieHeader = await auth.createOidcSession(userId, { name: "Alice" }, { maxAge: -1 });
|
|
|
|
const cookieValue = cookieHeader.split(";")[0];
|
|
const request = new Request("http://localhost/test", {
|
|
headers: { cookie: cookieValue },
|
|
});
|
|
|
|
await expect(auth.require(request)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
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;
|
|
|
|
beforeEach(() => {
|
|
({ auth } = createTestAuth());
|
|
});
|
|
|
|
const apiKeyPrincipal: Principal = {
|
|
kind: "api_key",
|
|
sessionId: "test",
|
|
displayName: "Test Key",
|
|
apiKey: "key",
|
|
};
|
|
|
|
const oidcPrincipal: Principal = {
|
|
kind: "oidc",
|
|
sessionId: "test",
|
|
user: { id: "u1", subject: "sub1", role: "viewer", headscaleUserId: "hs-1" },
|
|
profile: { name: "Test" },
|
|
};
|
|
|
|
const machine: Machine = {
|
|
id: "m1",
|
|
machineKey: "",
|
|
nodeKey: "",
|
|
discoKey: "",
|
|
ipAddresses: [],
|
|
name: "test",
|
|
lastSeen: "",
|
|
expiry: null,
|
|
createdAt: "",
|
|
registerMethod: "REGISTER_METHOD_OIDC",
|
|
tags: [],
|
|
givenName: "test",
|
|
online: true,
|
|
approvedRoutes: [],
|
|
availableRoutes: [],
|
|
subnetRoutes: [],
|
|
user: { id: "hs-1", name: "test", createdAt: "" },
|
|
};
|
|
|
|
test("can() returns true for api_key principal regardless of capability", () => {
|
|
expect(auth.can(apiKeyPrincipal, Capabilities.write_machines)).toBe(true);
|
|
expect(auth.can(apiKeyPrincipal, Capabilities.owner)).toBe(true);
|
|
});
|
|
|
|
test("canManageNode() returns true when user owns the node (matching headscaleUserId)", () => {
|
|
expect(auth.canManageNode(oidcPrincipal, machine)).toBe(true);
|
|
});
|
|
|
|
test("canManageNode() returns false when user doesn't own the node and lacks write_machines", () => {
|
|
const otherMachine: Machine = {
|
|
...machine,
|
|
user: { id: "hs-other", name: "other", createdAt: "" },
|
|
};
|
|
expect(auth.canManageNode(oidcPrincipal, otherMachine)).toBe(false);
|
|
});
|
|
});
|