Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions backend/__tests__/access-verify.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const authenticate = vi.fn();
vi.mock("../lib/auth/ldap.js", () => ({ authenticate: (...args) => authenticate(...args) }));

const { check, groupsAllow, invalidate, verify } = await import("../lib/auth/access-verify.js");

const ldapProvider = { id: 1, name: "Company LDAP", type: "ldap" };

const listWith = (overrides = {}) => ({
id: 7,
items: [],
allowed_groups: [],
...overrides,
});

beforeEach(() => {
authenticate.mockReset();
invalidate();
});

describe("groupsAllow", () => {
it("allows anyone when no groups are configured", () => {
expect(groupsAllow([], ["anything"])).toBe(true);
expect(groupsAllow(undefined, [])).toBe(true);
});

it("allows a member of one of the groups", () => {
expect(groupsAllow(["cn=vpn,ou=g"], ["cn=staff,ou=g", "cn=vpn,ou=g"])).toBe(true);
});

it("refuses somebody in none of them", () => {
expect(groupsAllow(["cn=vpn,ou=g"], ["cn=staff,ou=g"])).toBe(false);
expect(groupsAllow(["cn=vpn,ou=g"], [])).toBe(false);
expect(groupsAllow(["cn=vpn,ou=g"], undefined)).toBe(false);
});

it("ignores case and surrounding whitespace, since directories vary", () => {
expect(groupsAllow([" CN=VPN,OU=G "], ["cn=vpn,ou=g"])).toBe(true);
});

it("requires a whole match rather than a prefix", () => {
expect(groupsAllow(["cn=vpn,ou=g"], ["cn=vpn-readonly,ou=g"])).toBe(false);
});
});

describe("check", () => {
it("refuses empty credentials without troubling the directory", async () => {
const result = await check(listWith(), [ldapProvider], "", "");
expect(result.allowed).toBe(false);
expect(authenticate).not.toHaveBeenCalled();
});

it("refuses a blank password, which would otherwise be an anonymous bind", async () => {
const result = await check(listWith(), [ldapProvider], "alice", "");
expect(result.allowed).toBe(false);
expect(authenticate).not.toHaveBeenCalled();
});

it("accepts an entry from the list itself without asking a provider", async () => {
const list = listWith({ items: [{ username: "local", password: "secret" }] });
const result = await check(list, [ldapProvider], "local", "secret");

expect(result).toMatchObject({ allowed: true, via: "list" });
expect(authenticate).not.toHaveBeenCalled();
});

it("refuses a list entry with the wrong password", async () => {
const list = listWith({ items: [{ username: "local", password: "secret" }] });
expect((await check(list, [], "local", "nope")).allowed).toBe(false);
});

it("accepts a directory user the provider recognises", async () => {
authenticate.mockResolvedValue({ email: "alice@example.com", groups: [] });
const result = await check(listWith(), [ldapProvider], "alice", "pw");

expect(result).toMatchObject({ allowed: true, via: "Company LDAP", email: "alice@example.com" });
});

it("refuses a directory user outside the allowed groups", async () => {
authenticate.mockResolvedValue({ email: "bob@example.com", groups: ["cn=staff,ou=g"] });
const result = await check(listWith({ allowed_groups: ["cn=vpn,ou=g"] }), [ldapProvider], "bob", "pw");

expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/group/);
});

it("accepts a directory user inside the allowed groups", async () => {
authenticate.mockResolvedValue({ email: "bob@example.com", groups: ["cn=vpn,ou=g"] });
const result = await check(listWith({ allowed_groups: ["cn=vpn,ou=g"] }), [ldapProvider], "bob", "pw");

expect(result.allowed).toBe(true);
});

it("lets a list entry through even when groups are restricted", async () => {
const list = listWith({ items: [{ username: "local", password: "secret" }], allowed_groups: ["cn=vpn,ou=g"] });
expect((await check(list, [ldapProvider], "local", "secret")).allowed).toBe(true);
});

it("skips providers that cannot verify a password presented to us", async () => {
const result = await check(listWith(), [{ id: 2, name: "SSO", type: "oauth" }], "alice", "pw");

expect(result.allowed).toBe(false);
expect(authenticate).not.toHaveBeenCalled();
});

it("moves on to the next provider when one throws", async () => {
authenticate
.mockRejectedValueOnce(new Error("directory is down"))
.mockResolvedValueOnce({ email: "alice@example.com", groups: [] });

const result = await check(
listWith(),
[
{ id: 1, name: "Broken", type: "ldap" },
{ id: 2, name: "Working", type: "ldap" },
],
"alice",
"pw",
);

expect(result).toMatchObject({ allowed: true, via: "Working" });
});

it("refuses when every provider fails, rather than falling open", async () => {
authenticate.mockRejectedValue(new Error("directory is down"));
expect((await check(listWith(), [ldapProvider], "alice", "pw")).allowed).toBe(false);
});
});

