Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3105a78
Add LDAP, SAML and OAuth authentication providers
timothestoifl24 Aug 22, 2026
9f97b3b
Merge LDAP improvements from PR #5345, add directory sync and tests
timothestoifl24 Aug 22, 2026
87eb7cb
Test a provider's connection from inside its own settings
timothestoifl24 Aug 22, 2026
33e81c5
Let access lists accept authentication provider accounts
timothestoifl24 Aug 22, 2026
d1f2c3a
Add the missing OpenAPI examples that failed schema linting
timothestoifl24 Aug 22, 2026
b487237
Document every authentication provider environment variable
timothestoifl24 Aug 22, 2026
b2e4e15
Decide what happens to a provider's users when it is removed
timothestoifl24 Aug 22, 2026
70edfe9
Merge upstream develop into feature/auth-providers
timothestoifl24 Aug 22, 2026
58209f8
Gate account linking by email, and close the lockout paths around it
timothestoifl24 Aug 22, 2026
acc4ad6
Merge branch 'develop' into feature/auth-providers
timothestoifl24 Aug 29, 2026
288c14e
Merge branch 'develop' into feature/auth-providers
timothestoifl24 Sep 3, 2026
ca9cb7a
Merge branch 'develop' into feature/auth-providers
timothestoifl24 Sep 6, 2026
6674fb0
Merge branch 'develop' into feature/auth-providers
timothestoifl24 Sep 8, 2026
490a879
Merge branch 'develop' into feature/auth-providers
timothestoifl24 Sep 13, 2026
ef14319
Merge remote-tracking branch 'upstream/develop' into feature/auth-pro…
timothestoifl24 Sep 15, 2026
6ffde49
Add Estonian translations for authentication provider UI
timothestoifl24 Sep 15, 2026
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