describe("verify", () => {
it("answers a repeated request from cache instead of asking again", async () => {
authenticate.mockResolvedValue({ email: "alice@example.com", groups: [] });

const first = await verify(listWith(), [ldapProvider], "alice", "pw");
const second = await verify(listWith(), [ldapProvider], "alice", "pw");

expect(first.allowed).toBe(true);
expect(second.allowed).toBe(true);
expect(second.cached).toBe(true);
expect(authenticate).toHaveBeenCalledTimes(1);
});

it("does not let a cached pass cover a different password", async () => {
authenticate.mockResolvedValueOnce({ email: "alice@example.com", groups: [] }).mockResolvedValueOnce(null);

expect((await verify(listWith(), [ldapProvider], "alice", "right")).allowed).toBe(true);
expect((await verify(listWith(), [ldapProvider], "alice", "wrong")).allowed).toBe(false);
});

it("does not let one list's decision apply to another", async () => {
authenticate.mockResolvedValue({ email: "alice@example.com", groups: [] });

await verify(listWith({ id: 1 }), [ldapProvider], "alice", "pw");
await verify(listWith({ id: 2 }), [ldapProvider], "alice", "pw");

expect(authenticate).toHaveBeenCalledTimes(2);
});

it("forgets its decisions when a list changes", async () => {
authenticate.mockResolvedValue({ email: "alice@example.com", groups: [] });

await verify(listWith(), [ldapProvider], "alice", "pw");
invalidate(7);
await verify(listWith(), [ldapProvider], "alice", "pw");

expect(authenticate).toHaveBeenCalledTimes(2);
});
});
96 changes: 96 additions & 0 deletions backend/__tests__/definitions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { DEFAULTS, normalizeMeta, PROVIDER_TYPES, redactProvider, SECRET_FIELDS } from "../lib/auth/definitions.js";

describe("normalizeMeta", () => {
it("fills in the defaults for a type", () => {
const meta = normalizeMeta("ldap", {});
expect(meta.email_attribute).toBe("mail");
expect(meta.tls_reject_unauthorized).toBe(true);
expect(meta.auto_create_user).toBe(false);
});

it("keeps supplied values", () => {
const meta = normalizeMeta("ldap", { url: "ldaps://example.com", email_attribute: "userPrincipalName" });
expect(meta.url).toBe("ldaps://example.com");
expect(meta.email_attribute).toBe("userPrincipalName");
});

it("drops keys the type does not know about, so nothing arbitrary is persisted", () => {
const meta = normalizeMeta("ldap", { url: "ldap://x", evil: "payload", __proto__: { polluted: true } });
expect(meta.evil).toBeUndefined();
expect(meta.polluted).toBeUndefined();
expect(Object.hasOwn(meta, "evil")).toBe(false);
});

it("does not leak fields between provider types", () => {
const meta = normalizeMeta("saml", { client_secret: "oauth-only", entry_point: "https://idp" });
expect(meta.client_secret).toBeUndefined();
expect(meta.entry_point).toBe("https://idp");
});

it("treats null as absent so a default applies", () => {
expect(normalizeMeta("ldap", { email_attribute: null }).email_attribute).toBe("mail");
});

it("preserves false rather than replacing it with a truthy default", () => {
expect(normalizeMeta("ldap", { tls_reject_unauthorized: false }).tls_reject_unauthorized).toBe(false);
});

it("returns an empty object for an unknown type", () => {
expect(normalizeMeta("carrier-pigeon", { a: 1 })).toEqual({});
});

it("covers every declared provider type", () => {
for (const type of PROVIDER_TYPES) {
expect(DEFAULTS[type]).toBeDefined();
expect(Object.keys(normalizeMeta(type, {})).length).toBeGreaterThan(0);
}
});
});

describe("redactProvider", () => {
it("removes the secret and reports only whether one is stored", () => {
const redacted = redactProvider({
id: 1,
type: "ldap",
meta: { url: "ldap://x", bind_password: "topsecret" },
});

expect(redacted.meta.bind_password).toBeUndefined();
expect(redacted.meta.bind_password_set).toBe(true);
expect(redacted.meta.url).toBe("ldap://x");
});

it("reports false when no secret is stored", () => {
const redacted = redactProvider({ type: "ldap", meta: { bind_password: "" } });
expect(redacted.meta.bind_password_set).toBe(false);
});

it("redacts the secret of every provider type", () => {
for (const type of PROVIDER_TYPES) {
const meta = {};
for (const field of SECRET_FIELDS[type]) {
meta[field] = "a-secret";
}

const redacted = redactProvider({ type, meta });

for (const field of SECRET_FIELDS[type]) {
expect(redacted.meta[field]).toBeUndefined();
expect(redacted.meta[`${field}_set`]).toBe(true);
}
expect(JSON.stringify(redacted)).not.toContain("a-secret");
}
});

it("does not mutate the row it was given", () => {
const row = { type: "ldap", meta: { bind_password: "topsecret" } };
redactProvider(row);
expect(row.meta.bind_password).toBe("topsecret");
});

it("passes null and undefined straight through", () => {
expect(redactProvider(null)).toBeNull();
expect(redactProvider(undefined)).toBeUndefined();
});
});
Loading