diff --git a/backend/__tests__/access-verify.test.js b/backend/__tests__/access-verify.test.js new file mode 100644 index 0000000000..f503a2e947 --- /dev/null +++ b/backend/__tests__/access-verify.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/definitions.test.js b/backend/__tests__/definitions.test.js new file mode 100644 index 0000000000..7509c1d85d --- /dev/null +++ b/backend/__tests__/definitions.test.js @@ -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(); + }); +}); diff --git a/backend/__tests__/detach.test.js b/backend/__tests__/detach.test.js new file mode 100644 index 0000000000..21ad461563 --- /dev/null +++ b/backend/__tests__/detach.test.js @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * detachProviderUsers talks to three tables. Rather than stand up a database, + * these fakes model just enough of the query builder to exercise the decisions, + * which is where the risk actually is: who gets deleted and who is spared. + */ +const db = { auths: [], users: [], localAuthEnabled: true }; + +const makeQuery = (rows, onPatch) => { + const state = { filters: [], negations: [] }; + + const builder = { + where(field, opOrValue, maybeValue) { + if (typeof field === "object") { + for (const [k, v] of Object.entries(field)) { + state.filters.push([k, v]); + } + return builder; + } + if (maybeValue !== undefined && opOrValue === "!=") { + state.negations.push([field, maybeValue]); + } else { + state.filters.push([field, opOrValue]); + } + return builder; + }, + andWhere(...args) { + return builder.where(...args); + }, + matches() { + return rows().filter( + (r) => + state.filters.every(([k, v]) => (typeof r[k] === "boolean" ? r[k] === !!v : r[k] === v)) && + state.negations.every(([k, v]) => r[k] !== v), + ); + }, + first() { + return Promise.resolve(builder.matches()[0]); + }, + patch(changes) { + const rows = builder.matches(); + for (const row of rows) { + onPatch(row, changes); + } + return Promise.resolve(rows.length); + }, + // biome-ignore lint/suspicious/noThenProperty: objection query builders are thenables, so the fake must be awaitable the same way + then(resolve, reject) { + return Promise.resolve(builder.matches()).then(resolve, reject); + }, + }; + return builder; +}; + +const applyPatch = (row, changes) => { + for (const [k, v] of Object.entries(changes)) { + row[k] = typeof v === "number" && (k === "is_deleted" || k === "is_disabled") ? !!v : v; + } +}; + +vi.mock("../models/auth.js", () => ({ + default: { query: () => makeQuery(() => db.auths, applyPatch) }, +})); +vi.mock("../models/user.js", () => ({ + default: { query: () => makeQuery(() => db.users, applyPatch) }, +})); +vi.mock("../models/user_permission.js", () => ({ default: {} })); +// The lockout guard asks whether local sign in is still available, since an +// administrator holding only a password is no fallback once it is switched off +vi.mock("../lib/auth/local-auth.js", () => ({ + isLocalAuthEnabled: async () => db.localAuthEnabled, + LOCAL_AUTH_SETTING: "auth-local", + localAuthDisabledByEnv: () => null, +})); + +const { detachProviderUsers } = await import("../lib/auth/provision.js"); + +const provider = { id: 1, name: "Company LDAP" }; + +beforeEach(() => { + db.auths = []; + db.users = []; + db.localAuthEnabled = true; +}); + +const addUser = (id, email, roles = [], opts = {}) => { + db.users.push({ id, email, roles, is_deleted: false, is_disabled: false, ...opts }); +}; +const addLink = (id, userId, providerId, type = "ldap") => { + db.auths.push({ id, user_id: userId, provider_id: providerId, type, is_deleted: false }); +}; + +describe("detachProviderUsers, converting", () => { + it("keeps the accounts and drops only the link", async () => { + addUser(1, "alice@example.com"); + addLink(10, 1, 1); + + const result = await detachProviderUsers(provider, "convert"); + + expect(result).toMatchObject({ converted: 1, deleted: 0 }); + expect(db.users[0].is_deleted).toBe(false); + expect(db.auths[0].is_deleted).toBe(true); + }); + + it("is the default, so omitting the action never deletes anyone", async () => { + addUser(1, "alice@example.com"); + addLink(10, 1, 1); + + await detachProviderUsers(provider); + + expect(db.users[0].is_deleted).toBe(false); + }); + + it("leaves other providers' links alone", async () => { + addUser(1, "alice@example.com"); + addLink(10, 1, 1); + addLink(11, 1, 2); + + await detachProviderUsers(provider, "convert"); + + expect(db.auths.find((a) => a.id === 10).is_deleted).toBe(true); + expect(db.auths.find((a) => a.id === 11).is_deleted).toBe(false); + }); +}); + +describe("detachProviderUsers, deleting", () => { + it("removes an account that has no other way in", async () => { + addUser(1, "alice@example.com"); + addUser(2, "admin@example.com", ["admin"]); + addLink(10, 1, 1); + + const result = await detachProviderUsers(provider, "delete"); + + expect(result).toMatchObject({ deleted: 1, converted: 0 }); + expect(db.users.find((u) => u.id === 1).is_deleted).toBe(true); + }); + + it("keeps somebody who also has a password", async () => { + addUser(1, "alice@example.com"); + addLink(10, 1, 1); + addLink(11, 1, 0, "password"); + + const result = await detachProviderUsers(provider, "delete"); + + expect(result.deleted).toBe(0); + expect(result.converted).toBe(1); + expect(result.kept[0]).toMatchObject({ email: "alice@example.com" }); + expect(db.users[0].is_deleted).toBe(false); + }); + + it("keeps somebody who also signs in through another provider", async () => { + addUser(1, "alice@example.com"); + addLink(10, 1, 1); + addLink(11, 1, 2); + + const result = await detachProviderUsers(provider, "delete"); + + expect(result.deleted).toBe(0); + expect(db.users[0].is_deleted).toBe(false); + }); + + it("never removes the last remaining administrator", async () => { + addUser(1, "admin@example.com", ["admin"]); + addLink(10, 1, 1); + + const result = await detachProviderUsers(provider, "delete"); + + expect(result.deleted).toBe(0); + expect(result.kept[0].reason).toMatch(/only administrator/); + expect(db.users[0].is_deleted).toBe(false); + }); + + it("removes an administrator while another can still sign in elsewhere", async () => { + addUser(1, "admin-a@example.com", ["admin"]); + addUser(2, "admin-b@example.com", ["admin"]); + addLink(10, 1, 1); + addLink(11, 2, 1); + // admin-b also signs in through a provider that is staying + addLink(12, 2, 2); + + const result = await detachProviderUsers(provider, "delete"); + + expect(result.deleted).toBe(1); + expect(db.users.find((u) => u.id === 1).is_deleted).toBe(true); + expect(db.users.find((u) => u.id === 2).is_deleted).toBe(false); + }); + + it("ignores an account that was already deleted", async () => { + addUser(1, "gone@example.com", [], { is_deleted: true }); + addLink(10, 1, 1); + + const result = await detachProviderUsers(provider, "delete"); + + expect(result).toMatchObject({ converted: 0, deleted: 0 }); + }); + + it("spares an admin whose only fallback cannot sign in either", async () => { + // Local sign in is off, so the other administrator's password is no + // longer a way in and they do not count as a fallback + db.localAuthEnabled = false; + addUser(1, "admin-a@example.com", ["admin"]); + addUser(2, "admin-b@example.com", ["admin"]); + addLink(10, 1, 1); + addLink(11, 2, 0, "password"); + + const result = await detachProviderUsers(provider, "delete"); + + expect(result.deleted).toBe(0); + expect(result.kept[0].reason).toMatch(/only administrator/); + expect(db.users.find((u) => u.id === 1).is_deleted).toBe(false); + }); + + it("counts a password holding admin as a fallback while local sign in is on", async () => { + db.localAuthEnabled = true; + addUser(1, "admin-a@example.com", ["admin"]); + addUser(2, "admin-b@example.com", ["admin"]); + addLink(10, 1, 1); + addLink(11, 2, 0, "password"); + + const result = await detachProviderUsers(provider, "delete"); + + expect(result.deleted).toBe(1); + expect(db.users.find((u) => u.id === 1).is_deleted).toBe(true); + }); + + it("does not count an admin who only uses the provider being removed", async () => { + addUser(1, "admin-a@example.com", ["admin"]); + addUser(2, "admin-b@example.com", ["admin"]); + addLink(10, 1, 1); + addLink(11, 2, 1); + // Both are ours, so removing the provider takes the second one's only + // way in with it; whoever is considered first has no real fallback + const result = await detachProviderUsers(provider, "delete"); + + expect(result.deleted).toBe(0); + expect(result.kept).toHaveLength(2); + }); + + it("does nothing at all when the provider owns no accounts", async () => { + addUser(1, "local@example.com"); + addLink(10, 1, 0, "password"); + + const result = await detachProviderUsers(provider, "delete"); + + expect(result).toMatchObject({ converted: 0, deleted: 0 }); + expect(db.auths[0].is_deleted).toBe(false); + }); +}); diff --git a/backend/__tests__/env.test.js b/backend/__tests__/env.test.js new file mode 100644 index 0000000000..ae546e9166 --- /dev/null +++ b/backend/__tests__/env.test.js @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// env.js reaches the database through the model; the parsing this file covers +// does not, so stub it out rather than standing up a connection. +vi.mock("../models/auth_provider.js", () => ({ default: {} })); +// env.js now reaches provision.js, which imports these; without stubs the real +// modules open a database connection and try to write a JWT key file +vi.mock("../models/auth.js", () => ({ default: {} })); +vi.mock("../models/user.js", () => ({ default: {} })); +vi.mock("../models/user_permission.js", () => ({ default: {} })); +// env.js also reaches local-auth.js, which reads the setting behind AUTH_DISABLE_LOCAL +vi.mock("../models/setting.js", () => ({ default: {} })); + +const { getEnvProviders, localAuthDisabledByEnv } = await import("../lib/auth/env.js"); + +const AUTH_VARS = /^AUTH_/; +let saved; + +beforeEach(() => { + saved = { ...process.env }; + for (const key of Object.keys(process.env)) { + if (AUTH_VARS.test(key)) { + delete process.env[key]; + } + } +}); + +afterEach(() => { + process.env = saved; +}); + +describe("getEnvProviders", () => { + it("returns nothing when no provider is enabled", () => { + expect(getEnvProviders()).toEqual([]); + }); + + it("builds an LDAP provider from the environment", () => { + process.env.AUTH_LDAP_ENABLED = "true"; + process.env.AUTH_LDAP_URL = "ldaps://ldap.example.com:636"; + process.env.AUTH_LDAP_BASE_DN = "dc=example,dc=com"; + + const [provider] = getEnvProviders(); + + expect(provider.type).toBe("ldap"); + expect(provider.slug).toBe("env-ldap"); + expect(provider.is_env_managed).toBe(true); + expect(provider.is_enabled).toBe(true); + expect(provider.meta.url).toBe("ldaps://ldap.example.com:636"); + expect(provider.meta.base_dn).toBe("dc=example,dc=com"); + }); + + it("names a provider after its type when no name is given", () => { + process.env.AUTH_SAML_ENABLED = "1"; + expect(getEnvProviders()[0].name).toBe("SAML"); + + process.env.AUTH_SAML_NAME = "Company SSO"; + expect(getEnvProviders()[0].name).toBe("Company SSO"); + }); + + it.each(["1", "true", "TRUE", "yes", "on"])("treats %s as enabled", (value) => { + process.env.AUTH_LDAP_ENABLED = value; + expect(getEnvProviders()).toHaveLength(1); + }); + + it.each(["0", "false", "no", "off", "", "banana"])("treats %s as not enabled", (value) => { + process.env.AUTH_LDAP_ENABLED = value; + expect(getEnvProviders()).toHaveLength(0); + }); + + it("applies boolean defaults, including ones that default to true", () => { + process.env.AUTH_LDAP_ENABLED = "true"; + expect(getEnvProviders()[0].meta.tls_reject_unauthorized).toBe(true); + + process.env.AUTH_LDAP_TLS_REJECT_UNAUTHORIZED = "false"; + expect(getEnvProviders()[0].meta.tls_reject_unauthorized).toBe(false); + }); + + it("parses integers and falls back when they are nonsense", () => { + process.env.AUTH_LDAP_ENABLED = "true"; + process.env.AUTH_LDAP_TIMEOUT = "2500"; + expect(getEnvProviders()[0].meta.timeout).toBe(2500); + + process.env.AUTH_LDAP_TIMEOUT = "not-a-number"; + expect(getEnvProviders()[0].meta.timeout).toBe(10000); + }); + + it("splits a comma separated role list", () => { + process.env.AUTH_LDAP_ENABLED = "true"; + process.env.AUTH_LDAP_DEFAULT_ROLES = "admin, viewer ,, "; + expect(getEnvProviders()[0].meta.default_roles).toEqual(["admin", "viewer"]); + }); + + it("reads the sync settings", () => { + process.env.AUTH_LDAP_ENABLED = "true"; + process.env.AUTH_LDAP_SYNC_ENABLED = "true"; + process.env.AUTH_LDAP_SYNC_INTERVAL = "15"; + process.env.AUTH_LDAP_SYNC_DISABLE_MISSING = "yes"; + + const { meta } = getEnvProviders()[0]; + expect(meta.sync_enabled).toBe(true); + expect(meta.sync_interval).toBe(15); + expect(meta.sync_disable_missing).toBe(true); + }); + + it("can configure all three types at once, each with its own slug", () => { + process.env.AUTH_LDAP_ENABLED = "true"; + process.env.AUTH_SAML_ENABLED = "true"; + process.env.AUTH_OAUTH_ENABLED = "true"; + + const providers = getEnvProviders(); + expect(providers.map((p) => p.slug)).toEqual(["env-ldap", "env-saml", "env-oauth"]); + expect(new Set(providers.map((p) => p.sort_order)).size).toBe(3); + }); +}); + +describe("localAuthDisabledByEnv", () => { + it("returns null when unset, so the stored setting decides", () => { + delete process.env.AUTH_DISABLE_LOCAL; + expect(localAuthDisabledByEnv()).toBeNull(); + + process.env.AUTH_DISABLE_LOCAL = ""; + expect(localAuthDisabledByEnv()).toBeNull(); + }); + + it("overrides in both directions once set", () => { + process.env.AUTH_DISABLE_LOCAL = "true"; + expect(localAuthDisabledByEnv()).toBe(true); + + process.env.AUTH_DISABLE_LOCAL = "false"; + expect(localAuthDisabledByEnv()).toBe(false); + }); +}); diff --git a/backend/__tests__/guid.test.js b/backend/__tests__/guid.test.js new file mode 100644 index 0000000000..a84664fb8b --- /dev/null +++ b/backend/__tests__/guid.test.js @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { extractDirectoryGuid, guidToLdapFilter, normalizeEntryUuid, parseObjectGuid } from "../lib/auth/guid.js"; + +// The first three groups of an objectGUID are little-endian and the last two +// big-endian, so a straight hex dump of the bytes is the wrong answer. These +// vectors pin the byte order down. +const VECTORS = [ + { + bytes: [0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xa7, 0xb8, 0xc9, 0xd0, 0xe1, 0xf2, 0xa3, 0xb4, 0xc5, 0xd6], + guid: "d4c3b2a1-f6e5-b8a7-c9d0-e1f2a3b4c5d6", + }, + { + bytes: [0x6d, 0x3b, 0xf5, 0x9a, 0x12, 0x4e, 0x7c, 0x41, 0x8b, 0xd2, 0xe0, 0xc5, 0x42, 0x3a, 0xf1, 0x08], + guid: "9af53b6d-4e12-417c-8bd2-e0c5423af108", + }, + { + bytes: [0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f], + guid: "83828180-8584-8786-8889-8a8b8c8d8e8f", + }, +]; + +describe("parseObjectGuid", () => { + it.each(VECTORS)("decodes $guid", ({ bytes, guid }) => { + expect(parseObjectGuid(Buffer.from(bytes))).toBe(guid); + }); + + it("rejects anything that is not 16 bytes", () => { + expect(() => parseObjectGuid(Buffer.from([1, 2, 3]))).toThrow("exactly 16 bytes"); + expect(() => parseObjectGuid(Buffer.alloc(17))).toThrow("exactly 16 bytes"); + }); + + it("handles an all-zero GUID", () => { + expect(parseObjectGuid(Buffer.alloc(16))).toBe("00000000-0000-0000-0000-000000000000"); + }); +}); + +describe("guidToLdapFilter", () => { + it.each(VECTORS)("round-trips $guid back to the original bytes", ({ bytes, guid }) => { + const filter = guidToLdapFilter(guid); + const decoded = Buffer.from( + filter + .split("\\") + .filter(Boolean) + .map((byte) => Number.parseInt(byte, 16)), + ); + expect(decoded.equals(Buffer.from(bytes))).toBe(true); + }); + + it("escapes every byte, so the filter is safe to interpolate", () => { + const filter = guidToLdapFilter(VECTORS[0].guid); + expect(filter).toBe("\\a1\\b2\\c3\\d4\\e5\\f6\\a7\\b8\\c9\\d0\\e1\\f2\\a3\\b4\\c5\\d6"); + expect(filter.match(/\\/g)).toHaveLength(16); + }); + + it("rejects malformed input", () => { + expect(() => guidToLdapFilter("not-a-guid")).toThrow("Invalid GUID"); + expect(() => guidToLdapFilter("")).toThrow("Invalid GUID"); + }); + + it("accepts upper case", () => { + expect(guidToLdapFilter(VECTORS[0].guid.toUpperCase())).toBe(guidToLdapFilter(VECTORS[0].guid)); + }); +}); + +describe("normalizeEntryUuid", () => { + it("lowercases a valid UUID", () => { + expect(normalizeEntryUuid("550E8400-E29B-41D4-A716-446655440000")).toBe("550e8400-e29b-41d4-a716-446655440000"); + }); + + it("returns null for anything that is not a UUID", () => { + expect(normalizeEntryUuid("nope")).toBeNull(); + expect(normalizeEntryUuid("550e8400e29b41d4a716446655440000")).toBeNull(); + }); +}); + +describe("extractDirectoryGuid", () => { + it("prefers objectGUID", () => { + expect( + extractDirectoryGuid({ + objectGUID: Buffer.from(VECTORS[0].bytes), + entryUUID: "550e8400-e29b-41d4-a716-446655440000", + }), + ).toEqual({ guid: VECTORS[0].guid, source: "objectGUID" }); + }); + + it("falls back to entryUUID", () => { + expect(extractDirectoryGuid({ entryUUID: "550e8400-e29b-41d4-a716-446655440000" })).toEqual({ + guid: "550e8400-e29b-41d4-a716-446655440000", + source: "entryUUID", + }); + }); + + it("unwraps single-element arrays, which is how some servers reply", () => { + expect(extractDirectoryGuid({ objectGUID: [Buffer.from(VECTORS[1].bytes)] })).toEqual({ + guid: VECTORS[1].guid, + source: "objectGUID", + }); + }); + + it("falls through to entryUUID when objectGUID is the wrong length", () => { + expect( + extractDirectoryGuid({ + objectGUID: Buffer.from([1, 2, 3]), + entryUUID: "550e8400-e29b-41d4-a716-446655440000", + }), + ).toEqual({ guid: "550e8400-e29b-41d4-a716-446655440000", source: "entryUUID" }); + }); + + it("returns null when the directory publishes neither, so the caller can fall back to the DN", () => { + expect(extractDirectoryGuid({ cn: "alice" })).toBeNull(); + expect(extractDirectoryGuid(null)).toBeNull(); + }); +}); diff --git a/backend/__tests__/ldap-errors.test.js b/backend/__tests__/ldap-errors.test.js new file mode 100644 index 0000000000..0155403bfc --- /dev/null +++ b/backend/__tests__/ldap-errors.test.js @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { describeLdapError } from "../lib/auth/ldap.js"; + +describe("describeLdapError", () => { + it("explains the common protocol result codes", () => { + expect(describeLdapError({ code: 49 })).toMatch(/Invalid credentials/); + expect(describeLdapError({ code: 32 })).toMatch(/base DN/); + expect(describeLdapError({ code: 8 })).toMatch(/LDAPS or StartTLS/); + expect(describeLdapError({ code: 50 })).toMatch(/permission/); + }); + + it("explains connection level failures", () => { + expect(describeLdapError({ code: "ECONNREFUSED" })).toMatch(/Connection refused/); + expect(describeLdapError({ code: "ENOTFOUND" })).toMatch(/Server not found/); + expect(describeLdapError({ code: "ETIMEDOUT" })).toMatch(/timed out/); + }); + + it("explains an untrusted certificate", () => { + expect(describeLdapError({ code: "DEPTH_ZERO_SELF_SIGNED_CERT" })).toMatch(/TLS certificate/); + expect(describeLdapError({ code: "SELF_SIGNED_CERT_IN_CHAIN" })).toMatch(/TLS certificate/); + }); + + it("never returns the bare result code that ldapts produces on its own", () => { + // The whole point: "Code: 0x31" tells an administrator nothing + const described = describeLdapError({ code: 49, message: " Code: 0x31" }); + expect(described).not.toBe(" Code: 0x31"); + expect(described).toMatch(/bind DN and password/); + }); + + it("falls back to the driver message for codes it does not know", () => { + expect(describeLdapError({ code: 9999, message: "something specific" })).toBe("something specific"); + }); + + it("copes with an error carrying neither a known code nor a message", () => { + expect(describeLdapError({ code: 4242 })).toBe("LDAP error (code 4242)"); + expect(describeLdapError(null)).toBe("Unknown LDAP error"); + }); +}); diff --git a/backend/__tests__/ldap-filter.test.js b/backend/__tests__/ldap-filter.test.js new file mode 100644 index 0000000000..d0fbbe5fda --- /dev/null +++ b/backend/__tests__/ldap-filter.test.js @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { buildUserFilter, escapeFilterValue } from "../lib/auth/ldap.js"; + +describe("escapeFilterValue", () => { + it("escapes the characters that would otherwise close or extend a filter", () => { + expect(escapeFilterValue("(")).toBe("\\28"); + expect(escapeFilterValue(")")).toBe("\\29"); + expect(escapeFilterValue("*")).toBe("\\2a"); + expect(escapeFilterValue("\\")).toBe("\\5c"); + expect(escapeFilterValue("\0")).toBe("\\00"); + }); + + it("leaves ordinary values alone", () => { + expect(escapeFilterValue("alice")).toBe("alice"); + expect(escapeFilterValue("alice@example.com")).toBe("alice@example.com"); + expect(escapeFilterValue("cn=alice,ou=users,dc=example,dc=com")).toBe("cn=alice,ou=users,dc=example,dc=com"); + }); + + it("neutralises a wildcard, so a blank password cannot match every account", () => { + expect(escapeFilterValue("*")).not.toContain("*"); + }); + + it("neutralises an attempted filter injection", () => { + // Without escaping this would turn (uid=X) into an OR that always matches + const injected = "x)(|(uid=*"; + const escaped = escapeFilterValue(injected); + expect(escaped).toBe("x\\29\\28|\\28uid=\\2a"); + expect(escaped).not.toMatch(/[()*]/); + }); + + it("escapes a backslash before anything else, so escapes cannot be forged", () => { + // A naive implementation that replaced ( before \ would turn this into + // a real parenthesis + expect(escapeFilterValue("\\28")).toBe("\\5c28"); + }); + + it("coerces non-strings rather than throwing", () => { + expect(escapeFilterValue(42)).toBe("42"); + }); +}); + +describe("buildUserFilter", () => { + it("uses a hand written filter when one is set", () => { + expect(buildUserFilter({ user_filter: "(uid={{username}})" }, "alice")).toBe("(uid=alice)"); + }); + + it("substitutes every occurrence of the placeholder", () => { + expect(buildUserFilter({ user_filter: "(|(uid={{username}})(mail={{username}}))" }, "alice")).toBe( + "(|(uid=alice)(mail=alice))", + ); + }); + + it("escapes the value before substituting it", () => { + expect(buildUserFilter({ user_filter: "(uid={{username}})" }, "x)(|(uid=*")).toBe( + "(uid=x\\29\\28|\\28uid=\\2a)", + ); + }); + + it("builds a single clause from one login attribute", () => { + expect(buildUserFilter({ login_attributes: "uid" }, "alice")).toBe("(uid=alice)"); + }); + + it("builds an OR from several login attributes", () => { + expect(buildUserFilter({ login_attributes: "uid, mail, sAMAccountName" }, "alice")).toBe( + "(|(uid=alice)(mail=alice)(sAMAccountName=alice))", + ); + }); + + it("prefers a hand written filter over login attributes", () => { + expect(buildUserFilter({ user_filter: "(cn={{username}})", login_attributes: "uid,mail" }, "alice")).toBe( + "(cn=alice)", + ); + }); + + it("falls back to uid when nothing is configured", () => { + expect(buildUserFilter({}, "alice")).toBe("(uid=alice)"); + }); + + it("ignores blank entries in the attribute list", () => { + expect(buildUserFilter({ login_attributes: "uid,, ,mail" }, "alice")).toBe("(|(uid=alice)(mail=alice))"); + }); +}); diff --git a/backend/__tests__/link-by-email.test.js b/backend/__tests__/link-by-email.test.js new file mode 100644 index 0000000000..cae9491bc4 --- /dev/null +++ b/backend/__tests__/link-by-email.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +// canLinkByEmail is a pure decision; provision.js reaches the database for +// everything else, so stub the models rather than opening a connection. +vi.mock("../models/auth.js", () => ({ default: {} })); +vi.mock("../models/user.js", () => ({ default: {} })); +vi.mock("../models/user_permission.js", () => ({ default: {} })); +vi.mock("../models/setting.js", () => ({ default: {} })); +vi.mock("../models/auth_provider.js", () => ({ default: {} })); + +const { canLinkByEmail } = await import("../lib/auth/provision.js"); + +const provider = (type, linkByEmail) => ({ + name: `Test ${type}`, + type, + meta: { link_by_email: linkByEmail }, +}); + +const identity = (extra = {}) => ({ email: "alice@example.com", ...extra }); + +describe("canLinkByEmail", () => { + it("refuses by default, whatever the provider type", () => { + for (const type of ["ldap", "saml", "oauth"]) { + expect(canLinkByEmail(provider(type, false), identity({ email_verified: true }))).toBe(false); + expect(canLinkByEmail({ name: "x", type, meta: {} }, identity({ email_verified: true }))).toBe(false); + } + }); + + it("allows a directory to vouch for an address once configured", () => { + expect(canLinkByEmail(provider("ldap", true), identity())).toBe(true); + expect(canLinkByEmail(provider("saml", true), identity())).toBe(true); + }); + + it("requires OIDC to say the address was verified", () => { + expect(canLinkByEmail(provider("oauth", true), identity({ email_verified: true }))).toBe(true); + expect(canLinkByEmail(provider("oauth", true), identity({ email_verified: false }))).toBe(false); + // A provider that never sends the claim is not vouching for anything + expect(canLinkByEmail(provider("oauth", true), identity())).toBe(false); + }); + + it("does not accept a truthy value in place of a verified address", () => { + // The claim is normalised to a real boolean upstream, so anything else + // reaching here means the provider said something we cannot read + expect(canLinkByEmail(provider("oauth", true), identity({ email_verified: "yes" }))).toBe(false); + expect(canLinkByEmail(provider("oauth", true), identity({ email_verified: 1 }))).toBe(false); + }); +}); diff --git a/backend/__tests__/roles.test.js b/backend/__tests__/roles.test.js new file mode 100644 index 0000000000..df3db5e5d6 --- /dev/null +++ b/backend/__tests__/roles.test.js @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; + +// provision.js talks to the database; the role mapping this file covers is a +// pure function, so stub the models out rather than opening a connection. +vi.mock("../models/auth.js", () => ({ default: {} })); +vi.mock("../models/user.js", () => ({ default: {} })); +vi.mock("../models/user_permission.js", () => ({ default: {} })); +vi.mock("../models/setting.js", () => ({ default: {} })); +vi.mock("../models/auth_provider.js", () => ({ default: {} })); + +const { resolveRoles } = await import("../lib/auth/provision.js"); + +const provider = (adminGroup, name = "Company LDAP") => ({ name, meta: { admin_group: adminGroup } }); + +describe("resolveRoles", () => { + it("leaves roles alone when no admin group is configured", () => { + expect(resolveRoles(provider(""), { groups: ["anything"] }, ["admin"])).toBeNull(); + expect(resolveRoles(provider(undefined), { groups: [] }, [])).toBeNull(); + }); + + it("treats a whitespace-only group as not configured", () => { + expect(resolveRoles(provider(" "), { groups: ["x"] }, [])).toBeNull(); + }); + + it("leaves roles alone when membership could not be read", () => { + // null means the group lookup failed, which must not be read as "in no + // groups": that would strip admin from everyone during an outage + expect(resolveRoles(provider("npm-admins"), { groups: null, email: "a@b.c" }, ["admin"])).toBeNull(); + expect(resolveRoles(provider("npm-admins"), { email: "a@b.c" }, ["admin"])).toBeNull(); + }); + + it("still revokes admin when the directory genuinely reports no groups", () => { + expect(resolveRoles(provider("npm-admins"), { groups: [], email: "a@b.c" }, ["admin"])).toEqual([]); + }); + + it("grants admin to a member of the group", () => { + expect(resolveRoles(provider("npm-admins"), { groups: ["staff", "npm-admins"] }, [])).toEqual(["admin"]); + }); + + it("revokes admin once someone leaves the group", () => { + expect(resolveRoles(provider("npm-admins"), { groups: ["staff"] }, ["admin"])).toEqual([]); + }); + + it("matches case insensitively, since directories are inconsistent about it", () => { + expect(resolveRoles(provider("CN=NPM-Admins,OU=Groups"), { groups: ["cn=npm-admins,ou=groups"] }, [])).toEqual([ + "admin", + ]); + }); + + it("requires an exact match, not a substring", () => { + expect(resolveRoles(provider("npm-admins"), { groups: ["npm-admins-readonly"] }, [])).toEqual([]); + }); + + it("keeps other roles untouched while changing admin", () => { + expect(resolveRoles(provider("npm-admins"), { groups: ["npm-admins"] }, ["viewer"]).sort()).toEqual([ + "admin", + "viewer", + ]); + expect(resolveRoles(provider("npm-admins"), { groups: [] }, ["viewer", "admin"])).toEqual(["viewer"]); + }); + + it("does not duplicate admin for someone who already has it", () => { + expect(resolveRoles(provider("npm-admins"), { groups: ["npm-admins"] }, ["admin"])).toEqual(["admin"]); + }); + + it("copes with non-string group values", () => { + expect(resolveRoles(provider("123"), { groups: [123] }, [])).toEqual(["admin"]); + }); +}); diff --git a/backend/__tests__/saml-request-cache.test.js b/backend/__tests__/saml-request-cache.test.js new file mode 100644 index 0000000000..47c6f09d22 --- /dev/null +++ b/backend/__tests__/saml-request-cache.test.js @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +const { requestCache } = await import("../lib/auth/saml.js"); + +/** + * node-saml stores the id of each authentication request it issues and looks it + * up again when the assertion comes back, refusing anything that does not name + * a request we are still waiting for. It removes the id once used, which is + * what makes an assertion single use; these cover the store that behaviour + * rests on, since a fresh SAML instance is built per request and the default + * per-instance cache would lose the id in between. + */ +describe("the SAML request cache", () => { + it("hands back what was stored", async () => { + await requestCache.saveAsync("id-1", "2026-01-01T00:00:00Z"); + expect(await requestCache.getAsync("id-1")).toBe("2026-01-01T00:00:00Z"); + }); + + it("knows nothing about a request it never issued", async () => { + expect(await requestCache.getAsync("never-issued")).toBeNull(); + }); + + it("forgets an id once it has been used, so it cannot answer twice", async () => { + await requestCache.saveAsync("id-2", "now"); + expect(await requestCache.removeAsync("id-2")).toBe("id-2"); + expect(await requestCache.getAsync("id-2")).toBeNull(); + // A replay removes nothing, because there is nothing left to remove + expect(await requestCache.removeAsync("id-2")).toBeNull(); + }); + + it("refuses to overwrite an id that is already outstanding", async () => { + await requestCache.saveAsync("id-3", "first"); + expect(await requestCache.saveAsync("id-3", "second")).toBeNull(); + expect(await requestCache.getAsync("id-3")).toBe("first"); + }); + + it("copes with a null key, which node-saml passes when there is none", async () => { + expect(await requestCache.removeAsync(null)).toBeNull(); + expect(await requestCache.getAsync(null)).toBeNull(); + }); + + it("drops ids that have sat there past their lifetime", async () => { + await requestCache.saveAsync("id-4", "stale"); + // Age the entry rather than waiting ten minutes for it + requestCache.entries.get("id-4").createdAt -= 11 * 60 * 1000; + expect(await requestCache.getAsync("id-4")).toBeNull(); + }); +}); diff --git a/backend/internal/2fa.js b/backend/internal/2fa.js index db1c3385ea..9b330b44a6 100644 --- a/backend/internal/2fa.js +++ b/backend/internal/2fa.js @@ -33,7 +33,10 @@ const internal2fa = { * @returns {Promise} */ isEnabled: async (userId) => { - const auth = await internal2fa.getUserPasswordAuth(userId); + // Users who only sign in through an external provider have no password + // auth row, and therefore no TOTP secret to check against. + const auth = await authModel.query().where("user_id", userId).andWhere("type", "password").first(); + return auth?.meta?.totp_enabled === true; }, diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index fc9e8dbab1..a4fe127be0 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -1,6 +1,7 @@ import fs from "node:fs"; import batchflow from "batchflow"; import _ from "lodash"; +import { invalidate as invalidateAccessCache, verify } from "../lib/auth/access-verify.js"; import errs from "../lib/error.js"; import utils from "../lib/utils.js"; import { access as logger } from "../logger.js"; @@ -9,6 +10,7 @@ import accessListAuthModel from "../models/access_list_auth.js"; import accessListClientModel from "../models/access_list_client.js"; import proxyHostModel from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; +import internalAuthProvider from "./auth-provider.js"; import internalNginx from "./nginx.js"; const omissions = () => { @@ -29,6 +31,8 @@ const internalAccessList = { name: data.name, satisfy_any: data.satisfy_any, pass_auth: data.pass_auth, + auth_provider_ids: data.auth_provider_ids || [], + allowed_groups: data.allowed_groups || [], owner_user_id: access.token.getUserId(1), }) .then(utils.omitRow(omissions())); @@ -108,11 +112,16 @@ const internalAccessList = { // patch name if specified if (typeof data.name !== "undefined" && data.name) { - await accessListModel.query().where({ id: data.id }).patch({ - name: data.name, - satisfy_any: data.satisfy_any, - pass_auth: data.pass_auth, - }); + await accessListModel + .query() + .where({ id: data.id }) + .patch({ + name: data.name, + satisfy_any: data.satisfy_any, + pass_auth: data.pass_auth, + auth_provider_ids: data.auth_provider_ids || [], + allowed_groups: data.allowed_groups || [], + }); } // Check for items and add/update/remove them @@ -390,6 +399,49 @@ const internalAccessList = { return list; }, + /** + * Answers whether a set of HTTP Basic credentials may pass an access list. + * + * Called by nginx as a subrequest for every request to a protected site, so + * it has to stay cheap: results are cached, and a list with no providers + * never reaches here at all because nginx handles it with a htpasswd file. + * + * Deliberately unauthenticated. It is the site visitor's credentials being + * checked, not an administrator's, and the answer is only ever yes or no. + * + * @param {Integer} listId + * @param {String} username + * @param {String} password + * @returns {Promise} { allowed, via } + */ + verifyCredentials: async (listId, username, password) => { + const id = Number.parseInt(listId, 10); + if (Number.isNaN(id)) { + return { allowed: false, reason: "unknown access list" }; + } + + const list = await accessListModel + .query() + .where("id", id) + .andWhere("is_deleted", 0) + .withGraphFetched("[items]") + .first(); + + if (!list) { + return { allowed: false, reason: "unknown access list" }; + } + + const wanted = list.auth_provider_ids || []; + let providers = []; + + if (wanted.length) { + const enabled = await internalAuthProvider.getEnabled(); + providers = enabled.filter((p) => wanted.includes(p.id)); + } + + return await verify(list, providers, username, password); + }, + /** * @param {Object} list * @param {Integer} list.id @@ -409,6 +461,9 @@ const internalAccessList = { build: async (list) => { logger.info(`Building Access file #${list.id} for: ${list.name}`); + // The list has changed, so any decision made under the old rules is stale + invalidateAccessCache(list.id); + const htpasswdFile = internalAccessList.getFilename(list); // 1. remove any existing access file diff --git a/backend/internal/auth-provider.js b/backend/internal/auth-provider.js new file mode 100644 index 0000000000..56b9e8f3a4 --- /dev/null +++ b/backend/internal/auth-provider.js @@ -0,0 +1,587 @@ +import { normalizeMeta, PROVIDER_TYPES, redactProvider, SECRET_FIELDS } from "../lib/auth/definitions.js"; +import * as ldap from "../lib/auth/ldap.js"; +import { + ensureAWayBackIn, + isLocalAuthEnabled, + LOCAL_AUTH_SETTING, + localAuthDisabledByEnv, +} from "../lib/auth/local-auth.js"; +import * as oauth from "../lib/auth/oauth.js"; +import { detachProviderUsers, resolveUser } from "../lib/auth/provision.js"; +import * as saml from "../lib/auth/saml.js"; +import * as sync from "../lib/auth/sync.js"; +import errs from "../lib/error.js"; +import { auth as logger } from "../logger.js"; +import authModel from "../models/auth.js"; +import authProviderModel from "../models/auth_provider.js"; +import settingModel from "../models/setting.js"; +import userModel from "../models/user.js"; +import internalAuditLog from "./audit-log.js"; + +/** + * Turns a display name into a slug that's unique among providers. + * + * @param {String} name + * @param {Integer} [ignoreId] + * @returns {Promise} + */ +const generateSlug = async (name, ignoreId) => { + const base = + String(name || "provider") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "provider"; + + for (let suffix = 0; suffix < 100; suffix++) { + const slug = suffix === 0 ? base : `${base}-${suffix}`; + const query = authProviderModel.query().where("slug", slug).first(); + if (ignoreId) { + query.andWhere("id", "!=", ignoreId); + } + const existing = await query; + if (!existing) { + return slug; + } + } + + throw new errs.ValidationError(`Could not generate a unique identifier for "${name}"`); +}; + +const internalAuthProvider = { + /** + * Reconciles the directory sync timers with what is currently configured. + * Called after any change so the schedule never drifts from the database. + * + * @returns {Promise} how many providers are scheduled + */ + refreshSchedules: async () => { + const providers = await internalAuthProvider.getEnabled(); + return sync.reschedule(providers); + }, + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: async (access, data) => { + await access.can("auth_providers:create", data); + + if (!PROVIDER_TYPES.includes(data.type)) { + throw new errs.ValidationError(`Unknown authentication provider type: ${data.type}`); + } + + const row = await authProviderModel.query().insertAndFetch({ + name: data.name, + type: data.type, + slug: await generateSlug(data.name), + is_enabled: typeof data.is_enabled === "undefined" ? true : !!data.is_enabled, + is_env_managed: false, + sort_order: data.sort_order || 0, + meta: normalizeMeta(data.type, data.meta), + }); + + await internalAuditLog.add(access, { + action: "created", + object_type: "auth-provider", + object_id: row.id, + meta: redactProvider(row), + }); + + await internalAuthProvider.refreshSchedules(); + return redactProvider(row); + }, + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + update: async (access, data) => { + await access.can("auth_providers:update", data.id); + + const row = await internalAuthProvider.getRaw(data.id); + if (row.is_env_managed) { + throw new errs.ValidationError( + "This provider is configured through environment variables and cannot be edited here", + ); + } + + // The type is what determines the shape of meta, so it can't change + if (typeof data.type !== "undefined" && data.type !== row.type) { + throw new errs.ValidationError("The type of an existing authentication provider cannot be changed"); + } + + const patch = {}; + if (typeof data.name !== "undefined") { + patch.name = data.name; + } + if (typeof data.is_enabled !== "undefined") { + patch.is_enabled = !!data.is_enabled; + } + if (typeof data.sort_order !== "undefined") { + patch.sort_order = data.sort_order; + } + if (typeof data.meta !== "undefined") { + patch.meta = internalAuthProvider.mergeMeta(row, data.meta); + } + + await authProviderModel.query().where("id", row.id).patch(patch); + const updated = await internalAuthProvider.getRaw(row.id); + + await internalAuditLog.add(access, { + action: "updated", + object_type: "auth-provider", + object_id: updated.id, + meta: redactProvider(updated), + }); + + await internalAuthProvider.refreshSchedules(); + return redactProvider(updated); + }, + + /** + * Secrets are never sent to the client, so an update that leaves them out + * (or blank) must keep whatever is already stored. + * + * @param {Object} row + * @param {Object} meta + * @returns {Object} + */ + mergeMeta: (row, meta) => { + const merged = normalizeMeta(row.type, { ...(row.meta || {}), ...(meta || {}) }); + (SECRET_FIELDS[row.type] || []).forEach((field) => { + if (!meta || typeof meta[field] === "undefined" || meta[field] === "") { + merged[field] = row.meta?.[field] || ""; + } + delete merged[`${field}_set`]; + }); + return merged; + }, + + /** + * Fetches a provider including its secrets. For internal use only. + * + * @param {Integer} id + * @returns {Promise} + */ + getRaw: async (id) => { + const row = await authProviderModel.query().where("id", id).andWhere("is_deleted", 0).first(); + if (!row) { + throw new errs.ItemNotFoundError(id); + } + return row; + }, + + /** + * @param {Access} access + * @param {Integer} id + * @returns {Promise} + */ + get: async (access, id) => { + await access.can("auth_providers:get", id); + return redactProvider(await internalAuthProvider.getRaw(id)); + }, + + /** + * @param {Access} access + * @returns {Promise} + */ + getAll: async (access) => { + await access.can("auth_providers:list"); + const rows = await authProviderModel + .query() + .where("is_deleted", 0) + .orderBy("sort_order", "ASC") + .orderBy("name", "ASC"); + return rows.map(redactProvider); + }, + + /** + * @param {Access} access + * @param {Integer} id + * @returns {Promise} + */ + delete: async (access, id, userAction = "convert") => { + await access.can("auth_providers:delete", id); + + if (!["convert", "delete"].includes(userAction)) { + throw new errs.ValidationError( + `Unknown action for this provider's users: ${userAction}. Use "convert" or "delete".`, + ); + } + + const row = await internalAuthProvider.getRaw(id); + if (row.is_env_managed) { + throw new errs.ValidationError( + "This provider is configured through environment variables. Remove its variables to delete it.", + ); + } + + // Decide what becomes of its accounts before the provider itself goes, + // so they are never left pointing at something that no longer exists + const users = await detachProviderUsers(row, userAction); + + await authProviderModel.query().where("id", row.id).patch({ is_deleted: true, is_enabled: false }); + + await internalAuditLog.add(access, { + action: "deleted", + object_type: "auth-provider", + object_id: row.id, + meta: { ...redactProvider(row), users }, + }); + + sync.unschedule(row.id); + await internalAuthProvider.refreshSchedules(); + + // Removing the last provider while the password form is off would leave + // nobody able to sign in + const localRestored = await ensureAWayBackIn(); + + return { ...users, deleted_provider: true, local_auth_restored: localRestored }; + }, + + /** + * How many accounts a provider currently owns, and how many of those would + * be removed rather than kept if its users were deleted along with it. + * + * Used to tell an administrator what they are about to do. + * + * @param {Access} access + * @param {Integer} id + * @returns {Promise} + */ + getUserImpact: async (access, id) => { + await access.can("auth_providers:get", id); + const row = await internalAuthProvider.getRaw(id); + + const links = await authModel.query().where("provider_id", row.id).andWhere("is_deleted", 0); + + let removable = 0; + for (const link of links) { + const user = await userModel.query().where("id", link.user_id).andWhere("is_deleted", 0).first(); + if (!user) { + continue; + } + // Anyone with another sign in method survives either way + const other = await authModel + .query() + .where("user_id", user.id) + .andWhere("is_deleted", 0) + .andWhere("id", "!=", link.id) + .first(); + if (!other) { + removable++; + } + } + + return { users: links.length, removable }; + }, + + /** + * Checks that a provider's settings actually work, without signing anyone in. + * + * @param {Access} access + * @param {Integer} id + * @param {String} callbackUrl + * @returns {Promise} + */ + test: async (access, id, callbackUrl) => { + await access.can("auth_providers:update", id); + const row = await internalAuthProvider.getRaw(id); + + switch (row.type) { + case "ldap": + await ldap.test(row); + break; + case "saml": + await saml.test(row, callbackUrl); + break; + case "oauth": + await oauth.test(row); + break; + default: + throw new errs.ValidationError(`Unknown authentication provider type: ${row.type}`); + } + + return { valid: true }; + }, + + /** + * Tests connection settings that have not been saved yet, so the details can + * be checked while they are still being filled in. + * + * When an id is supplied the stored secrets are merged in for any field left + * blank, matching how an update behaves: the client never receives a secret + * back, so it cannot send one it was not given. + * + * Unlike the other calls this reports a failure as a result rather than an + * error, because the caller wants to show it inline next to the fields. + * + * @param {Access} access + * @param {Object} data + * @param {String} data.type + * @param {Object} data.meta + * @param {Integer} [data.id] An existing provider to take stored secrets from + * @returns {Promise} + */ + testConfig: async (access, data) => { + await access.can("auth_providers:update", data.id || 0); + + if (!PROVIDER_TYPES.includes(data.type)) { + throw new errs.ValidationError(`Unknown authentication provider type: ${data.type}`); + } + + let meta = normalizeMeta(data.type, data.meta); + + if (data.id) { + const stored = await internalAuthProvider.getRaw(data.id); + if (stored.type !== data.type) { + throw new errs.ValidationError("The type of an existing authentication provider cannot be changed"); + } + meta = internalAuthProvider.mergeMeta(stored, data.meta); + } + + const provider = { id: data.id || 0, name: data.name || "unsaved provider", type: data.type, meta }; + + try { + switch (provider.type) { + case "ldap": + await ldap.test(provider); + return { valid: true, detail: meta.bind_dn ? "bound" : "connected anonymously" }; + case "oauth": { + const endpoints = await oauth.getEndpoints(provider); + return { + valid: true, + detail: endpoints.issuer ? `discovered ${endpoints.issuer}` : "endpoints configured", + }; + } + case "saml": + await saml.test(provider, data.callback_url || "https://example.com/api/auth/0/callback"); + return { valid: true, detail: "certificate and settings accepted" }; + default: + throw new errs.ValidationError(`Unknown authentication provider type: ${provider.type}`); + } + } catch (err) { + logger.debug(`Connection test failed for a ${provider.type} provider: ${err.message}`); + return { valid: false, error: err.message }; + } + }, + + /** + * Verifies a real username and password against a provider, without issuing + * a token. Lets an administrator confirm a directory works before turning it + * on, and shows exactly which attributes came back. + * + * @param {Access} access + * @param {Integer} id + * @param {String} username + * @param {String} password + * @returns {Promise} + */ + testCredentials: async (access, id, username, password) => { + await access.can("auth_providers:update", id); + const row = await internalAuthProvider.getRaw(id); + + if (row.type !== "ldap") { + throw new errs.ValidationError("Only LDAP providers can be tested with a username and password"); + } + + return await ldap.testAuthentication(row, username, password); + }, + + /** + * Runs a directory sync now, rather than waiting for the schedule. + * + * @param {Access} access + * @param {Integer} id + * @returns {Promise} + */ + sync: async (access, id) => { + await access.can("auth_providers:update", id); + const row = await internalAuthProvider.getRaw(id); + + if (row.type !== "ldap") { + throw new errs.ValidationError("Directory sync is only available for LDAP providers"); + } + if (!row.is_enabled) { + throw new errs.ValidationError("Enable the provider before syncing it"); + } + + const result = await sync.runSync(row); + + await internalAuditLog.add(access, { + action: "updated", + object_type: "auth-provider", + object_id: row.id, + meta: { name: row.name, sync: result }, + }); + + return result; + }, + + /** + * @param {Access} access + * @param {Integer} id + * @returns {Promise} + */ + getSyncStatus: async (access, id) => { + await access.can("auth_providers:get", id); + const row = await internalAuthProvider.getRaw(id); + + return { + supported: row.type === "ldap", + enabled: !!row.meta?.sync_enabled, + running: sync.isRunning(row.id), + last_result: sync.getLastResult(row.id), + }; + }, + + /** + * Every enabled provider, with secrets. Used by the login flows. + * + * @param {String} [type] + * @returns {Promise<[Object]>} + */ + getEnabled: async (type) => { + const query = authProviderModel + .query() + .where("is_deleted", 0) + .andWhere("is_enabled", 1) + .orderBy("sort_order", "ASC") + .orderBy("id", "ASC"); + + if (type) { + query.andWhere("type", type); + } + + return await query; + }, + + /** + * Tries every enabled LDAP provider in turn with the supplied credentials. + * + * A directory that's unreachable or misconfigured is logged and skipped so + * that it can't take the remaining providers down with it. + * + * @param {String} identity + * @param {String} secret + * @returns {Promise} the local user, or null if nothing matched + */ + authenticateLdap: async (identity, secret) => { + const providers = await internalAuthProvider.getEnabled("ldap"); + + for (const provider of providers) { + let result = null; + try { + result = await ldap.authenticate(provider, identity, secret); + } catch (err) { + logger.error(`LDAP provider "${provider.name}" failed: ${err.message}`); + continue; + } + + if (result) { + logger.info(`Authenticated ${result.email} against LDAP provider "${provider.name}"`); + return await resolveUser(provider, result); + } + } + + return null; + }, + + /** + * The unauthenticated view used to render the login page. Deliberately + * minimal: an attacker should not learn anything about the configuration. + * + * @returns {Promise} + */ + getLoginOptions: async () => { + const providers = await internalAuthProvider.getEnabled(); + const localEnabled = await internalAuthProvider.isLocalAuthEnabled(); + + return { + local_enabled: localEnabled, + // LDAP is driven by the normal username/password form rather than a button + ldap_enabled: providers.some((p) => p.type === "ldap"), + providers: providers + .filter((p) => p.type === "saml" || p.type === "oauth") + .map((p) => ({ + id: p.id, + name: p.name, + type: p.type, + })), + }; + }, + + /** + * @returns {Promise} + */ + isLocalAuthEnabled, + + /** + * @param {Access} access + * @param {Boolean} enabled + * @returns {Promise} + */ + setLocalAuthEnabled: async (access, enabled) => { + await access.can("settings:update", LOCAL_AUTH_SETTING); + + if (!enabled) { + if (localAuthDisabledByEnv() !== null) { + // The env var is authoritative either way, so don't pretend otherwise + throw new errs.ValidationError( + "Local authentication is controlled by the AUTH_DISABLE_LOCAL environment variable", + ); + } + + const providers = await internalAuthProvider.getEnabled(); + if (!providers.length) { + throw new errs.ValidationError( + "Enable at least one authentication provider before turning off local sign in", + ); + } + + // Having a provider is not the same as being able to use it. If no + // administrator has actually signed in through one yet, turning the + // password form off locks everybody out of their own instance, with + // no way back in short of editing the database. + const enabledIds = providers.map((p) => p.id); + const admins = await userModel.query().where("is_deleted", 0).andWhere("is_disabled", 0); + + const linked = await authModel + .query() + .whereIn( + "user_id", + admins.filter((u) => (u.roles || []).includes("admin")).map((u) => u.id), + ) + .andWhere("is_deleted", 0) + .andWhere("type", "!=", "password") + .whereIn("provider_id", enabledIds); + + if (!linked.length) { + throw new errs.ValidationError( + "No administrator can sign in through a provider yet. Sign in once with an administrator account " + + "through one of them before turning off local sign in.", + ); + } + } + + await settingModel + .query() + .where("id", LOCAL_AUTH_SETTING) + .patch({ value: enabled ? "enabled" : "disabled" }); + + await internalAuditLog.add(access, { + action: "updated", + object_type: "setting", + object_id: 0, + meta: { id: LOCAL_AUTH_SETTING, value: enabled ? "enabled" : "disabled" }, + }); + + return { local_enabled: enabled }; + }, +}; + +export default internalAuthProvider; +export { LOCAL_AUTH_SETTING }; diff --git a/backend/internal/auth.js b/backend/internal/auth.js new file mode 100644 index 0000000000..eb502b7c18 --- /dev/null +++ b/backend/internal/auth.js @@ -0,0 +1,161 @@ +import { OAUTH, SAML } from "../lib/auth/definitions.js"; +import * as oauth from "../lib/auth/oauth.js"; +import { resolveUser } from "../lib/auth/provision.js"; +import * as saml from "../lib/auth/saml.js"; +import { exchangeCodes, loginFlows } from "../lib/auth/state.js"; +import errs from "../lib/error.js"; +import { auth as logger } from "../logger.js"; +import internalAuthProvider from "./auth-provider.js"; +import internalToken from "./token.js"; + +/** + * Works out the externally reachable base URL of this instance, which the IdP + * needs to be able to redirect back to. + * + * @param {Object} req + * @returns {String} + */ +const getBaseUrl = (req) => { + if (process.env.AUTH_PUBLIC_URL) { + return process.env.AUTH_PUBLIC_URL.replace(/\/+$/, ""); + } + return `${req.protocol}://${req.get("host")}`; +}; + +/** + * The redirect/ACS URL registered with the identity provider. + * + * @param {Object} req + * @param {Integer} providerId + * @returns {String} + */ +const getCallbackUrl = (req, providerId) => `${getBaseUrl(req)}/api/auth/${providerId}/callback`; + +const internalAuth = { + getBaseUrl, + getCallbackUrl, + + /** + * Begins a redirect based login, returning the URL to send the browser to. + * + * @param {Object} req + * @param {Integer} providerId + * @returns {Promise} + */ + startLogin: async (req, providerId) => { + const provider = await internalAuth.getEnabledProvider(providerId); + const callbackUrl = getCallbackUrl(req, provider.id); + + if (provider.type === OAUTH) { + const flow = oauth.createFlow(callbackUrl); + const key = loginFlows.put({ providerId: provider.id, ...flow }); + return await oauth.buildAuthorizationUrl(provider, flow, key); + } + + if (provider.type === SAML) { + const key = loginFlows.put({ providerId: provider.id, callbackUrl }); + return await saml.buildAuthorizationRequest(provider, callbackUrl, key); + } + + throw new errs.ValidationError(`Provider "${provider.name}" does not support redirect based sign in`); + }, + + /** + * Handles the IdP's response and returns a single use code that the + * frontend swaps for a real token. + * + * @param {Object} req + * @param {Integer} providerId + * @returns {Promise} + */ + completeLogin: async (req, providerId) => { + const provider = await internalAuth.getEnabledProvider(providerId); + + let identity; + + if (provider.type === OAUTH) { + const params = { ...req.query, ...req.body }; + if (params.error) { + // The provider's own wording is logged but not shown: it arrives + // before anything has been validated, so anyone able to aim a + // browser at this callback could choose the message the login + // page displays. + logger.warn( + `Provider "${provider.name}" rejected a sign in: ${params.error_description || params.error}`, + ); + throw new errs.AuthError("The identity provider rejected the sign in"); + } + + const flow = loginFlows.take(params.state); + if (!flow || flow.providerId !== provider.id) { + throw new errs.AuthError("This sign in request has expired or was not started here"); + } + if (!params.code) { + throw new errs.AuthError("The identity provider did not return an authorization code"); + } + + identity = await oauth.completeAuthorization(provider, flow, params.code); + } else if (provider.type === SAML) { + const body = req.body || {}; + const flow = loginFlows.take(body.RelayState); + if (!flow || flow.providerId !== provider.id) { + throw new errs.AuthError("This sign in request has expired or was not started here"); + } + + identity = await saml.completeAuthorization(provider, flow.callbackUrl, body); + } else { + throw new errs.ValidationError(`Provider "${provider.name}" does not support redirect based sign in`); + } + + const user = await resolveUser(provider, identity); + logger.info(`Authenticated ${user.email} against ${provider.type.toUpperCase()} provider "${provider.name}"`); + + return exchangeCodes.put({ userId: user.id, providerId: provider.id }); + }, + + /** + * Swaps the single use code from a completed SSO login for an access token. + * + * @param {String} code + * @returns {Promise} + */ + exchange: async (code) => { + const entry = exchangeCodes.take(code); + if (!entry) { + throw new errs.AuthError("This sign in code has expired. Please try again."); + } + return await internalToken.getTokenFromUserId(entry.userId); + }, + + /** + * @param {Integer} providerId + * @returns {Promise} + */ + getEnabledProvider: async (providerId) => { + const id = Number.parseInt(providerId, 10); + if (Number.isNaN(id)) { + throw new errs.ItemNotFoundError(providerId); + } + + const provider = await internalAuthProvider.getRaw(id); + if (!provider.is_enabled) { + throw new errs.ItemNotFoundError(providerId); + } + return provider; + }, + + /** + * @param {Object} req + * @param {Integer} providerId + * @returns {Promise} SP metadata XML + */ + getSamlMetadata: async (req, providerId) => { + const provider = await internalAuth.getEnabledProvider(providerId); + if (provider.type !== SAML) { + throw new errs.ItemNotFoundError(providerId); + } + return saml.generateMetadata(provider, getCallbackUrl(req, provider.id)); + }, +}; + +export default internalAuth; diff --git a/backend/internal/token.js b/backend/internal/token.js index 67255b2134..d035379aaf 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -5,54 +5,63 @@ import authModel from "../models/auth.js"; import TokenModel from "../models/token.js"; import userModel from "../models/user.js"; import twoFactor from "./2fa.js"; +import internalAuthProvider from "./auth-provider.js"; const ERROR_MESSAGE_INVALID_AUTH = "Invalid email or password"; const ERROR_MESSAGE_INVALID_AUTH_I18N = "error.invalid-auth"; const ERROR_MESSAGE_INVALID_2FA = "Invalid verification code"; const ERROR_MESSAGE_INVALID_2FA_I18N = "error.invalid-2fa"; -export default { +const internalToken = { /** - * @param {Object} data - * @param {String} data.identity - * @param {String} data.secret - * @param {String} [data.scope] - * @param {String} [data.expiry] - * @param {String} [issuer] - * @returns {Promise} + * Verifies an email address and password against the locally stored + * credentials, ignoring any external authentication providers. + * + * @param {String} email + * @param {String} password + * @returns {Promise} the user, or null when the pair is wrong */ - getTokenFromEmail: async (data, issuer) => { - const Token = TokenModel(); - - data.scope = data.scope || "user"; - data.expiry = data.expiry || "1d"; - + verifyLocalPassword: async (email, password) => { const user = await userModel .query() - .where("email", data.identity.toLowerCase().trim()) + .where("email", email.toLowerCase().trim()) .andWhere("is_deleted", 0) .andWhere("is_disabled", 0) .first(); if (!user) { - throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); + return null; } const auth = await authModel.query().where("user_id", "=", user.id).where("type", "=", "password").first(); - if (!auth) { - throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); + if (!auth?.secret) { + return null; } - const valid = await auth.verifyPassword(data.secret); - if (!valid) { - throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N); - } + const valid = await auth.verifyPassword(password); + return valid ? user : null; + }, + + /** + * Issues an access token for a user that has already been authenticated, + * interrupting with a 2FA challenge when they have one enabled. + * + * @param {Object} user + * @param {String} [scope] + * @param {String} [expiryPeriod] + * @param {String} [issuer] + * @returns {Promise} + */ + issueForUser: async (user, scope, expiryPeriod, issuer) => { + const Token = TokenModel(); + const thisScope = scope || "user"; + const thisExpiry = expiryPeriod || "1d"; - if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) { + if (thisScope !== "user" && _.indexOf(user.roles, thisScope) === -1) { // The scope requested doesn't exist as a role against the user, // you shall not pass. - throw new errs.AuthError(`Invalid scope: ${data.scope}`); + throw new errs.AuthError(`Invalid scope: ${thisScope}`); } // Check if 2FA is enabled @@ -75,9 +84,9 @@ export default { } // Create a moment of the expiry expression - const expiry = parseDatePeriod(data.expiry); + const expiry = parseDatePeriod(thisExpiry); if (expiry === null) { - throw new errs.AuthError(`Invalid expiry time: ${data.expiry}`); + throw new errs.AuthError(`Invalid expiry time: ${thisExpiry}`); } const signed = await Token.create({ @@ -85,8 +94,8 @@ export default { attrs: { id: user.id, }, - scope: [data.scope], - expiresIn: data.expiry, + scope: [thisScope], + expiresIn: thisExpiry, }); return { @@ -95,6 +104,67 @@ export default { }; }, + /** + * Authenticates a set of credentials from the login form. + * + * Local passwords are checked first (when local sign in is enabled) and + * then every configured LDAP provider, so that directory users can use the + * same form as everyone else. + * + * @param {Object} data + * @param {String} data.identity + * @param {String} data.secret + * @param {String} [data.scope] + * @param {String} [data.expiry] + * @param {String} [issuer] + * @returns {Promise} + */ + getTokenFromEmail: async (data, issuer) => { + const scope = data.scope || "user"; + const expiry = data.expiry || "1d"; + + let user = null; + + if (await internalAuthProvider.isLocalAuthEnabled()) { + user = await internalToken.verifyLocalPassword(data.identity, data.secret); + } + + if (!user) { + // LDAP identities are often a username rather than an email address, + // so hand over what was typed rather than the normalised version. + user = await internalAuthProvider.authenticateLdap(data.identity.trim(), data.secret); + } + + if (!user) { + throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N); + } + + return await internalToken.issueForUser(user, scope, expiry, issuer); + }, + + /** + * Issues a token for a user id, used once an external provider has + * vouched for who they are. + * + * @param {Integer} userId + * @param {String} [issuer] + * @returns {Promise} + */ + getTokenFromUserId: async (userId, issuer) => { + const user = await userModel + .query() + .where("id", userId) + .andWhere("is_deleted", 0) + .andWhere("is_disabled", 0) + .first(); + + if (!user) { + throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); + } + + return await internalToken.issueForUser(user, "user", "1d", issuer); + }, + /** * @param {Access} access * @param {Object} [data] @@ -141,7 +211,7 @@ export default { expires: expiry.toISOString(), }; } - throw new error.AssertionFailedError("Existing token contained invalid user data"); + throw new errs.AssertionFailedError("Existing token contained invalid user data"); }, /** @@ -225,3 +295,5 @@ export default { }; }, }; + +export default internalToken; diff --git a/backend/internal/user.js b/backend/internal/user.js index d4080dd78f..5e8b7b35cf 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -3,6 +3,7 @@ import _ from "lodash"; import errs from "../lib/error.js"; import utils from "../lib/utils.js"; import authModel from "../models/auth.js"; +import authProviderModel from "../models/auth_provider.js"; import userModel from "../models/user.js"; import userPermissionModel from "../models/user_permission.js"; import internalAuditLog from "./audit-log.js"; @@ -14,6 +15,61 @@ const omissions = () => { const DEFAULT_AVATAR = gravatar.url("admin@example.com", { default: "mm" }); +/** + * Works out where each of the given users can sign in from, so the Users list + * can show it. A user is "local" when they hold a password, and additionally + * lists every external provider their account is linked to. + * + * Done in two queries for the whole set rather than per user, so listing stays + * a fixed cost no matter how many people there are. + * + * @param {[Object]} users + * @returns {Promise<[Object]>} the same users, with auth_sources attached + */ +const attachAuthSources = async (users) => { + const rows = Array.isArray(users) ? users : [users]; + const ids = rows.map((u) => u?.id).filter(Boolean); + + if (!ids.length) { + return users; + } + + const links = await authModel.query().whereIn("user_id", ids).andWhere("is_deleted", 0); + + const providerIds = [...new Set(links.map((l) => l.provider_id).filter(Boolean))]; + const providers = providerIds.length + ? await authProviderModel.query().whereIn("id", providerIds) + : []; + const providersById = new Map(providers.map((p) => [p.id, p])); + + const byUser = new Map(); + for (const link of links) { + const list = byUser.get(link.user_id) || []; + + if (link.type === "password") { + list.push({ type: "local", provider_id: null, name: null }); + } else { + const provider = providersById.get(link.provider_id); + list.push({ + type: link.type, + provider_id: link.provider_id || null, + // A provider that has since been deleted leaves the link behind + name: provider ? provider.name : null, + }); + } + + byUser.set(link.user_id, list); + } + + rows.forEach((user) => { + if (user) { + user.auth_sources = byUser.get(user.id) || []; + } + }); + + return users; +}; + const internalUser = { /** * Create a user can happen unauthenticated only once and only when no active users exist. @@ -322,7 +378,7 @@ const internalUser = { } const res = await query; - return utils.omitRows(omissions())(res); + return await attachAuthSources(utils.omitRows(omissions())(res)); }, /** @@ -363,19 +419,19 @@ const internalUser = { } if (user.id === access.token.getUserId(0)) { - // they're setting their own password. Make sure their current password is correct + // they're setting their own password. Make sure their current password is correct. + // This deliberately checks the local password only: external providers own + // their own credentials and can't be changed from here. if (typeof data.current === "undefined" || !data.current) { throw new errs.ValidationError("Current password was not supplied"); } - return internalToken - .getTokenFromEmail({ - identity: user.email, - secret: data.current, - }) - .then(() => { - return user; - }); + return internalToken.verifyLocalPassword(user.email, data.current).then((verified) => { + if (!verified) { + throw new errs.AuthError("Invalid email or password", "error.invalid-auth"); + } + return user; + }); } return user; diff --git a/backend/lib/access/auth_providers-create.json b/backend/lib/access/auth_providers-create.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/auth_providers-create.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/auth_providers-delete.json b/backend/lib/access/auth_providers-delete.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/auth_providers-delete.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/auth_providers-get.json b/backend/lib/access/auth_providers-get.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/auth_providers-get.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/auth_providers-list.json b/backend/lib/access/auth_providers-list.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/auth_providers-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/auth_providers-update.json b/backend/lib/access/auth_providers-update.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/auth_providers-update.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/auth/access-verify.js b/backend/lib/auth/access-verify.js new file mode 100644 index 0000000000..0103f929d1 --- /dev/null +++ b/backend/lib/auth/access-verify.js @@ -0,0 +1,177 @@ +/** + * Credential checking for access lists that accept provider accounts. + * + * An access list normally protects a site with a htpasswd file, which only + * works for usernames typed into the list itself: a directory will not hand + * over password hashes, so its users cannot be written into that file. + * + * Instead nginx asks us, per request, whether a set of Basic credentials is + * acceptable. That check has to be cheap, because it sits in front of every + * single request to a protected site — hence the cache below. Without it every + * image on a page would cost an LDAP bind. + */ + +import crypto from "node:crypto"; +import { auth as logger } from "../../logger.js"; +import * as ldap from "./ldap.js"; + +/** Decisions already made, keyed by a hash of the credentials */ +const decisions = new Map(); + +// A positive answer is held long enough to cover a page load and its assets. +// A negative one expires quickly, so fixing somebody's group membership takes +// effect without waiting. +const ALLOW_TTL_MS = 5 * 60 * 1000; +const DENY_TTL_MS = 30 * 1000; +const MAX_ENTRIES = 5000; + +/** + * The cache key never contains the password itself, only a digest of it, so a + * memory dump does not hand over credentials. + * + * @param {Integer} listId + * @param {String} username + * @param {String} password + * @returns {String} + */ +const cacheKey = (listId, username, password) => + crypto.createHash("sha256").update(`${listId}\0${username}\0${password}`).digest("base64"); + +const readCache = (key) => { + const hit = decisions.get(key); + if (!hit) { + return null; + } + if (hit.expires <= Date.now()) { + decisions.delete(key); + return null; + } + return hit.result; +}; + +const writeCache = (key, result) => { + // Cheap bound: drop everything rather than track insertion order + if (decisions.size >= MAX_ENTRIES) { + decisions.clear(); + } + decisions.set(key, { + result, + expires: Date.now() + (result.allowed ? ALLOW_TTL_MS : DENY_TTL_MS), + }); +}; + +/** + * Forgets every cached decision for a list. Called when the list changes, so + * that removing somebody's access takes effect immediately. + * + * @param {Integer} [listId] omit to clear everything + */ +const invalidate = (listId) => { + if (typeof listId === "undefined") { + decisions.clear(); + return; + } + // Keys are digests, so which list they belong to cannot be told apart. + // Clearing the lot is correct and costs only some repeated checks. + decisions.clear(); +}; + +/** + * Whether a provider user's groups satisfy the list's group restriction. + * + * @param {[String]} allowedGroups + * @param {[String]} userGroups + * @returns {Boolean} + */ +const groupsAllow = (allowedGroups, userGroups) => { + if (!allowedGroups?.length) { + return true; + } + + // Unknown membership (null) denies here, which is the opposite of how role + // mapping treats it. Guarding a resource should fail closed; revoking + // somebody's admin role over a failed lookup should not. + const held = (userGroups || []).map((g) => String(g).toLowerCase()); + return allowedGroups.some((group) => held.includes(String(group).trim().toLowerCase())); +}; + +/** + * Checks credentials against the list's own entries first, then each provider + * it accepts. + * + * The list's own entries are plain comparisons against values we already hold, + * so they cost nothing; providers involve a network round trip and are only + * consulted when the local entries do not match. + * + * @param {Object} list access list row, with items expanded + * @param {[Object]} providers the enabled providers this list accepts + * @param {String} username + * @param {String} password + * @returns {Promise} { allowed, via, reason } + */ +const check = async (list, providers, username, password) => { + // An empty password would be an unauthenticated bind at the directory, and + // matches nothing sensible locally either + if (!username || !password) { + return { allowed: false, reason: "no credentials" }; + } + + for (const item of list.items || []) { + if (item.username === username && item.password && item.password === password) { + return { allowed: true, via: "list" }; + } + } + + for (const provider of providers) { + if (provider.type !== "ldap") { + // Only LDAP can verify a password presented to us. SAML and OAuth + // authenticate by redirecting a browser, which a subrequest cannot do. + continue; + } + + let identity = null; + try { + identity = await ldap.authenticate(provider, username, password); + } catch (err) { + logger.error(`Access list ${list.id}: provider "${provider.name}" failed: ${err.message}`); + continue; + } + + if (!identity) { + continue; + } + + if (!groupsAllow(list.allowed_groups, identity.groups)) { + logger.debug(`Access list ${list.id}: ${identity.email} authenticated but is not in an allowed group`); + return { allowed: false, reason: "not in an allowed group" }; + } + + return { allowed: true, via: provider.name, email: identity.email }; + } + + return { allowed: false, reason: "invalid credentials" }; +}; + +/** + * The cached form of {@link check}. + * + * @param {Object} list + * @param {[Object]} providers + * @param {String} username + * @param {String} password + * @returns {Promise} + */ +const verify = async (list, providers, username, password) => { + const key = cacheKey(list.id, username, password); + + const cached = readCache(key); + if (cached) { + return { ...cached, cached: true }; + } + + const result = await check(list, providers, username, password); + writeCache(key, result); + return result; +}; + +export { check, groupsAllow, invalidate, verify }; diff --git a/backend/lib/auth/definitions.js b/backend/lib/auth/definitions.js new file mode 100644 index 0000000000..c27a007d22 --- /dev/null +++ b/backend/lib/auth/definitions.js @@ -0,0 +1,162 @@ +/** + * Definitions for the supported external authentication provider types. + * + * Each type declares the meta fields it understands, which of those fields hold + * secrets (and must never be sent back over the API) and the defaults applied + * when a field is left empty. + */ + +const LDAP = "ldap"; +const SAML = "saml"; +const OAUTH = "oauth"; + +const PROVIDER_TYPES = [LDAP, SAML, OAUTH]; + +/** + * Fields whose values are write-only. They are stripped from every API response + * and, when an update omits them, the previously stored value is kept. + */ +const SECRET_FIELDS = { + [LDAP]: ["bind_password"], + [SAML]: ["sp_private_key"], + [OAUTH]: ["client_secret"], +}; + +const COMMON_DEFAULTS = { + // Create a local user the first time an unknown identity signs in + auto_create_user: false, + // When a user is auto created, give them these roles + default_roles: [], + // Optional: identities in this group/claim value become admins + admin_group: "", + // Adopt an existing local account when its email address matches the one + // the provider supplies. Off by default: any provider that lets somebody + // claim an arbitrary address could otherwise be used to take over an + // account, including an administrator's. + link_by_email: false, +}; + +const DEFAULTS = { + [LDAP]: { + ...COMMON_DEFAULTS, + url: "", + bind_dn: "", + bind_password: "", + base_dn: "", + // {{username}} is replaced with whatever was typed into the login form + user_filter: "(|(uid={{username}})(mail={{username}}))", + email_attribute: "mail", + name_attribute: "cn", + nickname_attribute: "givenName", + group_attribute: "memberOf", + // Optional reverse lookup, for directories that don't expose memberOf. + // {{dn}} and {{username}} are substituted before searching. + group_base_dn: "", + group_filter: "", + group_name_attribute: "dn", + // Comma separated attributes accepted at the login prompt. When set this + // builds the search filter, which is friendlier than writing one by hand; + // user_filter still wins if both are present. + login_attributes: "", + start_tls: false, + tls_reject_unauthorized: true, + timeout: 10000, + // Directories cap how many entries one search may return (1000 in Active + // Directory by default). Paging walks past that limit. + page_size: 500, + + // --- directory sync ------------------------------------------------ + // Walks the directory on a schedule so accounts exist before anyone + // signs in, and so group changes are picked up without a login. + sync_enabled: false, + sync_interval: 60, + // Restricts which entries sync considers; defaults to every person + sync_filter: "(objectClass=person)", + // Only sync members of this group, when set + sync_group: "", + // Disable local accounts whose directory entry has gone away + sync_disable_missing: false, + }, + [SAML]: { + ...COMMON_DEFAULTS, + entry_point: "", + // The SP entity id we advertise to the IdP + issuer: "nginx-proxy-manager", + idp_cert: "", + sp_private_key: "", + signature_algorithm: "sha256", + want_assertions_signed: true, + want_authn_response_signed: false, + email_attribute: "", + name_attribute: "", + nickname_attribute: "", + group_attribute: "", + // Attribute holding a lasting id for the person. Left empty the NameID + // is used, unless the IdP issues a transient one, in which case the + // email address is. + identifier_attribute: "", + }, + [OAUTH]: { + ...COMMON_DEFAULTS, + // When set, endpoints are resolved via OIDC discovery + issuer_url: "", + authorization_url: "", + token_url: "", + userinfo_url: "", + jwks_url: "", + client_id: "", + client_secret: "", + scopes: "openid email profile", + email_claim: "email", + name_claim: "name", + nickname_claim: "preferred_username", + group_claim: "groups", + // Send credentials in the Authorization header rather than the body + use_basic_auth: false, + }, +}; + +/** + * Applies the defaults for a type over the top of a supplied meta object, + * dropping anything the type doesn't know about. + * + * @param {String} type + * @param {Object} [meta] + * @returns {Object} + */ +const normalizeMeta = (type, meta) => { + const defaults = DEFAULTS[type]; + if (!defaults) { + return {}; + } + + const result = {}; + Object.keys(defaults).forEach((key) => { + result[key] = typeof meta?.[key] === "undefined" || meta[key] === null ? defaults[key] : meta[key]; + }); + return result; +}; + +/** + * Removes secret values from a provider's meta so it can be sent to a client. + * Secrets are replaced with a boolean `_set` marker so the UI can show + * whether a value exists without revealing it. + * + * @param {Object} provider + * @returns {Object} + */ +const redactProvider = (provider) => { + if (!provider) { + return provider; + } + + const meta = { ...(provider.meta || {}) }; + (SECRET_FIELDS[provider.type] || []).forEach((field) => { + meta[`${field}_set`] = !!meta[field]; + delete meta[field]; + }); + + return { ...provider, meta }; +}; + +export { DEFAULTS, LDAP, normalizeMeta, OAUTH, PROVIDER_TYPES, redactProvider, SAML, SECRET_FIELDS }; diff --git a/backend/lib/auth/env.js b/backend/lib/auth/env.js new file mode 100644 index 0000000000..f7d6a13c82 --- /dev/null +++ b/backend/lib/auth/env.js @@ -0,0 +1,196 @@ +import { auth as logger } from "../../logger.js"; +import authProviderModel from "../../models/auth_provider.js"; +import { LDAP, normalizeMeta, OAUTH, PROVIDER_TYPES, SAML } from "./definitions.js"; +import { ensureAWayBackIn, localAuthDisabledByEnv } from "./local-auth.js"; +import { detachProviderUsers } from "./provision.js"; + +const toBool = (value, fallback) => { + if (typeof value === "undefined" || value === null || value === "") { + return fallback; + } + return /^(1|true|yes|on)$/i.test(String(value).trim()); +}; + +const toInt = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +}; + +const toList = (value) => + String(value || "") + .split(",") + .map((v) => v.trim()) + .filter((v) => v !== ""); + +/** + * Secrets can also be supplied as docker secrets: the container's startup + * scripts expand any `__FILE` variable into `` before we run, so + * there is nothing extra to do here. + * + * @param {String} name + * @returns {String|undefined} + */ +const env = (name) => process.env[name]; + +/** + * Builds the meta object for one provider type from environment variables. + * + * @param {String} type + * @returns {Object} + */ +const buildMeta = (type) => { + const common = { + auto_create_user: toBool(env(`AUTH_${type.toUpperCase()}_AUTO_CREATE_USER`), false), + default_roles: toList(env(`AUTH_${type.toUpperCase()}_DEFAULT_ROLES`)), + admin_group: env(`AUTH_${type.toUpperCase()}_ADMIN_GROUP`) || "", + link_by_email: toBool(env(`AUTH_${type.toUpperCase()}_LINK_BY_EMAIL`), false), + }; + + switch (type) { + case LDAP: + return normalizeMeta(LDAP, { + ...common, + url: env("AUTH_LDAP_URL"), + bind_dn: env("AUTH_LDAP_BIND_DN"), + bind_password: env("AUTH_LDAP_BIND_PASSWORD"), + base_dn: env("AUTH_LDAP_BASE_DN"), + user_filter: env("AUTH_LDAP_USER_FILTER"), + email_attribute: env("AUTH_LDAP_EMAIL_ATTRIBUTE"), + name_attribute: env("AUTH_LDAP_NAME_ATTRIBUTE"), + nickname_attribute: env("AUTH_LDAP_NICKNAME_ATTRIBUTE"), + group_attribute: env("AUTH_LDAP_GROUP_ATTRIBUTE"), + group_base_dn: env("AUTH_LDAP_GROUP_BASE_DN"), + group_filter: env("AUTH_LDAP_GROUP_FILTER"), + group_name_attribute: env("AUTH_LDAP_GROUP_NAME_ATTRIBUTE"), + login_attributes: env("AUTH_LDAP_LOGIN_ATTRIBUTES"), + start_tls: toBool(env("AUTH_LDAP_START_TLS"), false), + tls_reject_unauthorized: toBool(env("AUTH_LDAP_TLS_REJECT_UNAUTHORIZED"), true), + timeout: toInt(env("AUTH_LDAP_TIMEOUT"), 10000), + page_size: toInt(env("AUTH_LDAP_PAGE_SIZE"), 500), + sync_enabled: toBool(env("AUTH_LDAP_SYNC_ENABLED"), false), + sync_interval: toInt(env("AUTH_LDAP_SYNC_INTERVAL"), 60), + sync_filter: env("AUTH_LDAP_SYNC_FILTER"), + sync_group: env("AUTH_LDAP_SYNC_GROUP"), + sync_disable_missing: toBool(env("AUTH_LDAP_SYNC_DISABLE_MISSING"), false), + }); + + case SAML: + return normalizeMeta(SAML, { + ...common, + entry_point: env("AUTH_SAML_ENTRY_POINT"), + issuer: env("AUTH_SAML_ISSUER"), + idp_cert: env("AUTH_SAML_IDP_CERT"), + sp_private_key: env("AUTH_SAML_SP_PRIVATE_KEY"), + signature_algorithm: env("AUTH_SAML_SIGNATURE_ALGORITHM"), + want_assertions_signed: toBool(env("AUTH_SAML_WANT_ASSERTIONS_SIGNED"), true), + want_authn_response_signed: toBool(env("AUTH_SAML_WANT_AUTHN_RESPONSE_SIGNED"), false), + email_attribute: env("AUTH_SAML_EMAIL_ATTRIBUTE"), + name_attribute: env("AUTH_SAML_NAME_ATTRIBUTE"), + nickname_attribute: env("AUTH_SAML_NICKNAME_ATTRIBUTE"), + group_attribute: env("AUTH_SAML_GROUP_ATTRIBUTE"), + identifier_attribute: env("AUTH_SAML_IDENTIFIER_ATTRIBUTE"), + }); + + case OAUTH: + return normalizeMeta(OAUTH, { + ...common, + issuer_url: env("AUTH_OAUTH_ISSUER_URL"), + authorization_url: env("AUTH_OAUTH_AUTHORIZATION_URL"), + token_url: env("AUTH_OAUTH_TOKEN_URL"), + userinfo_url: env("AUTH_OAUTH_USERINFO_URL"), + jwks_url: env("AUTH_OAUTH_JWKS_URL"), + client_id: env("AUTH_OAUTH_CLIENT_ID"), + client_secret: env("AUTH_OAUTH_CLIENT_SECRET"), + scopes: env("AUTH_OAUTH_SCOPES"), + email_claim: env("AUTH_OAUTH_EMAIL_CLAIM"), + name_claim: env("AUTH_OAUTH_NAME_CLAIM"), + nickname_claim: env("AUTH_OAUTH_NICKNAME_CLAIM"), + group_claim: env("AUTH_OAUTH_GROUP_CLAIM"), + use_basic_auth: toBool(env("AUTH_OAUTH_USE_BASIC_AUTH"), false), + }); + + default: + return {}; + } +}; + +const DEFAULT_NAMES = { + [LDAP]: "LDAP", + [SAML]: "SAML", + [OAUTH]: "OAuth", +}; + +/** + * Returns the provider definitions described by the environment. + * + * At most one provider of each type can be configured this way; anything more + * elaborate belongs in the UI. + * + * @returns {[Object]} + */ +const getEnvProviders = () => + PROVIDER_TYPES.filter((type) => toBool(env(`AUTH_${type.toUpperCase()}_ENABLED`), false)).map((type, idx) => ({ + slug: `env-${type}`, + type, + name: env(`AUTH_${type.toUpperCase()}_NAME`) || DEFAULT_NAMES[type], + is_enabled: true, + is_env_managed: true, + is_deleted: false, + sort_order: idx, + meta: buildMeta(type), + })); + +/** + * Reconciles the environment configured providers with the database. + * + * Rows are owned by the environment: they're recreated from scratch on every + * boot, and removed when their variables go away. Providers created in the UI + * are never touched. + * + * @returns {Promise} + */ +const syncEnvProviders = async () => { + const wanted = getEnvProviders(); + const wantedSlugs = wanted.map((p) => p.slug); + + const existing = await authProviderModel.query().where("is_env_managed", 1); + + // Drop rows whose environment variables have been removed. Their accounts + // are converted to local rather than deleted: nobody confirmed anything + // here, so a variable disappearing from a compose file must not silently + // take people's accounts with it. They keep their hosts and permissions, + // and an administrator can set them a password from the Users screen. + const stale = existing.filter((row) => !wantedSlugs.includes(row.slug) && !row.is_deleted); + for (const row of stale) { + const users = await detachProviderUsers(row, "convert"); + await authProviderModel.query().where("id", row.id).patch({ is_deleted: true, is_enabled: false }); + logger.info( + `Removed environment configured auth provider: ${row.slug}` + + (users.converted ? `, ${users.converted} account(s) converted to local` : ""), + ); + } + + for (const provider of wanted) { + const row = existing.find((r) => r.slug === provider.slug); + if (row) { + await authProviderModel.query().where("id", row.id).patch(provider); + logger.info(`Updated environment configured auth provider: ${provider.slug} (${provider.type})`); + } else { + await authProviderModel.query().insert(provider); + logger.info(`Added environment configured auth provider: ${provider.slug} (${provider.type})`); + } + } + + if (stale.length) { + // Checked once the wanted providers are in place, so swapping one for + // another doesn't briefly look like having none. Variables disappearing + // from a compose file must not leave an instance nobody can sign in to. + await ensureAWayBackIn(); + } + + return wanted.length; +}; + +// Re-exported so callers reading environment configured auth have one place to +// look; the implementation lives with the setting it overrides. +export { getEnvProviders, localAuthDisabledByEnv, syncEnvProviders }; diff --git a/backend/lib/auth/guid.js b/backend/lib/auth/guid.js new file mode 100644 index 0000000000..db263f751b --- /dev/null +++ b/backend/lib/auth/guid.js @@ -0,0 +1,110 @@ +/** + * Stable directory identifiers. + * + * A distinguished name is not a durable key: renaming someone, or moving them + * between organisational units, changes it. Directories therefore publish an + * immutable identifier alongside it, and that is what a local account should be + * tied to: + * + * - Active Directory `objectGUID`, 16 raw bytes in a mixed-endian layout + * - OpenLDAP / 389-ds `entryUUID`, already an RFC 4122 string + * + * Both are normalised here to the same lowercase hyphenated form. + */ + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** + * Converts an Active Directory objectGUID to its canonical string form. + * + * The first three groups are stored little-endian and the last two big-endian, + * which is why they cannot simply be hex encoded in order. + * + * @param {Buffer|String} raw + * @returns {String} lowercase hyphenated GUID + */ +const parseObjectGuid = (raw) => { + const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), "binary"); + + if (buf.length !== 16) { + throw new Error(`objectGUID must be exactly 16 bytes, got ${buf.length}`); + } + + const hex = (...bytes) => Buffer.from(bytes).toString("hex"); + + return [ + hex(buf[3], buf[2], buf[1], buf[0]), + hex(buf[5], buf[4]), + hex(buf[7], buf[6]), + hex(buf[8], buf[9]), + hex(buf[10], buf[11], buf[12], buf[13], buf[14], buf[15]), + ].join("-"); +}; + +/** + * Turns a canonical GUID back into the byte-escaped form an Active Directory + * search filter needs, e.g. `\d3\d1\9a\5c...`. + * + * @param {String} guid + * @returns {String} + */ +const guidToLdapFilter = (guid) => { + const hex = String(guid).replace(/-/g, "").toLowerCase(); + + if (!/^[0-9a-f]{32}$/.test(hex)) { + throw new Error(`Invalid GUID: "${guid}"`); + } + + const at = (i) => hex.slice(i * 2, i * 2 + 2); + + // Undo the endian swap performed when the GUID was parsed + const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15]; + return order.map((i) => `\\${at(i)}`).join(""); +}; + +/** + * @param {String} value + * @returns {String|null} + */ +const normalizeEntryUuid = (value) => { + const normalized = String(value).trim().toLowerCase(); + return UUID_PATTERN.test(normalized) ? normalized : null; +}; + +/** + * Reads whichever stable identifier a search entry happens to carry. + * + * Returns null when the directory publishes neither, in which case the caller + * has to fall back to matching on the distinguished name. + * + * @param {Object} entry an ldapts search entry + * @returns {Object|null} { guid, source } or null + */ +const extractDirectoryGuid = (entry) => { + if (!entry) { + return null; + } + + const objectGuid = entry.objectGUID ?? entry.objectguid; + if (objectGuid) { + const raw = Array.isArray(objectGuid) ? objectGuid[0] : objectGuid; + try { + return { guid: parseObjectGuid(raw), source: "objectGUID" }; + } catch (_) { + // Fall through to entryUUID rather than failing the whole login + } + } + + const entryUuid = entry.entryUUID ?? entry.entryuuid; + if (entryUuid) { + const raw = Array.isArray(entryUuid) ? entryUuid[0] : entryUuid; + const normalized = normalizeEntryUuid(Buffer.isBuffer(raw) ? raw.toString("utf8") : raw); + if (normalized) { + return { guid: normalized, source: "entryUUID" }; + } + } + + return null; +}; + +export { extractDirectoryGuid, guidToLdapFilter, normalizeEntryUuid, parseObjectGuid, UUID_PATTERN }; diff --git a/backend/lib/auth/ldap.js b/backend/lib/auth/ldap.js new file mode 100644 index 0000000000..88993e2a02 --- /dev/null +++ b/backend/lib/auth/ldap.js @@ -0,0 +1,473 @@ +import { Client } from "ldapts"; +import { auth as logger } from "../../logger.js"; +import errs from "../error.js"; +import { extractDirectoryGuid } from "./guid.js"; + +/** + * Escapes a value for safe use inside an LDAP search filter. + * + * @see https://datatracker.ietf.org/doc/html/rfc4515#section-3 + * @param {String} value + * @returns {String} + */ +const escapeFilterValue = (value) => + String(value).replace(/[\\*()\0]/g, (char) => { + switch (char) { + case "\\": + return "\\5c"; + case "*": + return "\\2a"; + case "(": + return "\\28"; + case ")": + return "\\29"; + default: + return "\\00"; + } + }); + +/** + * Attributes that must come back as raw bytes. objectGUID is binary and would + * be mangled if ldapts decoded it as UTF-8. + */ +const BINARY_ATTRIBUTES = ["objectGUID", "objectSid"]; + +/** + * Reads an attribute off a search entry, always returning a flat array of + * strings. ldapts hands back strings, arrays or Buffers depending on the value. + * + * @param {Object} entry + * @param {String} attribute + * @returns {[String]} + */ +const attributeValues = (entry, attribute) => { + if (!attribute || typeof entry[attribute] === "undefined" || entry[attribute] === null) { + return []; + } + const raw = Array.isArray(entry[attribute]) ? entry[attribute] : [entry[attribute]]; + return raw + .map((value) => (Buffer.isBuffer(value) ? value.toString("utf8") : String(value))) + .filter((v) => v !== ""); +}; + +const firstAttributeValue = (entry, attribute) => attributeValues(entry, attribute)[0] || null; + +/** + * Turns a driver error into something an administrator can act on. + * + * ldapts reports protocol failures as a bare result code, so "Code: 0x31" is + * all you get for a wrong bind password unless it is translated. + * + * @see https://datatracker.ietf.org/doc/html/rfc4511#appendix-A + * @param {Error} err + * @returns {String} + */ +const describeLdapError = (err) => { + if (!err) { + return "Unknown LDAP error"; + } + + // Connection level problems never reach a result code + switch (err.code) { + case "ECONNREFUSED": + return "Connection refused — check the server URL and port"; + case "ENOTFOUND": + case "EAI_AGAIN": + return "Server not found — check the host name"; + case "ETIMEDOUT": + return "Connection timed out — check the server URL, port and any firewall"; + case "DEPTH_ZERO_SELF_SIGNED_CERT": + case "SELF_SIGNED_CERT_IN_CHAIN": + case "UNABLE_TO_VERIFY_LEAF_SIGNATURE": + return "The server's TLS certificate could not be verified. Use a trusted certificate, or turn off certificate verification if you trust this server."; + default: + break; + } + + // ldapts exposes the protocol result as a numeric code + const RESULTS = { + 1: "The server reported an internal error", + 7: "Authentication method not supported by the server", + 8: "The server requires a stronger connection — try LDAPS or StartTLS", + 32: "No such object — check the base DN", + 34: "Malformed DN", + 48: "The server refused to authenticate — inappropriate authentication", + 49: "Invalid credentials — check the bind DN and password", + 50: "The bind account does not have permission for this operation", + 51: "The server is busy — try again shortly", + 52: "The server is unavailable", + 53: "The server was unwilling to perform this operation", + }; + + if (typeof err.code === "number" && RESULTS[err.code]) { + return RESULTS[err.code]; + } + + const message = String(err.message || "").trim(); + return message || `LDAP error (code ${err.code})`; +}; + +const createClient = (meta) => { + if (!meta.url) { + throw new errs.ConfigurationError("LDAP provider has no server URL configured"); + } + + const options = { + url: meta.url, + timeout: meta.timeout || 10000, + connectTimeout: meta.timeout || 10000, + }; + + // Supplying tlsOptions makes ldapts open a TLS socket, which a plain + // ldap:// server will hang up on. Only set them when TLS is actually in play. + if (/^ldaps:/i.test(meta.url) || meta.start_tls) { + options.tlsOptions = { + rejectUnauthorized: meta.tls_reject_unauthorized !== false, + }; + } + + return new Client(options); +}; + +/** + * Opens a connection bound as the service account (or anonymously). + * + * @param {Object} meta + * @returns {Promise} + */ +const connect = async (meta) => { + const client = createClient(meta); + try { + if (meta.start_tls) { + await client.startTLS({ rejectUnauthorized: meta.tls_reject_unauthorized !== false }); + } + if (meta.bind_dn) { + await client.bind(meta.bind_dn, meta.bind_password || ""); + } + } catch (err) { + await client.unbind().catch(() => {}); + throw new errs.AuthError(describeLdapError(err)); + } + return client; +}; + +/** + * The attributes every lookup needs. objectGUID and entryUUID are the stable + * identifiers a local account is tied to; the rest populate the user record. + * + * @param {Object} meta + * @returns {[String]} + */ +const wantedAttributes = (meta) => + [ + "dn", + "objectGUID", + "entryUUID", + meta.email_attribute || "mail", + meta.name_attribute || "cn", + meta.nickname_attribute, + meta.group_attribute, + ].filter(Boolean); + +/** + * Builds the filter that locates the person signing in. + * + * A hand written `user_filter` always wins. Otherwise `login_attributes` is + * turned into an OR across those attributes, which covers the common case of + * "let them type their username, their email, or their sAMAccountName". + * + * @param {Object} meta + * @param {String} username + * @returns {String} + */ +const buildUserFilter = (meta, username) => { + const escaped = escapeFilterValue(username); + + if (meta.user_filter) { + return meta.user_filter.replace(/\{\{username\}\}/g, escaped); + } + + const attributes = String(meta.login_attributes || "") + .split(",") + .map((a) => a.trim()) + .filter(Boolean); + + if (!attributes.length) { + return `(uid=${escaped})`; + } + if (attributes.length === 1) { + return `(${attributes[0]}=${escaped})`; + } + return `(|${attributes.map((a) => `(${a}=${escaped})`).join("")})`; +}; + +/** + * Runs a search, transparently paging when the directory caps result sizes. + * + * @param {Client} client + * @param {String} base + * @param {Object} options + * @param {Number} [pageSize] + * @returns {Promise<[Object]>} + */ +const search = async (client, base, options, pageSize) => { + const searchOptions = { + scope: "sub", + explicitBufferAttributes: BINARY_ATTRIBUTES, + ...options, + }; + + // Paging is pointless when we only ever want one or two entries, and some + // servers reject the control alongside a small size limit. + if (pageSize && pageSize > 0 && !searchOptions.sizeLimit) { + searchOptions.paged = { pageSize }; + } + + const { searchEntries } = await client.search(base, searchOptions); + return searchEntries; +}; + +/** + * Turns a directory entry into the identity shape the rest of auth works with. + * + * @param {Client} client + * @param {Object} meta + * @param {Object} entry + * @param {String} [username] + * @returns {Promise} + */ +const entryToIdentity = async (client, meta, entry, username) => { + const dn = entry.dn; + const email = firstAttributeValue(entry, meta.email_attribute || "mail"); + + if (!email) { + throw new errs.AuthError( + `LDAP entry ${dn} has no "${meta.email_attribute || "mail"}" attribute, which is required`, + ); + } + + const directory = extractDirectoryGuid(entry); + + return { + // Prefer the directory's immutable id: a DN changes when somebody is + // renamed or moved between organisational units. + identifier: directory ? directory.guid : dn, + identifier_source: directory ? directory.source : "dn", + dn, + email, + name: firstAttributeValue(entry, meta.name_attribute) || email, + nickname: firstAttributeValue(entry, meta.nickname_attribute) || null, + groups: await resolveGroups(client, meta, entry, dn, username || email), + }; +}; + +/** + * Collects the groups a user belongs to. + * + * Directories with the memberOf overlay (and Active Directory) put the groups + * straight on the user entry. Plain OpenLDAP instead stores membership on the + * group, so when a group filter is configured we search the other way around. + * + * @param {Client} client + * @param {Object} meta + * @param {Object} entry the user's search entry + * @param {String} userDn + * @param {String} username + * @returns {Promise<[String]|null>} null when membership could not be read at all + */ +const resolveGroups = async (client, meta, entry, userDn, username) => { + const fromEntry = attributeValues(entry, meta.group_attribute); + if (fromEntry.length || !meta.group_filter) { + return fromEntry; + } + + const filter = meta.group_filter + .replace(/\{\{dn\}\}/g, escapeFilterValue(userDn)) + .replace(/\{\{username\}\}/g, escapeFilterValue(username)); + + const nameAttribute = meta.group_name_attribute || "dn"; + + try { + const entries = await search( + client, + meta.group_base_dn || meta.base_dn || "", + { filter, attributes: nameAttribute === "dn" ? ["dn"] : ["dn", nameAttribute] }, + meta.page_size, + ); + + return entries + .map((group) => (nameAttribute === "dn" ? group.dn : firstAttributeValue(group, nameAttribute))) + .filter((name) => !!name); + } catch (err) { + // Group membership only affects role mapping, so a failure here should + // not stop an otherwise valid login. Say we don't know rather than that + // there are none: the caller leaves roles alone when membership is + // unknown, where an empty list would revoke them. + logger.warn(`LDAP group search failed for ${userDn}: ${err.message}`); + return null; + } +}; + +/** + * Authenticates a username/password pair against an LDAP directory. + * + * The directory is searched using the (optional) service account first, then we + * bind as the located user's DN to verify the password. Binding as the user is + * the only way to check a password without being able to read it. + * + * @param {Object} provider + * @param {String} username Whatever was typed into the login form + * @param {String} password + * @returns {Promise} The external identity, or null if invalid + */ +const authenticate = async (provider, username, password) => { + const meta = provider.meta || {}; + + // An empty password would be an unauthenticated bind, which LDAP servers + // happily accept and which would let anyone in as any user. + if (!password) { + return null; + } + + const client = await connect(meta); + + try { + const entries = await search(client, meta.base_dn || "", { + filter: buildUserFilter(meta, username), + sizeLimit: 2, + attributes: wantedAttributes(meta), + }); + + if (entries.length !== 1) { + logger.debug(`LDAP search for "${username}" on provider ${provider.id} returned ${entries.length} entries`); + return null; + } + + const entry = entries[0]; + + // Prove the password by binding as the user themselves. This uses a + // separate connection so that `client` stays bound as the service + // account, which is usually the only identity allowed to read groups. + const userClient = createClient(meta); + try { + if (meta.start_tls) { + await userClient.startTLS({ rejectUnauthorized: meta.tls_reject_unauthorized !== false }); + } + await userClient.bind(entry.dn, password); + } catch (_) { + return null; + } finally { + await userClient.unbind().catch(() => {}); + } + + return await entryToIdentity(client, meta, entry, username); + } finally { + await client.unbind().catch(() => { + // Nothing useful to do if the socket is already gone + }); + } +}; + +/** + * Streams every directory entry the provider's sync settings select. + * + * Entries are handed to the callback a page at a time so that a large + * directory never has to be held in memory all at once. + * + * @param {Object} provider + * @param {Function} onIdentity called with each identity + * @returns {Promise} counts + */ +const listDirectory = async (provider, onIdentity) => { + const meta = provider.meta || {}; + const client = await connect(meta); + + let seen = 0; + let skipped = 0; + + try { + let filter = meta.sync_filter || "(objectClass=person)"; + + // Restrict to one group's members when asked to + if (meta.sync_group) { + filter = `(&${filter}(${meta.group_attribute || "memberOf"}=${escapeFilterValue(meta.sync_group)}))`; + } + + const entries = await search( + client, + meta.base_dn || "", + { filter, attributes: wantedAttributes(meta) }, + meta.page_size || 500, + ); + + for (const entry of entries) { + seen++; + try { + const identity = await entryToIdentity(client, meta, entry); + await onIdentity(identity); + } catch (err) { + // One unusable entry (usually no email address) must not abort + // the whole run + skipped++; + logger.debug(`Skipping LDAP entry ${entry.dn}: ${err.message}`); + } + } + } finally { + await client.unbind().catch(() => {}); + } + + return { seen, skipped }; +}; + +/** + * Verifies that a provider's settings can actually reach the directory. + * + * @param {Object} provider + * @returns {Promise} + */ +const test = async (provider) => { + const meta = provider.meta || {}; + const client = await connect(meta); + + try { + await search(client, meta.base_dn || "", { + scope: "base", + filter: "(objectClass=*)", + sizeLimit: 1, + }); + return { reachable: true }; + } catch (err) { + throw new errs.AuthError(describeLdapError(err)); + } finally { + await client.unbind().catch(() => {}); + } +}; + +/** + * Runs a real credential check without issuing a token, so an administrator + * can confirm a provider works before turning it on. + * + * @param {Object} provider + * @param {String} username + * @param {String} password + * @returns {Promise} + */ +const testAuthentication = async (provider, username, password) => { + const identity = await authenticate(provider, username, password); + + if (!identity) { + return { valid: false }; + } + + return { + valid: true, + dn: identity.dn, + email: identity.email, + name: identity.name, + identifier_source: identity.identifier_source, + groups: identity.groups ?? [], + groups_unavailable: identity.groups === null, + }; +}; + +export { authenticate, buildUserFilter, describeLdapError, escapeFilterValue, listDirectory, test, testAuthentication }; diff --git a/backend/lib/auth/local-auth.js b/backend/lib/auth/local-auth.js new file mode 100644 index 0000000000..f31fa00a2d --- /dev/null +++ b/backend/lib/auth/local-auth.js @@ -0,0 +1,79 @@ +/** + * Whether email/password sign in is available at all. + * + * This lives on its own rather than in `internal/auth-provider.js` because + * provisioning needs to know the answer: an administrator whose only + * credential is a password is no fallback when local sign in is switched off, + * and the lockout guards have to account for that. + */ + +import { auth as logger } from "../../logger.js"; +import authProviderModel from "../../models/auth_provider.js"; +import settingModel from "../../models/setting.js"; + +const LOCAL_AUTH_SETTING = "auth-local"; + +/** + * Whether local sign in has been switched off by environment. When unset, the + * stored setting decides. + * + * @returns {Boolean|null} + */ +const localAuthDisabledByEnv = () => { + const value = process.env.AUTH_DISABLE_LOCAL; + if (typeof value === "undefined" || value === "") { + return null; + } + return /^(1|true|yes|on)$/i.test(String(value).trim()); +}; + +/** + * @returns {Promise} + */ +const isLocalAuthEnabled = async () => { + const fromEnv = localAuthDisabledByEnv(); + if (fromEnv !== null) { + return !fromEnv; + } + + const row = await settingModel.query().where("id", LOCAL_AUTH_SETTING).first(); + // Missing row means the migration hasn't been seen yet; fail open so + // nobody gets locked out of their own instance. + return row?.value !== "disabled"; +}; + +/** + * Puts the password form back when it is the only thing left. + * + * Local sign in can only be switched off while a provider is available to take + * its place. Once the last one is removed that is no longer true, and an + * instance with neither is one nobody can sign in to at all. Called after a + * provider goes away, whether from the UI or because its variables did. + * + * @returns {Promise} whether local sign in was turned back on + */ +const ensureAWayBackIn = async () => { + if (await isLocalAuthEnabled()) { + return false; + } + + const remaining = await authProviderModel.query().where("is_enabled", 1).andWhere("is_deleted", 0); + if (remaining.length) { + return false; + } + + if (localAuthDisabledByEnv() !== null) { + // The environment wins, so all we can do is say what has happened + logger.error( + "The last authentication provider is gone and AUTH_DISABLE_LOCAL is set, so nobody can sign in. " + + "Unset AUTH_DISABLE_LOCAL and restart.", + ); + return false; + } + + await settingModel.query().where("id", LOCAL_AUTH_SETTING).patch({ value: "enabled" }); + logger.warn("Local sign in was turned back on: the last authentication provider has been removed"); + return true; +}; + +export { ensureAWayBackIn, isLocalAuthEnabled, LOCAL_AUTH_SETTING, localAuthDisabledByEnv }; diff --git a/backend/lib/auth/oauth.js b/backend/lib/auth/oauth.js new file mode 100644 index 0000000000..bb694023e1 --- /dev/null +++ b/backend/lib/auth/oauth.js @@ -0,0 +1,295 @@ +import crypto from "node:crypto"; +import jwt from "jsonwebtoken"; +import { auth as logger } from "../../logger.js"; +import errs from "../error.js"; + +const DISCOVERY_TTL_MS = 5 * 60 * 1000; +const discoveryCache = new Map(); +const jwksCache = new Map(); + +const fetchJson = async (url, options) => { + const response = await fetch(url, options); + const text = await response.text(); + + let payload; + try { + payload = JSON.parse(text); + } catch (_) { + throw new errs.AuthError(`Unexpected non-JSON response from ${url} (HTTP ${response.status})`); + } + + if (!response.ok) { + const detail = payload.error_description || payload.error || `HTTP ${response.status}`; + throw new errs.AuthError(`Request to ${url} failed: ${detail}`); + } + return payload; +}; + +const cached = async (cache, key, loader) => { + const hit = cache.get(key); + if (hit && hit.expires > Date.now()) { + return hit.value; + } + const value = await loader(); + cache.set(key, { value, expires: Date.now() + DISCOVERY_TTL_MS }); + return value; +}; + +/** + * Resolves the endpoints for a provider, either from OIDC discovery or from + * the manually configured URLs. Manual values always win, so a provider can use + * discovery but override a single endpoint. + * + * @param {Object} provider + * @returns {Promise} + */ +const getEndpoints = async (provider) => { + const meta = provider.meta || {}; + let discovered = {}; + + if (meta.issuer_url) { + const url = `${meta.issuer_url.replace(/\/+$/, "")}/.well-known/openid-configuration`; + discovered = await cached(discoveryCache, url, () => { + logger.debug(`Fetching OIDC discovery document: ${url}`); + return fetchJson(url); + }); + } + + const endpoints = { + issuer: discovered.issuer || meta.issuer_url || null, + authorization_url: meta.authorization_url || discovered.authorization_endpoint || null, + token_url: meta.token_url || discovered.token_endpoint || null, + userinfo_url: meta.userinfo_url || discovered.userinfo_endpoint || null, + jwks_url: meta.jwks_url || discovered.jwks_uri || null, + }; + + if (!endpoints.authorization_url || !endpoints.token_url) { + throw new errs.ConfigurationError( + "OAuth provider is missing an authorization or token endpoint. Set an issuer URL for discovery, or configure the endpoints manually.", + ); + } + + return endpoints; +}; + +/** + * Creates the per-login values that must be remembered until the IdP redirects + * the browser back to us. + * + * @param {String} redirectUri + * @returns {Object} + */ +const createFlow = (redirectUri) => ({ + nonce: crypto.randomBytes(32).toString("base64url"), + codeVerifier: crypto.randomBytes(64).toString("base64url"), + redirectUri, +}); + +/** + * Builds the URL the browser is sent to. + * + * The `state` is supplied by the caller: it's the single use key under which + * the flow is stored, so a response can only be accepted for a request we + * actually made, and only once. + * + * @param {Object} provider + * @param {Object} flow + * @param {String} state + * @returns {Promise} + */ +const buildAuthorizationUrl = async (provider, flow, state) => { + const meta = provider.meta || {}; + if (!meta.client_id) { + throw new errs.ConfigurationError("OAuth provider has no client ID configured"); + } + + const endpoints = await getEndpoints(provider); + const codeChallenge = crypto.createHash("sha256").update(flow.codeVerifier).digest("base64url"); + + const params = new URLSearchParams({ + response_type: "code", + client_id: meta.client_id, + redirect_uri: flow.redirectUri, + scope: meta.scopes || "openid email profile", + state, + nonce: flow.nonce, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + + const separator = endpoints.authorization_url.includes("?") ? "&" : "?"; + return `${endpoints.authorization_url}${separator}${params.toString()}`; +}; + +/** + * Verifies an ID token's signature against the provider's JWKS. + * + * @param {Object} provider + * @param {Object} endpoints + * @param {String} idToken + * @param {String} nonce + * @returns {Promise} the verified claims + */ +const verifyIdToken = async (provider, endpoints, idToken, nonce) => { + const meta = provider.meta || {}; + const decoded = jwt.decode(idToken, { complete: true }); + + if (!decoded) { + throw new errs.AuthError("The identity provider returned a malformed ID token"); + } + + if (!endpoints.jwks_url) { + throw new errs.ConfigurationError( + "Cannot verify the ID token because no JWKS URL is configured or discoverable. Configure a userinfo URL instead.", + ); + } + + const jwks = await cached(jwksCache, endpoints.jwks_url, () => fetchJson(endpoints.jwks_url)); + const key = (jwks.keys || []).find((k) => !decoded.header.kid || k.kid === decoded.header.kid); + if (!key) { + // The IdP may have rotated its keys since we cached them + jwksCache.delete(endpoints.jwks_url); + throw new errs.AuthError("No matching signing key was found for the ID token"); + } + + const publicKey = crypto.createPublicKey({ key, format: "jwk" }); + const verifyOptions = { + algorithms: [decoded.header.alg], + audience: meta.client_id, + }; + if (endpoints.issuer) { + verifyOptions.issuer = endpoints.issuer; + } + + const claims = jwt.verify(idToken, publicKey, verifyOptions); + + if (claims.nonce && nonce && claims.nonce !== nonce) { + throw new errs.AuthError("The ID token nonce did not match the login request"); + } + + return claims; +}; + +/** + * Exchanges an authorization code for the signed in user's identity. + * + * @param {Object} provider + * @param {Object} flow The values stored when the request was built + * @param {String} code + * @returns {Promise} + */ +const completeAuthorization = async (provider, flow, code) => { + const meta = provider.meta || {}; + const endpoints = await getEndpoints(provider); + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: flow.redirectUri, + code_verifier: flow.codeVerifier, + }); + + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + + if (meta.use_basic_auth) { + const basic = Buffer.from(`${meta.client_id}:${meta.client_secret || ""}`).toString("base64"); + headers.Authorization = `Basic ${basic}`; + } else { + body.set("client_id", meta.client_id); + if (meta.client_secret) { + body.set("client_secret", meta.client_secret); + } + } + + const tokens = await fetchJson(endpoints.token_url, { method: "POST", headers, body: body.toString() }); + + // Claims are routinely split between the two sources: some providers only + // put group memberships in the ID token, others only return a subject from + // userinfo. Collect both and merge them. + // + // The ID token is only trusted when its signature can actually be checked, + // which requires a JWKS endpoint. + let idClaims = null; + if (tokens.id_token && endpoints.jwks_url) { + idClaims = await verifyIdToken(provider, endpoints, tokens.id_token, flow.nonce); + } + + let userClaims = null; + if (endpoints.userinfo_url && tokens.access_token) { + userClaims = await fetchJson(endpoints.userinfo_url, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + }, + }); + } + + if (!idClaims && !userClaims) { + throw new errs.AuthError( + "The identity provider returned neither a verifiable ID token nor a usable userinfo endpoint", + ); + } + + // A userinfo response for a different subject would mean the access token + // and the ID token describe different people. + if (idClaims?.sub && userClaims?.sub && idClaims.sub !== userClaims.sub) { + throw new errs.AuthError("The identity provider returned conflicting subjects for this sign in"); + } + + const claims = { ...(idClaims || {}), ...(userClaims || {}) }; + + const email = claims[meta.email_claim || "email"]; + if (!email) { + throw new errs.AuthError( + `The identity provider did not return a "${meta.email_claim || "email"}" claim, which is required`, + ); + } + + return { + identifier: String(claims.sub || email), + email: String(email), + // Whether the provider vouches for the address, which decides if it may + // be used to adopt an existing account. Some send it as a string. + email_verified: claims.email_verified === true || claims.email_verified === "true", + name: claims[meta.name_claim || "name"] || String(email), + nickname: claims[meta.nickname_claim] || null, + groups: toArray(claims[meta.group_claim || "groups"]), + }; +}; + +/** + * Group claims come back as arrays, single strings, or space/comma separated + * strings depending on the provider. + * + * @param {*} value + * @returns {[String]} + */ +const toArray = (value) => { + if (typeof value === "undefined" || value === null) { + return []; + } + if (Array.isArray(value)) { + return value.map(String); + } + return String(value) + .split(/[\s,]+/) + .filter((v) => v !== ""); +}; + +/** + * Checks that the provider's endpoints can be resolved. + * + * @param {Object} provider + * @returns {Promise} + */ +const test = async (provider) => { + if (!provider.meta?.client_id) { + throw new errs.ConfigurationError("OAuth provider has no client ID configured"); + } + await getEndpoints(provider); +}; + +export { buildAuthorizationUrl, completeAuthorization, createFlow, getEndpoints, test, toArray }; diff --git a/backend/lib/auth/provision.js b/backend/lib/auth/provision.js new file mode 100644 index 0000000000..0b2f6a0574 --- /dev/null +++ b/backend/lib/auth/provision.js @@ -0,0 +1,385 @@ +import gravatar from "gravatar"; +import { auth as logger } from "../../logger.js"; +import authModel from "../../models/auth.js"; +import userModel from "../../models/user.js"; +import userPermissionModel from "../../models/user_permission.js"; +import errs from "../error.js"; +import { isLocalAuthEnabled } from "./local-auth.js"; + +/** + * Works out which roles an externally authenticated user should hold. + * + * Roles are only recalculated when the provider has an admin group configured; + * without one, roles stay entirely under the control of the Users screen. + * + * @param {Object} provider + * @param {Object} identity + * @param {[String]} currentRoles + * @returns {[String]|null} the new roles, or null to leave them alone + */ +const resolveRoles = (provider, identity, currentRoles) => { + const adminGroup = (provider.meta?.admin_group || "").trim(); + if (!adminGroup) { + return null; + } + + // A group lookup that failed reports no groups rather than an empty list. + // Treating "we could not ask" as "not a member" would strip admin from + // everybody the moment a directory hiccups, which is exactly the lockout + // this guard exists to avoid. + if (!Array.isArray(identity.groups)) { + logger.warn( + `Group membership for ${identity.email} is unknown, so roles were left as they are (provider "${provider.name}")`, + ); + return null; + } + + const groups = identity.groups.map((g) => String(g).toLowerCase()); + const isAdmin = groups.includes(adminGroup.toLowerCase()); + + const roles = new Set(currentRoles || []); + if (isAdmin) { + roles.add("admin"); + } else { + roles.delete("admin"); + } + + return Array.from(roles); +}; + +/** + * Whether an identity may be attached to an account that already holds the + * same email address. + * + * Matching on an email address means trusting the provider to have proved the + * address belongs to whoever just signed in. A directory does; a public OAuth + * provider that lets anyone set their own address does not, and would hand out + * any account, administrators included, to whoever asked for it. So it is off + * unless configured, and for OIDC the provider must additionally say the + * address was verified. + * + * @param {Object} provider + * @param {Object} identity + * @returns {Boolean} + */ +const canLinkByEmail = (provider, identity) => { + if (!provider.meta?.link_by_email) { + return false; + } + + if (provider.type === "oauth" && identity.email_verified !== true) { + logger.warn( + `Provider "${provider.name}" did not report ${identity.email} as a verified address, so it was not linked`, + ); + return false; + } + + return true; +}; + +/** + * Roles to give a brand new user, before any group mapping is applied. + * + * @param {Object} provider + * @returns {[String]} + */ +const initialRoles = (provider) => { + const configured = provider.meta?.default_roles; + return Array.isArray(configured) ? [...configured] : []; +}; + +const createPermissions = (userId, isAdmin) => + userPermissionModel.query().insert({ + user_id: userId, + visibility: isAdmin ? "all" : "user", + proxy_hosts: "manage", + redirection_hosts: "manage", + dead_hosts: "manage", + streams: "manage", + access_lists: "manage", + certificates: "manage", + }); + +/** + * Turns a verified external identity into a local user row, creating or linking + * one as the provider's configuration allows. + * + * @param {Object} provider + * @param {Object} identity + * @param {String} identity.identifier Stable id at the provider (GUID, sub, nameID, or DN) + * @param {String} identity.email + * @param {String} [identity.name] + * @param {String} [identity.nickname] + * @param {[String]} [identity.groups] + * @param {Object} [options] + * @param {Boolean} [options.forceCreate] Create even when auto_create_user is off. + * Directory sync sets this: creating accounts + * ahead of first login is the point of it. + * @returns {Promise} the user row + */ +const resolveUser = async (provider, identity, options = {}) => { + const email = String(identity.email || "") + .toLowerCase() + .trim(); + + if (!email) { + throw new errs.AuthError("The authentication provider did not supply an email address"); + } + + // 1. An identity we've seen before + const existingAuth = await authModel + .query() + .where("provider_id", provider.id) + .andWhere("identifier", identity.identifier) + .andWhere("is_deleted", 0) + .first(); + + let user = null; + + if (existingAuth) { + user = await userModel.query().where("id", existingAuth.user_id).andWhere("is_deleted", 0).first(); + } + + // 2. Otherwise there may be an account already holding this email address. + // Adopting it is only safe when the provider has been trusted to say who + // owns an address, so an operator has to ask for it. + if (!user) { + const sameEmail = await userModel.query().where("email", email).andWhere("is_deleted", 0).first(); + + if (sameEmail) { + if (!canLinkByEmail(provider, identity)) { + logger.warn( + `Refused to link ${email} from provider "${provider.name}" to the existing account: ` + + "linking by email address is not enabled for this provider", + ); + throw new errs.AuthError( + "An account with this email address already exists. An administrator has to link it to this provider.", + "error.external-email-already-taken", + ); + } + user = sameEmail; + } + } + + // 3. Otherwise create one, if the provider is allowed to + if (!user) { + if (!options.forceCreate && !provider.meta?.auto_create_user) { + logger.info(`Rejected login for unknown user ${email} from provider ${provider.name}`); + throw new errs.AuthError("No account exists for this user", "error.no-account-for-external-user"); + } + + const roles = resolveRoles(provider, identity, initialRoles(provider)) ?? initialRoles(provider); + + user = await userModel.query().insertAndFetch({ + is_deleted: 0, + is_disabled: 0, + email, + name: identity.name || email, + nickname: identity.nickname || identity.name || email, + avatar: gravatar.url(email, { default: "mm" }), + roles, + }); + + await createPermissions(user.id, roles.includes("admin")); + logger.info(`Created user ${email} from provider ${provider.name}`); + } else { + if (user.is_disabled) { + throw new errs.AuthError("This account is disabled"); + } + + // Keep roles in sync when the provider maps an admin group + const roles = resolveRoles(provider, identity, user.roles); + if (roles && !sameRoles(roles, user.roles)) { + await userModel.query().where("id", user.id).patch({ roles }); + logger.info(`Updated roles for ${email} from provider ${provider.name}: [${roles.join(", ")}]`); + user.roles = roles; + + // Admins need to be able to see everything they administer + if (roles.includes("admin")) { + await userPermissionModel.query().where("user_id", user.id).patch({ visibility: "all" }); + } + } + + // A user created before this provider existed may have no permissions row + const permissions = await userPermissionModel.query().where("user_id", user.id).first(); + if (!permissions) { + await createPermissions(user.id, (user.roles || []).includes("admin")); + } + } + + // 4. Record the link so the next login matches on identifier rather than email + await linkIdentity(provider, user, identity); + + return user; +}; + +const sameRoles = (a, b) => { + const left = [...(a || [])].sort(); + const right = [...(b || [])].sort(); + return left.length === right.length && left.every((v, i) => v === right[i]); +}; + +/** + * Creates or refreshes the auth row that ties a user to an external identity. + * + * @param {Object} provider + * @param {Object} user + * @param {Object} identity + * @returns {Promise} + */ +const linkIdentity = async (provider, user, identity) => { + const existing = await authModel + .query() + .where("user_id", user.id) + .andWhere("provider_id", provider.id) + .andWhere("is_deleted", 0) + .first(); + + const meta = { + email: identity.email, + name: identity.name || null, + // Keep the last known membership when the lookup failed, rather than + // recording an empty list that reads as "belongs to nothing" + groups: Array.isArray(identity.groups) ? identity.groups : existing?.meta?.groups || [], + provider_slug: provider.slug, + // Kept for troubleshooting: the identifier is normally an opaque GUID, + // so the DN is the only human readable pointer back to the directory + dn: identity.dn || null, + identifier_source: identity.identifier_source || null, + seen_on: new Date().toISOString(), + }; + + if (existing) { + return await authModel.query().where("id", existing.id).patch({ + identifier: identity.identifier, + meta, + }); + } + + return await authModel.query().insert({ + user_id: user.id, + provider_id: provider.id, + identifier: identity.identifier, + type: provider.type, + // Not a credential we can authenticate with; the provider holds it + secret: "", + meta, + }); +}; + +/** + * Whether an administrator other than the one given could actually sign in. + * + * Counting who holds the admin role is not enough. With local sign in switched + * off, an administrator whose only credential is a password can no longer get + * in, so they are no fallback at all; and when the provider being removed is + * the only one they use, neither are they. Asking "could this person still + * reach the login screen" is the question the lockout guards actually mean. + * + * @param {Integer} excludeUserId + * @param {Integer} [excludeProviderId] A provider that is going away, and so + * cannot be counted on to let anyone in + * @returns {Promise} + */ +const anotherAdminCanSignIn = async (excludeUserId, excludeProviderId = null) => { + const localEnabled = await isLocalAuthEnabled(); + + const others = await userModel + .query() + .where("is_deleted", 0) + .andWhere("is_disabled", 0) + .andWhere("id", "!=", excludeUserId); + + for (const other of others) { + if (!(other.roles || []).includes("admin")) { + continue; + } + + const links = await authModel.query().where("user_id", other.id).andWhere("is_deleted", 0); + + const usable = links.some((link) => + link.type === "password" ? localEnabled : link.provider_id !== excludeProviderId, + ); + + if (usable) { + return true; + } + } + + return false; +}; + +/** + * Releases the accounts a provider owns, when that provider goes away. + * + * Without this, removing a provider strands everyone it created: their link + * points at a provider that no longer exists and they hold no password, so + * nobody can sign in as them and nothing says why. + * + * Two outcomes are offered: + * + * - `convert` keeps the accounts and drops the link. They become local accounts + * with no password set, which an administrator can then set from the Users + * screen. Hosts, permissions and ownership are untouched. + * - `delete` removes the accounts too, but only those that would otherwise be + * left with no way in at all. + * + * @param {Object} provider + * @param {String} action "convert" or "delete" + * @returns {Promise} { converted, deleted, kept } + */ +const detachProviderUsers = async (provider, action = "convert") => { + const links = await authModel.query().where("provider_id", provider.id).andWhere("is_deleted", 0); + + let converted = 0; + let deleted = 0; + const kept = []; + + for (const link of links) { + await authModel.query().where("id", link.id).patch({ is_deleted: true }); + + const user = await userModel.query().where("id", link.user_id).andWhere("is_deleted", 0).first(); + if (!user) { + continue; + } + + if (action !== "delete") { + converted++; + continue; + } + + // Somebody who also signs in with a password, or through another + // provider, is not ours to remove + const remaining = await authModel.query().where("user_id", user.id).andWhere("is_deleted", 0).first(); + if (remaining) { + converted++; + kept.push({ id: user.id, email: user.email, reason: "has another way to sign in" }); + continue; + } + + // Deleting the last administrator would leave nobody able to administer + // the instance, which is never what someone means to do + if ((user.roles || []).includes("admin")) { + if (!(await anotherAdminCanSignIn(user.id, provider.id))) { + converted++; + kept.push({ id: user.id, email: user.email, reason: "is the only administrator who can sign in" }); + logger.warn( + `Keeping ${user.email} while removing "${provider.name}": no other administrator could still sign in`, + ); + continue; + } + } + + await userModel.query().where("id", user.id).patch({ is_deleted: 1 }); + deleted++; + } + + logger.info( + `Removing provider "${provider.name}": ${converted} account(s) converted to local, ${deleted} deleted` + + (kept.length ? `, ${kept.length} kept` : ""), + ); + + return { converted, deleted, kept }; +}; + +export { anotherAdminCanSignIn, canLinkByEmail, detachProviderUsers, linkIdentity, resolveRoles, resolveUser }; diff --git a/backend/lib/auth/saml.js b/backend/lib/auth/saml.js new file mode 100644 index 0000000000..d73343cebd --- /dev/null +++ b/backend/lib/auth/saml.js @@ -0,0 +1,271 @@ +import { SAML } from "@node-saml/node-saml"; +import { auth as logger } from "../../logger.js"; +import errs from "../error.js"; + +/** + * Attribute names in a SAML assertion are frequently long URNs, so look the + * value up by the configured name first and then fall back to the well known + * claim URIs and short names that most IdPs emit. + * + * @param {Object} profile + * @param {String} configured + * @param {[String]} fallbacks + * @returns {String|null} + */ +const readClaim = (profile, configured, fallbacks) => { + const candidates = configured ? [configured] : fallbacks; + for (const key of candidates) { + const value = profile?.[key] ?? profile?.attributes?.[key]; + if (Array.isArray(value) && value.length) { + return String(value[0]); + } + if (typeof value === "string" && value !== "") { + return value; + } + } + return null; +}; + +const readClaimList = (profile, configured, fallbacks) => { + const candidates = configured ? [configured] : fallbacks; + for (const key of candidates) { + const value = profile?.[key] ?? profile?.attributes?.[key]; + if (Array.isArray(value)) { + return value.map(String); + } + if (typeof value === "string" && value !== "") { + return [value]; + } + } + return []; +}; + +const EMAIL_FALLBACKS = [ + "email", + "mail", + "nameID", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + "urn:oid:0.9.2342.19200300.100.1.3", +]; + +const NAME_FALLBACKS = [ + "displayName", + "cn", + "name", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + "urn:oid:2.5.4.3", +]; + +const NICKNAME_FALLBACKS = [ + "givenName", + "firstName", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", + "urn:oid:2.5.4.42", +]; + +/** + * A transient NameID is a per-session pseudonym: the IdP issues a different one + * every time. It says nothing about who somebody is, so it cannot be what we + * remember them by — a directory of them would grow a new entry per login. + */ +const TRANSIENT_NAMEID = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"; + +const GROUP_FALLBACKS = ["groups", "memberOf", "Role", "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"]; + +/** + * Outstanding SAML request ids, so an assertion can be tied back to the request + * that asked for it. + * + * A fresh SAML instance is built per request, and node-saml's own default cache + * lives on the instance, so the id saved when the login started would be gone by + * the time the response arrived. This one is shared across them all. + * + * The backend is a single process, so a Map is enough. It empties on restart, + * which at worst asks somebody mid-login to press the button again. + */ +const REQUEST_TTL_MS = 10 * 60 * 1000; + +const requestCache = { + entries: new Map(), + + prune() { + const now = Date.now(); + this.entries.forEach((entry, key) => { + if (entry.createdAt + REQUEST_TTL_MS <= now) { + this.entries.delete(key); + } + }); + }, + + async saveAsync(key, value) { + this.prune(); + if (this.entries.has(key)) { + return null; + } + const item = { value, createdAt: Date.now() }; + this.entries.set(key, item); + return item; + }, + + async getAsync(key) { + this.prune(); + return this.entries.get(key)?.value ?? null; + }, + + async removeAsync(key) { + if (key === null || !this.entries.has(key)) { + return null; + } + this.entries.delete(key); + return key; + }, +}; + +/** + * Builds a configured node-saml instance for a provider. + * + * @param {Object} provider + * @param {String} callbackUrl + * @returns {SAML} + */ +const createSaml = (provider, callbackUrl) => { + const meta = provider.meta || {}; + + if (!meta.entry_point) { + throw new errs.ConfigurationError("SAML provider has no sign-in URL (entry point) configured"); + } + if (!meta.idp_cert) { + throw new errs.ConfigurationError("SAML provider has no IdP signing certificate configured"); + } + + return new SAML({ + callbackUrl, + entryPoint: meta.entry_point, + issuer: meta.issuer || "nginx-proxy-manager", + idpCert: meta.idp_cert, + privateKey: meta.sp_private_key || undefined, + signatureAlgorithm: meta.signature_algorithm || "sha256", + wantAssertionsSigned: meta.want_assertions_signed !== false, + wantAuthnResponseSigned: meta.want_authn_response_signed === true, + // Every assertion must name the request it answers, and each request is + // only answerable once. Without this a captured assertion could be + // replayed until it expired, since a signature stays valid whoever + // presents it. This rules out IdP initiated sign in, which is the point: + // a login has to start here. + validateInResponseTo: "always", + cacheProvider: requestCache, + audience: meta.issuer || "nginx-proxy-manager", + disableRequestedAuthnContext: true, + }); +}; + +/** + * @param {Object} provider + * @param {String} callbackUrl + * @param {String} relayState Single use key identifying this login attempt + * @returns {Promise} the URL to redirect the browser to + */ +const buildAuthorizationRequest = async (provider, callbackUrl, relayState) => { + const saml = createSaml(provider, callbackUrl); + return await saml.getAuthorizeUrlAsync(relayState, undefined, {}); +}; + +/** + * Validates a SAML response posted back by the IdP. + * + * @param {Object} provider + * @param {String} callbackUrl + * @param {Object} body The raw request body ({ SAMLResponse, RelayState }) + * @returns {Promise} + */ +const completeAuthorization = async (provider, callbackUrl, body) => { + const meta = provider.meta || {}; + const saml = createSaml(provider, callbackUrl); + + const { profile } = await saml.validatePostResponseAsync(body); + if (!profile) { + throw new errs.AuthError("The identity provider did not return a valid assertion"); + } + + const email = readClaim(profile, meta.email_attribute, EMAIL_FALLBACKS); + if (!email) { + throw new errs.AuthError( + "The SAML assertion did not contain an email address. Set an email attribute on the provider.", + ); + } + + const { identifier, source } = stableIdentifier(provider, profile, email); + + return { + identifier, + identifier_source: source, + email, + name: readClaim(profile, meta.name_attribute, NAME_FALLBACKS) || email, + nickname: readClaim(profile, meta.nickname_attribute, NICKNAME_FALLBACKS), + groups: readClaimList(profile, meta.group_attribute, GROUP_FALLBACKS), + }; +}; + +/** + * Picks something to remember a person by that will still be the same tomorrow. + * + * The NameID is the natural choice, but only when the IdP issues a lasting one. + * simpleSAMLphp and several hosted IdPs default to a transient format, and + * keying off that would mean nobody is ever recognised twice. When that is what + * comes back, the email address in the assertion is used instead — it is + * scoped to this provider either way, so it only ever matches the account this + * same provider created. + * + * @param {Object} provider + * @param {Object} profile + * @param {String} email + * @returns {Object} { identifier, source } + */ +const stableIdentifier = (provider, profile, email) => { + const attribute = (provider.meta?.identifier_attribute || "").trim(); + if (attribute) { + const value = readClaim(profile, attribute, []); + if (value) { + return { identifier: String(value), source: attribute }; + } + logger.warn( + `SAML provider "${provider.name}" is set to identify people by "${attribute}", which the assertion did not contain`, + ); + } + + if (profile.nameID && profile.nameIDFormat !== TRANSIENT_NAMEID) { + return { identifier: String(profile.nameID), source: "nameID" }; + } + + if (profile.nameID) { + logger.debug( + `SAML provider "${provider.name}" returned a transient NameID, so ${email} is identified by email address instead`, + ); + } + + return { identifier: email, source: "email" }; +}; + +/** + * Generates the SP metadata XML that can be handed to the IdP. + * + * @param {Object} provider + * @param {String} callbackUrl + * @returns {String} + */ +const generateMetadata = (provider, callbackUrl) => { + const saml = createSaml(provider, callbackUrl); + return saml.generateServiceProviderMetadata(null, null); +}; + +/** + * @param {Object} provider + * @param {String} callbackUrl + * @returns {Promise} + */ +const test = async (provider, callbackUrl) => { + // Constructing the instance validates the certificate and required settings + createSaml(provider, callbackUrl); +}; + +export { buildAuthorizationRequest, completeAuthorization, generateMetadata, requestCache, test }; diff --git a/backend/lib/auth/state.js b/backend/lib/auth/state.js new file mode 100644 index 0000000000..16c768341c --- /dev/null +++ b/backend/lib/auth/state.js @@ -0,0 +1,68 @@ +import crypto from "node:crypto"; + +/** + * A tiny in-memory, single-use, TTL'd key/value store. + * + * Used for the short lived values in the redirect based login flows: + * - OAuth `state`/PKCE verifiers + * - SAML request ids + * - The one time code handed to the frontend after a successful SSO login + * + * The backend runs as a single process so an in-memory store is enough, and it + * deliberately does not survive a restart: every value here is valid for at + * most a few minutes anyway. + */ +class TransientStore { + constructor(ttlMs) { + this.ttlMs = ttlMs; + this.entries = new Map(); + } + + prune() { + const now = Date.now(); + this.entries.forEach((entry, key) => { + if (entry.expires <= now) { + this.entries.delete(key); + } + }); + } + + /** + * @param {Object} value + * @returns {String} the generated key + */ + put(value) { + this.prune(); + const key = crypto.randomBytes(32).toString("base64url"); + this.entries.set(key, { value, expires: Date.now() + this.ttlMs }); + return key; + } + + /** + * Reads and removes a key. Returns null when missing or expired. + * + * @param {String} key + * @returns {Object|null} + */ + take(key) { + this.prune(); + if (!key) { + return null; + } + const entry = this.entries.get(key); + if (!entry) { + return null; + } + this.entries.delete(key); + return entry.expires > Date.now() ? entry.value : null; + } +} + +// Login flows in progress: the user has been redirected to the IdP and we're +// waiting for them to come back. +const loginFlows = new TransientStore(10 * 60 * 1000); + +// Completed logins waiting to be exchanged for a token by the frontend. +const exchangeCodes = new TransientStore(60 * 1000); + +export { exchangeCodes, loginFlows, TransientStore }; diff --git a/backend/lib/auth/sync.js b/backend/lib/auth/sync.js new file mode 100644 index 0000000000..76eb40a717 --- /dev/null +++ b/backend/lib/auth/sync.js @@ -0,0 +1,269 @@ +/** + * Directory sync. + * + * Signing in provisions one account at a time, which is fine but means an + * administrator cannot hand out permissions to somebody who has never logged + * in, and a group change only takes effect the next time they do. + * + * Sync walks a provider's directory on a schedule instead: it creates the + * accounts it finds, refreshes their details and group driven roles, and + * optionally disables the ones that have gone away. + * + * Only LDAP supports this. SAML and OAuth have no way to enumerate users. + */ + +import { auth as logger } from "../../logger.js"; +import authModel from "../../models/auth.js"; +import userModel from "../../models/user.js"; +import * as ldap from "./ldap.js"; +import { anotherAdminCanSignIn, resolveUser } from "./provision.js"; + +/** Runs in progress, keyed by provider id, so two never overlap */ +const running = new Map(); + +/** The most recent result per provider, surfaced in the UI */ +const lastResults = new Map(); + +/** Scheduled timers, keyed by provider id */ +const timers = new Map(); + +const MIN_INTERVAL_MINUTES = 5; + +/** + * @param {Object} provider + * @returns {Boolean} + */ +const isSyncable = (provider) => provider.type === "ldap" && !!provider.meta?.sync_enabled; + +/** + * Disables accounts whose directory entry has disappeared. + * + * Only accounts belonging to this provider are considered, and only when the + * run actually saw something. A directory that returns nothing because of a + * misconfiguration must not disable an entire organisation. + * + * @param {Object} provider + * @param {[String]} seenIdentifiers + * @returns {Promise<[Object]>} the users that were disabled + */ +const disableMissing = async (provider, seenIdentifiers) => { + if (!seenIdentifiers.length) { + logger.warn( + `Sync for "${provider.name}" matched no directory entries, so nothing was disabled. Check the sync filter.`, + ); + return []; + } + + const links = await authModel + .query() + .where("provider_id", provider.id) + .andWhere("is_deleted", 0) + .whereNotIn("identifier", seenIdentifiers); + + const disabled = []; + + for (const link of links) { + const user = await userModel + .query() + .where("id", link.user_id) + .andWhere("is_deleted", 0) + .andWhere("is_disabled", 0) + .first(); + + if (!user) { + continue; + } + + // Never lock out the last administrator over a directory hiccup. The + // provider itself is still standing here, so other administrators who + // use it do count. + if ((user.roles || []).includes("admin")) { + if (!(await anotherAdminCanSignIn(user.id))) { + logger.warn( + `Not disabling ${user.email}: no other administrator could still sign in, despite them being absent from "${provider.name}"`, + ); + continue; + } + } + + await userModel.query().where("id", user.id).patch({ is_disabled: 1 }); + disabled.push({ id: user.id, email: user.email }); + logger.info(`Disabled ${user.email}: no longer present in "${provider.name}"`); + } + + return disabled; +}; + +/** + * Runs one sync pass over a provider's directory. + * + * @param {Object} provider a raw provider row, secrets included + * @returns {Promise} a summary of what happened + */ +const runSync = async (provider) => { + if (provider.type !== "ldap") { + throw new Error(`Directory sync is only supported for LDAP providers, not ${provider.type}`); + } + + if (running.has(provider.id)) { + logger.debug(`Sync for "${provider.name}" is already running`); + return running.get(provider.id); + } + + const startedAt = new Date(); + const seenIdentifiers = []; + let created = 0; + let updated = 0; + let failed = 0; + + const run = (async () => { + logger.info(`Starting directory sync for "${provider.name}"`); + + const { seen, skipped } = await ldap.listDirectory(provider, async (identity) => { + try { + const before = await authModel + .query() + .where("provider_id", provider.id) + .andWhere("identifier", identity.identifier) + .andWhere("is_deleted", 0) + .first(); + + await resolveUser(provider, identity, { forceCreate: true }); + + seenIdentifiers.push(identity.identifier); + if (before) { + updated++; + } else { + created++; + } + } catch (err) { + failed++; + logger.warn(`Sync could not provision ${identity.email}: ${err.message}`); + } + }); + + let disabled = []; + if (provider.meta?.sync_disable_missing) { + disabled = await disableMissing(provider, seenIdentifiers); + } + + const result = { + provider_id: provider.id, + started_on: startedAt.toISOString(), + finished_on: new Date().toISOString(), + entries: seen, + created, + updated, + disabled: disabled.length, + skipped, + failed, + ok: true, + }; + + logger.info( + `Sync for "${provider.name}" finished: ${seen} entries, ${created} created, ${updated} updated, ` + + `${disabled.length} disabled, ${skipped} skipped, ${failed} failed`, + ); + + return result; + })(); + + running.set(provider.id, run); + + try { + const result = await run; + lastResults.set(provider.id, result); + return result; + } catch (err) { + const result = { + provider_id: provider.id, + started_on: startedAt.toISOString(), + finished_on: new Date().toISOString(), + ok: false, + error: err.message, + }; + lastResults.set(provider.id, result); + logger.error(`Sync for "${provider.name}" failed: ${err.message}`); + throw err; + } finally { + running.delete(provider.id); + } +}; + +/** + * @param {Integer} providerId + * @returns {Object|null} + */ +const getLastResult = (providerId) => lastResults.get(providerId) || null; + +/** + * @param {Integer} providerId + * @returns {Boolean} + */ +const isRunning = (providerId) => running.has(providerId); + +/** + * Stops the timer for one provider. + * + * @param {Integer} providerId + */ +const unschedule = (providerId) => { + const timer = timers.get(providerId); + if (timer) { + clearInterval(timer); + timers.delete(providerId); + } +}; + +/** + * Reconciles the running timers with the providers that currently want syncing. + * + * Called at boot and whenever a provider is created, changed or removed, so + * the schedule never drifts from the configuration. + * + * @param {[Object]} providers every enabled provider, secrets included + * @returns {Integer} how many are scheduled + */ +const reschedule = (providers) => { + const wanted = providers.filter((p) => p.is_enabled && isSyncable(p)); + const wantedIds = new Set(wanted.map((p) => p.id)); + + for (const id of [...timers.keys()]) { + if (!wantedIds.has(id)) { + unschedule(id); + } + } + + for (const provider of wanted) { + // A tight loop against a directory helps nobody + const minutes = Math.max(MIN_INTERVAL_MINUTES, provider.meta.sync_interval || 60); + const intervalMs = minutes * 60 * 1000; + + unschedule(provider.id); + + const timer = setInterval(() => { + runSync(provider).catch(() => { + // runSync already logged and recorded the failure + }); + }, intervalMs); + + // Don't hold the process open just for a sync timer + timer.unref?.(); + timers.set(provider.id, timer); + + logger.info(`Directory sync scheduled for "${provider.name}" every ${minutes} minutes`); + } + + return wanted.length; +}; + +/** + * Cancels every scheduled sync. Used by tests. + */ +const stopAll = () => { + for (const id of [...timers.keys()]) { + unschedule(id); + } +}; + +export { disableMissing, getLastResult, isRunning, isSyncable, reschedule, runSync, stopAll, unschedule }; diff --git a/backend/logger.js b/backend/logger.js index 2b60dbff7b..3339c668f4 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -9,6 +9,7 @@ const global = new signale.Signale({ scope: "Global ", ...opts }); const migrate = new signale.Signale({ scope: "Migrate ", ...opts }); const express = new signale.Signale({ scope: "Express ", ...opts }); const access = new signale.Signale({ scope: "Access ", ...opts }); +const auth = new signale.Signale({ scope: "Auth ", ...opts }); const nginx = new signale.Signale({ scope: "Nginx ", ...opts }); const ssl = new signale.Signale({ scope: "SSL ", ...opts }); const certbot = new signale.Signale({ scope: "Certbot ", ...opts }); @@ -23,4 +24,4 @@ const debug = (logger, ...args) => { } }; -export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion }; +export { debug, global, migrate, express, access, auth, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion }; diff --git a/backend/migrations/20260821120000_auth_providers.js b/backend/migrations/20260821120000_auth_providers.js new file mode 100644 index 0000000000..d055776193 --- /dev/null +++ b/backend/migrations/20260821120000_auth_providers.js @@ -0,0 +1,96 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "auth_providers"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .createTable("auth_provider", (table) => { + table.increments().primary(); + table.dateTime("created_on").notNull(); + table.dateTime("modified_on").notNull(); + table.integer("is_deleted").notNull().unsigned().defaultTo(0); + table.integer("is_enabled").notNull().unsigned().defaultTo(1); + // Providers that are configured through environment variables are + // synced into this table on boot and cannot be edited in the UI. + table.integer("is_env_managed").notNull().unsigned().defaultTo(0); + // A stable identifier, used to match env configured providers on boot + table.string("slug", 100).notNull(); + table.string("name", 100).notNull(); + table.string("type", 30).notNull(); + table.integer("sort_order").notNull().unsigned().defaultTo(0); + table.json("meta").notNull(); + table.unique("slug"); + }) + .then(() => { + logger.info(`[${migrateName}] auth_provider Table created`); + + // Records which provider an external identity came from, so that + // a user can be linked back to their upstream account. + // + // identifier holds the directory's immutable id where it publishes + // one (objectGUID / entryUUID / OIDC sub / SAML NameID), otherwise + // the DN. It stays NULL for local password rows: every engine we + // support treats NULLs as distinct in a unique index, so the index + // below constrains external identities without affecting them. + return knex.schema.alterTable("auth", (table) => { + table.integer("provider_id").notNull().unsigned().defaultTo(0); + table.string("identifier", 255).nullable(); + table.unique(["provider_id", "identifier"], { + indexName: "auth_provider_identifier_unique", + }); + }); + }) + .then(() => { + logger.info(`[${migrateName}] auth Table altered`); + + return knex("setting").insert({ + id: "auth-local", + name: "Local Authentication", + description: "Whether users are able to sign in with an email address and password", + value: "enabled", + meta: JSON.stringify({}), + }); + }) + .then(() => { + logger.info(`[${migrateName}] auth-local Setting added`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex("setting") + .where({ id: "auth-local" }) + .del() + .then(() => { + return knex.schema.alterTable("auth", (table) => { + table.dropUnique(["provider_id", "identifier"], "auth_provider_identifier_unique"); + table.dropColumn("provider_id"); + table.dropColumn("identifier"); + }); + }) + .then(() => { + return knex.schema.dropTable("auth_provider"); + }) + .then(() => { + logger.info(`[${migrateName}] auth_provider Table dropped`); + }); +}; + +export { up, down }; diff --git a/backend/migrations/20260822140000_access_list_auth_providers.js b/backend/migrations/20260822140000_access_list_auth_providers.js new file mode 100644 index 0000000000..9164883e77 --- /dev/null +++ b/backend/migrations/20260822140000_access_list_auth_providers.js @@ -0,0 +1,54 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "access_list_auth_providers"; + +/** + * Migrate + * + * Lets an access list accept the same directory and identity provider accounts + * used to sign in to the admin interface, rather than only the usernames and + * passwords typed into the list itself. + * + * Both columns are nullable json rather than notNull with a default, because + * MySQL refuses a default on a JSON column. Absent is read as empty. + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .table("access_list", (table) => { + // Providers whose users may authenticate against this list + table.json("auth_provider_ids").nullable(); + // When set, a provider user must also be in one of these groups + table.json("allowed_groups").nullable(); + }) + .then(() => { + logger.info(`[${migrateName}] access_list Table altered`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .table("access_list", (table) => { + table.dropColumn("auth_provider_ids"); + table.dropColumn("allowed_groups"); + }) + .then(() => { + logger.info(`[${migrateName}] access_list Columns dropped`); + }); +}; + +export { up, down }; diff --git a/backend/models/access_list.js b/backend/models/access_list.js index 427d447d62..5cd8fb5e7f 100644 --- a/backend/models/access_list.js +++ b/backend/models/access_list.js @@ -31,12 +31,20 @@ class AccessList extends Model { $parseDatabaseJson(json) { const thisJson = super.$parseDatabaseJson(json); + + // A real property rather than a getter: this value has to survive being + // copied into a plain object on its way to the nginx templates, which a + // prototype getter would not. + thisJson.provider_auth = Array.isArray(thisJson.auth_provider_ids) && thisJson.auth_provider_ids.length > 0; + return convertIntFieldsToBool(thisJson, boolFields); } $formatDatabaseJson(json) { - const thisJson = convertBoolFieldsToInt(json, boolFields); - return super.$formatDatabaseJson(thisJson); + // Derived on read, never stored + const thisJson = { ...json }; + delete thisJson.provider_auth; + return super.$formatDatabaseJson(convertBoolFieldsToInt(thisJson, boolFields)); } static get name() { @@ -48,7 +56,7 @@ class AccessList extends Model { } static get jsonAttributes() { - return ["meta"]; + return ["meta", "auth_provider_ids", "allowed_groups"]; } static get relationMappings() { diff --git a/backend/models/auth_provider.js b/backend/models/auth_provider.js new file mode 100644 index 0000000000..78ae3d94e3 --- /dev/null +++ b/backend/models/auth_provider.js @@ -0,0 +1,51 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +import { Model } from "objection"; +import db from "../db.js"; +import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; +import now from "./now_helper.js"; + +Model.knex(db()); + +const boolFields = ["is_deleted", "is_enabled", "is_env_managed"]; + +class AuthProvider extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + + // Default for meta + if (typeof this.meta === "undefined") { + this.meta = {}; + } + } + + $beforeUpdate() { + this.modified_on = now(); + } + + $parseDatabaseJson(json) { + const thisJson = super.$parseDatabaseJson(json); + return convertIntFieldsToBool(thisJson, boolFields); + } + + $formatDatabaseJson(json) { + const thisJson = convertBoolFieldsToInt(json, boolFields); + return super.$formatDatabaseJson(thisJson); + } + + static get name() { + return "AuthProvider"; + } + + static get tableName() { + return "auth_provider"; + } + + static get jsonAttributes() { + return ["meta"]; + } +} + +export default AuthProvider; diff --git a/backend/package.json b/backend/package.json index 1686308b00..c1b54d6d86 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,11 +9,14 @@ "scripts": { "lint": "biome lint", "prettier": "biome format --write .", - "validate-schema": "node validate-schema.js", - "regenerate-config": "node scripts/regenerate-config" + "regenerate-config": "node scripts/regenerate-config", + "test": "vitest run", + "test:watch": "vitest", + "validate-schema": "node validate-schema.js" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^16.0.0", + "@node-saml/node-saml": "^5.1.0", "ajv": "^8.20.0", "archiver": "^8.0.0", "batchflow": "^0.4.0", @@ -27,6 +30,7 @@ "gravatar": "^1.8.2", "jsonwebtoken": "^9.0.3", "knex": "3.3.0", + "ldapts": "^9.0.0", "liquidjs": "10.29.0", "lodash": "^4.18.1", "moment": "^2.30.1", @@ -44,7 +48,8 @@ "devDependencies": { "@apidevtools/swagger-parser": "^13.0.0", "@biomejs/biome": "^2.5.10", - "nodemon": "^3.1.14" + "nodemon": "^3.1.14", + "vitest": "^4.1.8" }, "signale": { "displayDate": true, diff --git a/backend/routes/access-verify.js b/backend/routes/access-verify.js new file mode 100644 index 0000000000..f8813fd0d7 --- /dev/null +++ b/backend/routes/access-verify.js @@ -0,0 +1,109 @@ +import express from "express"; +import internalAccessList from "../internal/access-list.js"; +import { auth as authLogger, debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * Reads an HTTP Basic header. + * + * The password may itself contain a colon, so only the first one separates the + * two halves. + * + * @param {String} header + * @returns {Object|null} + */ +const parseBasicAuth = (header) => { + if (typeof header !== "string") { + return null; + } + + const [scheme, encoded] = header.split(" "); + if (!encoded || scheme.toLowerCase() !== "basic") { + return null; + } + + let decoded; + try { + decoded = Buffer.from(encoded, "base64").toString("utf8"); + } catch (_) { + return null; + } + + const separator = decoded.indexOf(":"); + if (separator === -1) { + return null; + } + + return { + username: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; +}; + +/** + * GET /access-lists/123/verify + * + * The target of nginx's auth_request for access lists that accept provider + * accounts. Answers 204 to let the request through, or 401 with a challenge so + * the browser prompts. + * + * Unauthenticated on purpose: it is the visitor's own credentials being + * checked. The reply is only ever pass or fail, and never says whether the + * username exists, so it gives away nothing a login form does not. + */ +router + .route("/:listID/verify") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res) => { + const challenge = () => { + // nginx copies this onto its own 401 so the browser knows to prompt + res.set("WWW-Authenticate", 'Basic realm="Authorization required"'); + res.status(401).send(); + }; + + try { + const credentials = parseBasicAuth(req.headers.authorization); + if (!credentials) { + return challenge(); + } + + const result = await internalAccessList.verifyCredentials( + req.params.listID, + credentials.username, + credentials.password, + ); + + if (!result.allowed) { + authLogger.debug( + `Access list ${req.params.listID}: denied "${credentials.username}" (${result.reason})`, + ); + return challenge(); + } + + if (!result.cached) { + authLogger.info( + `Access list ${req.params.listID}: allowed "${credentials.username}" via ${result.via}`, + ); + } + + // Handed back to nginx, which can forward them to the proxied app + res.set("X-Auth-User", credentials.username); + if (result.email) { + res.set("X-Auth-Email", result.email); + } + return res.status(204).send(); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + // Failing closed: a broken directory must not open a protected site + return challenge(); + } + }); + +export default router; diff --git a/backend/routes/auth-providers.js b/backend/routes/auth-providers.js new file mode 100644 index 0000000000..4736e17083 --- /dev/null +++ b/backend/routes/auth-providers.js @@ -0,0 +1,280 @@ +import express from "express"; +import internalAuth from "../internal/auth.js"; +import internalAuthProvider from "../internal/auth-provider.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import apiValidator from "../lib/validator/api.js"; +import { debug, express as logger } from "../logger.js"; +import { getValidationSchema } from "../schema/index.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * /api/auth-providers + */ +router + .route("/") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/auth-providers + * + * Retrieve all configured authentication providers + */ + .get(async (req, res, next) => { + try { + const rows = await internalAuthProvider.getAll(res.locals.access); + res.status(200).send(rows); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * POST /api/auth-providers + * + * Create a new authentication provider + */ + .post(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/auth-providers", "post"), req.body); + const result = await internalAuthProvider.create(res.locals.access, payload); + res.status(201).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * /api/auth-providers/local + * + * The global toggle for email + password sign in. It lives here rather than + * under /settings because turning it off is only safe in the context of the + * configured providers. + */ +router + .route("/local") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + .get(async (req, res, next) => { + try { + const enabled = await internalAuthProvider.isLocalAuthEnabled(); + res.status(200).send({ local_enabled: enabled }); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + .put(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/auth-providers/local", "put"), req.body); + const result = await internalAuthProvider.setLocalAuthEnabled(res.locals.access, payload.local_enabled); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * POST /api/auth-providers/test + * + * Check connection settings that have not been saved yet. Declared before the + * /:providerID routes so that "test" is not taken for a provider id. + */ +router + .route("/test") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .post(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/auth-providers/test", "post"), req.body); + if (payload.id) { + payload.callback_url = internalAuth.getCallbackUrl(req, payload.id); + } + const result = await internalAuthProvider.testConfig(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * /api/auth-providers/123 + */ +router + .route("/:providerID") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/auth-providers/123 + */ + .get(async (req, res, next) => { + try { + const row = await internalAuthProvider.get(res.locals.access, req.params.providerID); + res.status(200).send(row); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * PUT /api/auth-providers/123 + */ + .put(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/auth-providers/{providerID}", "put"), req.body); + payload.id = Number.parseInt(req.params.providerID, 10); + const result = await internalAuthProvider.update(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * DELETE /api/auth-providers/123 + */ + .delete(async (req, res, next) => { + try { + // Defaults to keeping the accounts: losing people's access should + // never be the consequence of leaving a parameter off + const result = await internalAuthProvider.delete( + res.locals.access, + req.params.providerID, + req.query.users || "convert", + ); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * GET /api/auth-providers/123/users + * + * How many accounts this provider owns, so the interface can say what deleting + * it would affect before anyone commits to it. + */ +router + .route("/:providerID/users") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const result = await internalAuthProvider.getUserImpact(res.locals.access, req.params.providerID); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * POST /api/auth-providers/123/test + * + * Check that a provider's settings actually work, without signing anyone in. + */ +router + .route("/:providerID/test") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .post(async (req, res, next) => { + try { + const callbackUrl = internalAuth.getCallbackUrl(req, req.params.providerID); + const result = await internalAuthProvider.test(res.locals.access, req.params.providerID, callbackUrl); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * POST /api/auth-providers/123/test-credentials + * + * Verify a real username and password against a directory, without signing in. + */ +router + .route("/:providerID/test-credentials") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .post(async (req, res, next) => { + try { + const payload = await apiValidator( + getValidationSchema("/auth-providers/{providerID}/test-credentials", "post"), + req.body, + ); + const result = await internalAuthProvider.testCredentials( + res.locals.access, + req.params.providerID, + payload.username, + payload.password, + ); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * /api/auth-providers/123/sync + * + * GET reports the last directory sync, POST starts one now. + */ +router + .route("/:providerID/sync") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + .get(async (req, res, next) => { + try { + const result = await internalAuthProvider.getSyncStatus(res.locals.access, req.params.providerID); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + .post(async (req, res, next) => { + try { + const result = await internalAuthProvider.sync(res.locals.access, req.params.providerID); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000000..e6a103d545 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,133 @@ +import express from "express"; +import internalAuth from "../internal/auth.js"; +import internalAuthProvider from "../internal/auth-provider.js"; +import apiValidator from "../lib/validator/api.js"; +import { auth as authLogger, debug, express as logger } from "../logger.js"; +import { getValidationSchema } from "../schema/index.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * Sends the browser back to the frontend after a redirect based login. + * + * On success the frontend receives a single use code which it immediately + * swaps for a real token; the token itself never travels in a URL, where it + * would end up in browser history and access logs. + */ +const backToLogin = (res, params) => { + const query = new URLSearchParams(params).toString(); + res.redirect(302, `/?${query}`); +}; + +/** + * GET /api/auth/providers + * + * The sign in options for the login screen. Unauthenticated by design, so it + * exposes provider names and nothing else. + */ +router + .route("/providers") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res, next) => { + try { + const options = await internalAuthProvider.getLoginOptions(); + res.status(200).send(options); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * POST /api/auth/exchange + * + * Swaps the single use code from a completed SSO login for an access token. + */ +router + .route("/exchange") + .options((_, res) => { + res.sendStatus(204); + }) + .post(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/auth/exchange", "post"), req.body); + const result = await internalAuth.exchange(payload.code); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * GET /api/auth/123/login + * + * Starts a SAML or OAuth login by redirecting to the identity provider. + */ +router + .route("/:providerID/login") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res, _next) => { + try { + const url = await internalAuth.startLogin(req, req.params.providerID); + res.redirect(302, url); + } catch (err) { + authLogger.error(`Could not start login for provider ${req.params.providerID}: ${err.message}`); + backToLogin(res, { sso_error: err.public ? err.message : "Could not start sign in" }); + } + }); + +/** + * GET|POST /api/auth/123/callback + * + * Where the identity provider sends the user back to. OAuth uses a GET with + * query parameters, SAML posts the assertion form. + */ +const handleCallback = async (req, res) => { + try { + const code = await internalAuth.completeLogin(req, req.params.providerID); + backToLogin(res, { sso_code: code }); + } catch (err) { + authLogger.error(`Login callback failed for provider ${req.params.providerID}: ${err.message}`); + backToLogin(res, { sso_error: err.public ? err.message : "Sign in failed" }); + } +}; + +router + .route("/:providerID/callback") + .options((_, res) => { + res.sendStatus(204); + }) + .get(handleCallback) + .post(handleCallback); + +/** + * GET /api/auth/123/metadata + * + * Service provider metadata for a SAML provider, to hand to the IdP. + */ +router + .route("/:providerID/metadata") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res, next) => { + try { + const xml = await internalAuth.getSamlMetadata(req, req.params.providerID); + res.set("Content-Type", "application/xml"); + res.status(200).send(xml); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/routes/main.js b/backend/routes/main.js index a308ea6179..6b293be3d4 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -4,7 +4,10 @@ import errs from "../lib/error.js"; import logRequest from "../lib/express/log-request.js"; import pjson from "../package.json" with { type: "json" }; import { isSetup } from "../setup.js"; +import accessVerifyRoutes from "./access-verify.js"; import auditLogRoutes from "./audit-log.js"; +import authRoutes from "./auth.js"; +import authProviderRoutes from "./auth-providers.js"; import ciRoutes from "./ci.js"; import accessListsRoutes from "./nginx/access_lists.js"; import certificatesHostsRoutes from "./nginx/certificates.js"; @@ -46,8 +49,15 @@ router.get("/", async (_, res /*, next*/) => { }); }); +// Called by nginx as an auth_request subrequest for access lists backed by an +// authentication provider. Carries the visitor's Basic credentials, not a token, +// so it sits outside the authenticated routes below. +router.use("/access-lists", accessVerifyRoutes); + router.use("/schema", schemaRoutes); router.use("/tokens", tokensRoutes); +router.use("/auth", authRoutes); +router.use("/auth-providers", authProviderRoutes); router.use("/users", usersRoutes); router.use("/audit-log", auditLogRoutes); router.use("/reports", reportsRoutes); diff --git a/backend/schema/components/access-list-object.json b/backend/schema/components/access-list-object.json index d80eb06d8f..1054ae84dc 100644 --- a/backend/schema/components/access-list-object.json +++ b/backend/schema/components/access-list-object.json @@ -1,7 +1,17 @@ { "type": "object", "description": "Access List object", - "required": ["id", "created_on", "modified_on", "owner_user_id", "name", "meta", "satisfy_any", "pass_auth", "proxy_host_count"], + "required": [ + "id", + "created_on", + "modified_on", + "owner_user_id", + "name", + "meta", + "satisfy_any", + "pass_auth", + "proxy_host_count" + ], "properties": { "id": { "$ref": "../common.json#/properties/id" @@ -36,6 +46,34 @@ "type": "integer", "minimum": 0, "example": 3 + }, + "auth_provider_ids": { + "type": "array", + "description": "Authentication providers whose users may sign in to sites protected by this list. Only LDAP providers can be used: SAML and OAuth authenticate by redirecting a browser, which an nginx subrequest cannot do.", + "items": { + "type": "integer", + "minimum": 1 + }, + "example": [ + 1 + ] + }, + "allowed_groups": { + "type": "array", + "description": "When set, a provider user must also belong to one of these groups. Entries typed into the list itself are unaffected.", + "items": { + "type": "string", + "minLength": 1 + }, + "example": [ + "cn=vpn-users,ou=groups,dc=example,dc=com" + ] + }, + "provider_auth": { + "type": "boolean", + "readOnly": true, + "description": "Whether this list defers credential checking to a provider, rather than a htpasswd file", + "example": true } } } diff --git a/backend/schema/components/auth-login-options.json b/backend/schema/components/auth-login-options.json new file mode 100644 index 0000000000..3b6ee52dd7 --- /dev/null +++ b/backend/schema/components/auth-login-options.json @@ -0,0 +1,62 @@ +{ + "type": "object", + "description": "The sign in methods available on the login screen", + "required": [ + "local_enabled", + "ldap_enabled", + "providers" + ], + "additionalProperties": false, + "properties": { + "local_enabled": { + "type": "boolean", + "description": "Whether an email address and password can be used to sign in", + "example": true + }, + "ldap_enabled": { + "type": "boolean", + "description": "Whether at least one LDAP directory is configured. LDAP uses the same form as local sign in.", + "example": false + }, + "providers": { + "type": "array", + "description": "Providers that sign in by redirecting to an external site", + "items": { + "type": "object", + "required": [ + "id", + "name", + "type" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "example": 2 + }, + "name": { + "type": "string", + "minLength": 1, + "example": "Company SSO" + }, + "type": { + "type": "string", + "enum": [ + "saml", + "oauth" + ], + "example": "saml" + } + } + }, + "example": [ + { + "id": 2, + "name": "Company SSO", + "type": "saml" + } + ] + } + } +} diff --git a/backend/schema/components/auth-provider-list.json b/backend/schema/components/auth-provider-list.json new file mode 100644 index 0000000000..82c6daa3a6 --- /dev/null +++ b/backend/schema/components/auth-provider-list.json @@ -0,0 +1,7 @@ +{ + "type": "array", + "description": "Authentication Provider list", + "items": { + "$ref": "./auth-provider-object.json" + } +} diff --git a/backend/schema/components/auth-provider-object.json b/backend/schema/components/auth-provider-object.json new file mode 100644 index 0000000000..6c11c85073 --- /dev/null +++ b/backend/schema/components/auth-provider-object.json @@ -0,0 +1,77 @@ +{ + "type": "object", + "description": "Authentication Provider object", + "required": ["id", "created_on", "modified_on", "name", "type", "slug", "is_enabled", "meta"], + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "description": "Unique identifier", + "example": 1 + }, + "created_on": { + "type": "string", + "description": "Date and time of creation", + "format": "date-time", + "example": "2026-08-21T09:41:04.000Z" + }, + "modified_on": { + "type": "string", + "description": "Date and time of last update", + "format": "date-time", + "example": "2026-08-21T09:41:04.000Z" + }, + "is_deleted": { + "type": "boolean", + "description": "Is Deleted", + "example": false + }, + "is_enabled": { + "type": "boolean", + "description": "Whether this provider can be used to sign in", + "example": true + }, + "is_env_managed": { + "type": "boolean", + "description": "Configured through environment variables and therefore read only", + "example": false + }, + "slug": { + "type": "string", + "description": "Stable identifier derived from the name", + "minLength": 1, + "example": "company-ldap" + }, + "name": { + "type": "string", + "description": "Display name, shown on the login screen", + "minLength": 1, + "maxLength": 100, + "example": "Company LDAP" + }, + "type": { + "type": "string", + "description": "Protocol used to talk to the provider", + "enum": ["ldap", "saml", "oauth"], + "example": "ldap" + }, + "sort_order": { + "type": "integer", + "minimum": 0, + "description": "Order in which providers are listed", + "example": 0 + }, + "meta": { + "type": "object", + "description": "Provider configuration. Secret values are never returned; instead a boolean `_set` indicates whether one is stored.", + "example": { + "url": "ldap://ldap.example.com:389", + "base_dn": "dc=example,dc=com", + "bind_password_set": true, + "auto_create_user": true, + "link_by_email": false + } + } + } +} diff --git a/backend/schema/components/auth-sync-result.json b/backend/schema/components/auth-sync-result.json new file mode 100644 index 0000000000..5e71713fc0 --- /dev/null +++ b/backend/schema/components/auth-sync-result.json @@ -0,0 +1,64 @@ +{ + "type": "object", + "description": "The outcome of a directory sync run", + "required": [ + "provider_id", + "ok" + ], + "additionalProperties": false, + "properties": { + "provider_id": { + "type": "integer", + "minimum": 1, + "example": 1 + }, + "started_on": { + "type": "string", + "format": "date-time", + "example": "2026-08-22T10:06:15.644Z" + }, + "finished_on": { + "type": "string", + "format": "date-time", + "example": "2026-08-22T10:06:15.724Z" + }, + "ok": { + "type": "boolean", + "example": true + }, + "error": { + "type": "string", + "description": "Present when the run failed", + "example": "Invalid credentials — check the bind DN and password" + }, + "entries": { + "type": "integer", + "description": "Directory entries matched by the sync filter", + "example": 128 + }, + "created": { + "type": "integer", + "description": "Local accounts created", + "example": 3 + }, + "updated": { + "type": "integer", + "description": "Existing accounts refreshed", + "example": 125 + }, + "disabled": { + "type": "integer", + "description": "Accounts disabled because they are no longer in the directory", + "example": 1 + }, + "skipped": { + "type": "integer", + "description": "Entries ignored, usually for having no email address", + "example": 2 + }, + "failed": { + "type": "integer", + "example": 0 + } + } +} diff --git a/backend/schema/components/user-object.json b/backend/schema/components/user-object.json index 7acd0a4290..b7e2ae1d0b 100644 --- a/backend/schema/components/user-object.json +++ b/backend/schema/components/user-object.json @@ -1,7 +1,17 @@ { "type": "object", "description": "User object", - "required": ["id", "created_on", "modified_on", "is_disabled", "email", "name", "nickname", "avatar", "roles"], + "required": [ + "id", + "created_on", + "modified_on", + "is_disabled", + "email", + "name", + "nickname", + "avatar", + "roles" + ], "additionalProperties": false, "properties": { "id": { @@ -49,7 +59,9 @@ }, "roles": { "description": "Roles applied", - "example": ["admin"], + "example": [ + "admin" + ], "type": "array", "items": { "type": "string" @@ -111,6 +123,59 @@ "pattern": "^(manage|view|hidden)$" } } + }, + "auth_sources": { + "type": "array", + "description": "Where this user is able to sign in from. Only returned when listing users.", + "items": { + "type": "object", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "local", + "ldap", + "saml", + "oauth" + ], + "example": "ldap" + }, + "provider_id": { + "description": "Null for local password accounts", + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "name": { + "description": "Provider display name, null if the provider has since been removed", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + }, + "example": [ + { + "type": "ldap", + "provider_id": 1, + "name": "Company LDAP" + } + ] } } } diff --git a/backend/schema/paths/auth-providers/get.json b/backend/schema/paths/auth-providers/get.json new file mode 100644 index 0000000000..68e94a2468 --- /dev/null +++ b/backend/schema/paths/auth-providers/get.json @@ -0,0 +1,45 @@ +{ + "operationId": "getAuthProviders", + "summary": "Get all authentication providers", + "tags": ["auth-providers"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": [ + { + "id": 1, + "created_on": "2026-08-21T09:41:04.000Z", + "modified_on": "2026-08-21T09:41:04.000Z", + "is_deleted": false, + "is_enabled": true, + "is_env_managed": false, + "slug": "company-ldap", + "name": "Company LDAP", + "type": "ldap", + "sort_order": 0, + "meta": { + "url": "ldap://ldap.example.com:389", + "base_dn": "dc=example,dc=com", + "bind_password_set": true + } + } + ] + } + }, + "schema": { + "$ref": "../../components/auth-provider-list.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/local/get.json b/backend/schema/paths/auth-providers/local/get.json new file mode 100644 index 0000000000..dfa8edefbe --- /dev/null +++ b/backend/schema/paths/auth-providers/local/get.json @@ -0,0 +1,37 @@ +{ + "operationId": "getLocalAuth", + "summary": "Check whether email and password sign in is enabled", + "tags": ["auth-providers"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "local_enabled": true + } + } + }, + "schema": { + "type": "object", + "required": ["local_enabled"], + "additionalProperties": false, + "properties": { + "local_enabled": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/local/put.json b/backend/schema/paths/auth-providers/local/put.json new file mode 100644 index 0000000000..8ae4c6673f --- /dev/null +++ b/backend/schema/paths/auth-providers/local/put.json @@ -0,0 +1,60 @@ +{ + "operationId": "setLocalAuth", + "summary": "Enable or disable email and password sign in", + "description": "Local sign in can only be turned off while at least one authentication provider is enabled, and never while the AUTH_DISABLE_LOCAL environment variable is set.", + "tags": ["auth-providers"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "requestBody": { + "description": "Local Authentication Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["local_enabled"], + "properties": { + "local_enabled": { + "type": "boolean", + "example": false + } + } + }, + "example": { + "local_enabled": false + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "local_enabled": false + } + } + }, + "schema": { + "type": "object", + "required": ["local_enabled"], + "additionalProperties": false, + "properties": { + "local_enabled": { + "type": "boolean", + "example": false + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/post.json b/backend/schema/paths/auth-providers/post.json new file mode 100644 index 0000000000..800a70ec8c --- /dev/null +++ b/backend/schema/paths/auth-providers/post.json @@ -0,0 +1,66 @@ +{ + "operationId": "createAuthProvider", + "summary": "Create an authentication provider", + "tags": ["auth-providers"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "requestBody": { + "description": "Authentication Provider Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { + "$ref": "../../components/auth-provider-object.json#/properties/name" + }, + "type": { + "$ref": "../../components/auth-provider-object.json#/properties/type" + }, + "is_enabled": { + "$ref": "../../components/auth-provider-object.json#/properties/is_enabled" + }, + "sort_order": { + "$ref": "../../components/auth-provider-object.json#/properties/sort_order" + }, + "meta": { + "$ref": "../../components/auth-provider-object.json#/properties/meta" + } + } + }, + "example": { + "name": "Company LDAP", + "type": "ldap", + "is_enabled": true, + "meta": { + "url": "ldap://ldap.example.com:389", + "bind_dn": "cn=readonly,dc=example,dc=com", + "bind_password": "secret", + "base_dn": "dc=example,dc=com", + "user_filter": "(|(uid={{username}})(mail={{username}}))", + "auto_create_user": true, + "link_by_email": false + } + } + } + } + }, + "responses": { + "201": { + "description": "201 response", + "content": { + "application/json": { + "schema": { + "$ref": "../../components/auth-provider-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/providerID/delete.json b/backend/schema/paths/auth-providers/providerID/delete.json new file mode 100644 index 0000000000..3fd654f07a --- /dev/null +++ b/backend/schema/paths/auth-providers/providerID/delete.json @@ -0,0 +1,108 @@ +{ + "operationId": "deleteAuthProvider", + "summary": "Delete an authentication provider", + "tags": [ + "auth-providers" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 1 + }, + { + "in": "query", + "name": "users", + "schema": { + "type": "string", + "enum": [ + "convert", + "delete" + ], + "default": "convert" + }, + "required": false, + "description": "What to do with the accounts this provider created", + "example": "convert" + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "converted": 3, + "deleted": 0, + "kept": [], + "deleted_provider": true + } + } + }, + "schema": { + "type": "object", + "required": [ + "deleted_provider" + ], + "additionalProperties": false, + "properties": { + "deleted_provider": { + "type": "boolean", + "example": true + }, + "converted": { + "type": "integer", + "description": "Accounts kept and turned into local accounts", + "example": 3 + }, + "deleted": { + "type": "integer", + "description": "Accounts removed", + "example": 0 + }, + "kept": { + "type": "array", + "description": "Accounts deliberately kept despite a delete being asked for", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "example": 2 + }, + "email": { + "type": "string", + "example": "alice@example.com" + }, + "reason": { + "type": "string", + "example": "is the only administrator" + } + } + }, + "example": [] + } + } + } + } + } + } + }, + "description": "Removes a provider and decides what becomes of the accounts it created. Defaults to converting them to local accounts, because losing access should never be the consequence of omitting a parameter. Accounts that can still sign in another way, and the last remaining administrator, are always kept." +} diff --git a/backend/schema/paths/auth-providers/providerID/get.json b/backend/schema/paths/auth-providers/providerID/get.json new file mode 100644 index 0000000000..31966ba7ca --- /dev/null +++ b/backend/schema/paths/auth-providers/providerID/get.json @@ -0,0 +1,35 @@ +{ + "operationId": "getAuthProvider", + "summary": "Get an authentication provider", + "tags": ["auth-providers"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 1 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/auth-provider-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/providerID/put.json b/backend/schema/paths/auth-providers/providerID/put.json new file mode 100644 index 0000000000..5e5e320c48 --- /dev/null +++ b/backend/schema/paths/auth-providers/providerID/put.json @@ -0,0 +1,66 @@ +{ + "operationId": "updateAuthProvider", + "summary": "Update an authentication provider", + "tags": ["auth-providers"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 1 + } + ], + "requestBody": { + "description": "Authentication Provider Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { + "$ref": "../../../components/auth-provider-object.json#/properties/name" + }, + "is_enabled": { + "$ref": "../../../components/auth-provider-object.json#/properties/is_enabled" + }, + "sort_order": { + "$ref": "../../../components/auth-provider-object.json#/properties/sort_order" + }, + "meta": { + "$ref": "../../../components/auth-provider-object.json#/properties/meta" + } + } + }, + "example": { + "name": "Company LDAP", + "is_enabled": false + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/auth-provider-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/providerID/sync/get.json b/backend/schema/paths/auth-providers/providerID/sync/get.json new file mode 100644 index 0000000000..1c933338bf --- /dev/null +++ b/backend/schema/paths/auth-providers/providerID/sync/get.json @@ -0,0 +1,103 @@ +{ + "operationId": "getAuthProviderSync", + "summary": "Get the directory sync status for a provider", + "tags": [ + "auth-providers" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 1 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "supported", + "enabled", + "running" + ], + "additionalProperties": false, + "properties": { + "supported": { + "type": "boolean", + "description": "Only LDAP providers can be enumerated", + "example": true + }, + "enabled": { + "type": "boolean", + "example": true + }, + "running": { + "type": "boolean", + "example": false + }, + "last_result": { + "oneOf": [ + { + "$ref": "../../../../components/auth-sync-result.json" + }, + { + "type": "null" + } + ], + "example": { + "provider_id": 1, + "started_on": "2026-08-22T10:06:15.644Z", + "finished_on": "2026-08-22T10:06:15.724Z", + "ok": true, + "entries": 128, + "created": 3, + "updated": 125, + "disabled": 1, + "skipped": 2, + "failed": 0 + } + } + } + }, + "examples": { + "default": { + "value": { + "supported": true, + "enabled": true, + "running": false, + "last_result": { + "provider_id": 1, + "started_on": "2026-08-22T10:06:15.644Z", + "finished_on": "2026-08-22T10:06:15.724Z", + "ok": true, + "entries": 128, + "created": 3, + "updated": 125, + "disabled": 1, + "skipped": 2, + "failed": 0 + } + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/providerID/sync/post.json b/backend/schema/paths/auth-providers/providerID/sync/post.json new file mode 100644 index 0000000000..1aafde62cf --- /dev/null +++ b/backend/schema/paths/auth-providers/providerID/sync/post.json @@ -0,0 +1,56 @@ +{ + "operationId": "runAuthProviderSync", + "summary": "Run a directory sync now", + "description": "Walks the provider's directory immediately instead of waiting for the schedule. LDAP only.", + "tags": [ + "auth-providers" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 1 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "../../../../components/auth-sync-result.json" + }, + "examples": { + "default": { + "value": { + "provider_id": 1, + "started_on": "2026-08-22T10:06:15.644Z", + "finished_on": "2026-08-22T10:06:15.724Z", + "ok": true, + "entries": 128, + "created": 3, + "updated": 125, + "disabled": 1, + "skipped": 2, + "failed": 0 + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/providerID/test-credentials/post.json b/backend/schema/paths/auth-providers/providerID/test-credentials/post.json new file mode 100644 index 0000000000..dec00ddfbf --- /dev/null +++ b/backend/schema/paths/auth-providers/providerID/test-credentials/post.json @@ -0,0 +1,131 @@ +{ + "operationId": "testAuthProviderCredentials", + "summary": "Verify a username and password against a directory", + "description": "Runs a real authentication attempt without issuing a token, so a provider can be confirmed before it is switched on. LDAP only.", + "tags": [ + "auth-providers" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 1 + } + ], + "requestBody": { + "description": "Credentials to try", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "example": "alice" + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "example": "hunter2" + } + } + }, + "example": { + "username": "alice", + "password": "hunter2" + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "valid": true, + "dn": "cn=alice,ou=users,dc=example,dc=com", + "email": "alice@example.com", + "name": "Alice Anderson", + "identifier_source": "entryUUID", + "groups": [ + "cn=npm-admins,ou=groups,dc=example,dc=com" + ] + } + } + }, + "schema": { + "type": "object", + "required": [ + "valid" + ], + "additionalProperties": true, + "properties": { + "valid": { + "type": "boolean", + "description": "Whether the credentials were accepted", + "example": true + }, + "dn": { + "type": "string", + "description": "Distinguished name of the entry that matched", + "example": "cn=alice,ou=users,dc=example,dc=com" + }, + "email": { + "type": "string", + "example": "alice@example.com" + }, + "name": { + "type": "string", + "example": "Alice Anderson" + }, + "identifier_source": { + "type": "string", + "description": "Which stable identifier the directory published", + "enum": [ + "objectGUID", + "entryUUID", + "dn" + ], + "example": "entryUUID" + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "cn=npm-admins,ou=groups,dc=example,dc=com" + ] + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/providerID/test/post.json b/backend/schema/paths/auth-providers/providerID/test/post.json new file mode 100644 index 0000000000..30de27e055 --- /dev/null +++ b/backend/schema/paths/auth-providers/providerID/test/post.json @@ -0,0 +1,50 @@ +{ + "operationId": "testAuthProvider", + "summary": "Verify that an authentication provider is reachable and correctly configured", + "tags": ["auth-providers"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 1 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "valid": true + } + } + }, + "schema": { + "type": "object", + "required": ["valid"], + "additionalProperties": false, + "properties": { + "valid": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/providerID/users/get.json b/backend/schema/paths/auth-providers/providerID/users/get.json new file mode 100644 index 0000000000..f9ad4215ff --- /dev/null +++ b/backend/schema/paths/auth-providers/providerID/users/get.json @@ -0,0 +1,58 @@ +{ + "operationId": "getAuthProviderUsers", + "summary": "Count the accounts a provider owns", + "description": "Reports how many accounts came from this provider, and how many of those would actually be removed if its users were deleted along with it. Anyone who can still sign in another way is counted as surviving either way.", + "tags": ["auth-providers"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 1 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "users": 4, + "removable": 3 + } + } + }, + "schema": { + "type": "object", + "required": ["users", "removable"], + "additionalProperties": false, + "properties": { + "users": { + "type": "integer", + "description": "Accounts currently linked to this provider", + "example": 4 + }, + "removable": { + "type": "integer", + "description": "Of those, how many have no other way to sign in", + "example": 3 + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/auth-providers/test/post.json b/backend/schema/paths/auth-providers/test/post.json new file mode 100644 index 0000000000..9cc5f4f7a0 --- /dev/null +++ b/backend/schema/paths/auth-providers/test/post.json @@ -0,0 +1,105 @@ +{ + "operationId": "testAuthProviderConfig", + "summary": "Check connection settings that have not been saved yet", + "description": "Runs the same connectivity check as the saved-provider test, against settings supplied in the request. Nothing is written. Supply an existing provider id to reuse its stored secrets for any field left blank, since secrets are never returned to the client. A failure is reported as valid:false with a message rather than an error status.", + "tags": [ + "auth-providers" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "requestBody": { + "description": "Settings to check", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "description": "Existing provider to take stored secrets from", + "example": 1 + }, + "name": { + "type": "string", + "maxLength": 100, + "description": "Only used to label log output; not required", + "example": "Company LDAP" + }, + "type": { + "$ref": "../../../components/auth-provider-object.json#/properties/type" + }, + "meta": { + "$ref": "../../../components/auth-provider-object.json#/properties/meta" + } + } + }, + "example": { + "type": "ldap", + "meta": { + "url": "ldap://ldap.example.com:389", + "base_dn": "dc=example,dc=com", + "bind_dn": "cn=readonly,dc=example,dc=com", + "bind_password": "secret" + } + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "reachable": { + "value": { + "valid": true, + "detail": "bound" + } + }, + "unreachable": { + "value": { + "valid": false, + "error": "connect ECONNREFUSED 10.0.0.5:389" + } + } + }, + "schema": { + "type": "object", + "required": [ + "valid" + ], + "additionalProperties": false, + "properties": { + "valid": { + "type": "boolean", + "example": true + }, + "detail": { + "type": "string", + "description": "What succeeded, when it did", + "example": "bound" + }, + "error": { + "type": "string", + "description": "Why it failed, when it did", + "example": "Invalid credentials — check the bind DN and password" + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/auth/exchange/post.json b/backend/schema/paths/auth/exchange/post.json new file mode 100644 index 0000000000..94bacf2719 --- /dev/null +++ b/backend/schema/paths/auth/exchange/post.json @@ -0,0 +1,67 @@ +{ + "operationId": "exchangeSsoCode", + "summary": "Exchange a single use SSO code for an access token", + "description": "Completes a SAML or OAuth login. The code is issued by the provider callback, is valid for one minute and can only be used once.", + "tags": [ + "auth" + ], + "requestBody": { + "description": "Exchange Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "code" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "example": "V0hBVCBBUkUgWU9VIExPT0tJTkcgQVQ" + } + } + }, + "example": { + "code": "V0hBVCBBUkUgWU9VIExPT0tJTkcgQVQ" + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "../../../components/token-object.json" + }, + { + "$ref": "../../../components/token-challenge.json" + } + ] + }, + "examples": { + "token": { + "value": { + "expires": "2026-08-23T10:06:15.644Z", + "token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4" + } + }, + "requires2fa": { + "value": { + "requires_2fa": true, + "challenge_token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...challenge" + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/auth/providerID/callback/get.json b/backend/schema/paths/auth/providerID/callback/get.json new file mode 100644 index 0000000000..a566f2495e --- /dev/null +++ b/backend/schema/paths/auth/providerID/callback/get.json @@ -0,0 +1,24 @@ +{ + "operationId": "providerCallback", + "summary": "OAuth redirect target", + "description": "Where the identity provider sends the browser back to. Redirects to the login screen with a single use code, or an error message.", + "tags": ["auth"], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 2 + } + ], + "responses": { + "302": { + "description": "Redirect to the login screen" + } + } +} diff --git a/backend/schema/paths/auth/providerID/callback/post.json b/backend/schema/paths/auth/providerID/callback/post.json new file mode 100644 index 0000000000..55c276216e --- /dev/null +++ b/backend/schema/paths/auth/providerID/callback/post.json @@ -0,0 +1,51 @@ +{ + "operationId": "providerCallbackPost", + "summary": "SAML assertion consumer service", + "description": "Where the identity provider posts the SAML assertion. Redirects to the login screen with a single use code, or an error message.", + "tags": [ + "auth" + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 2 + } + ], + "requestBody": { + "description": "SAML Response", + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "SAMLResponse": { + "type": "string", + "example": "PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46..." + }, + "RelayState": { + "type": "string", + "example": "7A2hqM3hY12bBL0HiPf0iYSmpRB2FSnAoaip7mr-kEI" + } + } + }, + "example": { + "SAMLResponse": "PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46...", + "RelayState": "7A2hqM3hY12bBL0HiPf0iYSmpRB2FSnAoaip7mr-kEI" + } + } + } + }, + "responses": { + "302": { + "description": "Redirect to the login screen" + } + } +} diff --git a/backend/schema/paths/auth/providerID/login/get.json b/backend/schema/paths/auth/providerID/login/get.json new file mode 100644 index 0000000000..576ea8c72f --- /dev/null +++ b/backend/schema/paths/auth/providerID/login/get.json @@ -0,0 +1,24 @@ +{ + "operationId": "startProviderLogin", + "summary": "Begin a SAML or OAuth login", + "description": "Redirects the browser to the identity provider. Not intended to be called with fetch.", + "tags": ["auth"], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 2 + } + ], + "responses": { + "302": { + "description": "Redirect to the identity provider" + } + } +} diff --git a/backend/schema/paths/auth/providerID/metadata/get.json b/backend/schema/paths/auth/providerID/metadata/get.json new file mode 100644 index 0000000000..18372a0cf2 --- /dev/null +++ b/backend/schema/paths/auth/providerID/metadata/get.json @@ -0,0 +1,30 @@ +{ + "operationId": "getSamlMetadata", + "summary": "Get the service provider metadata for a SAML provider", + "tags": ["auth"], + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "description": "Authentication Provider ID", + "example": 2 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } +} diff --git a/backend/schema/paths/auth/providers/get.json b/backend/schema/paths/auth/providers/get.json new file mode 100644 index 0000000000..297656ad21 --- /dev/null +++ b/backend/schema/paths/auth/providers/get.json @@ -0,0 +1,33 @@ +{ + "operationId": "getLoginOptions", + "summary": "Get the sign in methods available on the login screen", + "description": "Unauthenticated. Returns only the names and types of enabled providers, never their configuration.", + "tags": ["auth"], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "local_enabled": true, + "ldap_enabled": true, + "providers": [ + { + "id": 2, + "name": "Company SSO", + "type": "saml" + } + ] + } + } + }, + "schema": { + "$ref": "../../../components/auth-login-options.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/access-lists/listID/put.json b/backend/schema/paths/nginx/access-lists/listID/put.json index 61e8044013..cce1ed94b5 100644 --- a/backend/schema/paths/nginx/access-lists/listID/put.json +++ b/backend/schema/paths/nginx/access-lists/listID/put.json @@ -1,10 +1,14 @@ { "operationId": "updateAccessList", "summary": "Update a Access List", - "tags": ["access-lists"], + "tags": [ + "access-lists" + ], "security": [ { - "bearerAuth": ["access_lists.manage"] + "bearerAuth": [ + "access_lists.manage" + ] } ], "parameters": [ @@ -44,6 +48,12 @@ }, "clients": { "$ref": "../../../../common.json#/properties/access_clients" + }, + "auth_provider_ids": { + "$ref": "../../../../components/access-list-object.json#/properties/auth_provider_ids" + }, + "allowed_groups": { + "$ref": "../../../../components/access-list-object.json#/properties/allowed_groups" } } }, @@ -93,7 +103,9 @@ "name": "Administrator", "nickname": "some guy", "avatar": "//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?default=mm", - "roles": ["admin"] + "roles": [ + "admin" + ] }, "items": [ { diff --git a/backend/schema/paths/nginx/access-lists/post.json b/backend/schema/paths/nginx/access-lists/post.json index 38b7003a1e..54a30b7178 100644 --- a/backend/schema/paths/nginx/access-lists/post.json +++ b/backend/schema/paths/nginx/access-lists/post.json @@ -1,7 +1,9 @@ { "operationId": "createAccessList", "summary": "Create a Access List", - "tags": ["access-lists"], + "tags": [ + "access-lists" + ], "security": [ { "bearerAuth": [ @@ -35,6 +37,12 @@ }, "clients": { "$ref": "../../../common.json#/properties/access_clients" + }, + "auth_provider_ids": { + "$ref": "../../../components/access-list-object.json#/properties/auth_provider_ids" + }, + "allowed_groups": { + "$ref": "../../../components/access-list-object.json#/properties/allowed_groups" } } }, diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19ddd..5842944d3c 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -20,6 +20,14 @@ "name": "public", "description": "Endpoints that do not require authentication" }, + { + "name": "auth", + "description": "Endpoints for signing in with an external authentication provider" + }, + { + "name": "auth-providers", + "description": "Endpoints for managing external authentication providers" + }, { "name": "audit-log", "description": "Endpoints related to Audit Logs" @@ -81,6 +89,89 @@ "$ref": "./paths/audit-log/id/get.json" } }, + "/auth/providers": { + "get": { + "$ref": "./paths/auth/providers/get.json" + } + }, + "/auth/exchange": { + "post": { + "$ref": "./paths/auth/exchange/post.json" + } + }, + "/auth/{providerID}/login": { + "get": { + "$ref": "./paths/auth/providerID/login/get.json" + } + }, + "/auth/{providerID}/callback": { + "get": { + "$ref": "./paths/auth/providerID/callback/get.json" + }, + "post": { + "$ref": "./paths/auth/providerID/callback/post.json" + } + }, + "/auth/{providerID}/metadata": { + "get": { + "$ref": "./paths/auth/providerID/metadata/get.json" + } + }, + "/auth-providers": { + "get": { + "$ref": "./paths/auth-providers/get.json" + }, + "post": { + "$ref": "./paths/auth-providers/post.json" + } + }, + "/auth-providers/local": { + "get": { + "$ref": "./paths/auth-providers/local/get.json" + }, + "put": { + "$ref": "./paths/auth-providers/local/put.json" + } + }, + "/auth-providers/test": { + "post": { + "$ref": "./paths/auth-providers/test/post.json" + } + }, + "/auth-providers/{providerID}": { + "get": { + "$ref": "./paths/auth-providers/providerID/get.json" + }, + "put": { + "$ref": "./paths/auth-providers/providerID/put.json" + }, + "delete": { + "$ref": "./paths/auth-providers/providerID/delete.json" + } + }, + "/auth-providers/{providerID}/users": { + "get": { + "$ref": "./paths/auth-providers/providerID/users/get.json" + } + }, + "/auth-providers/{providerID}/test": { + "post": { + "$ref": "./paths/auth-providers/providerID/test/post.json" + } + }, + "/auth-providers/{providerID}/sync": { + "get": { + "$ref": "./paths/auth-providers/providerID/sync/get.json" + }, + "post": { + "$ref": "./paths/auth-providers/providerID/sync/post.json" + } + }, + "/auth-providers/{providerID}/test-credentials": { + "post": { + "$ref": "./paths/auth-providers/providerID/test-credentials/post.json" + } + }, "/nginx/access-lists": { "get": { "$ref": "./paths/nginx/access-lists/get.json" diff --git a/backend/setup.js b/backend/setup.js index f6b1454434..95bffc3d67 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -1,4 +1,6 @@ import fs from "node:fs/promises"; +import internalAuthProvider from "./internal/auth-provider.js"; +import { syncEnvProviders } from "./lib/auth/env.js"; import { installPlugins } from "./lib/certbot.js"; import utils from "./lib/utils.js"; import { setup as logger } from "./logger.js"; @@ -158,4 +160,38 @@ const setupLogrotation = () => { return runLogrotate(); }; -export default () => setupDefaultUser().then(setupDefaultSettings).then(setupCertbotPlugins).then(setupLogrotation); +/** + * Reconciles any authentication providers described by environment variables + * with the database, so that a container can be configured entirely through + * its environment. + * + * @returns {Promise} + */ +const setupAuthProviders = async () => { + try { + const count = await syncEnvProviders(); + if (count) { + logger.info(`${count} authentication provider(s) configured from the environment`); + } + } catch (err) { + // A bad provider config must not stop the app from booting, otherwise a + // typo in an env var locks the admin out of fixing it. + logger.error(`Could not sync authentication providers from the environment: ${err.message}`); + } + + try { + const scheduled = await internalAuthProvider.refreshSchedules(); + if (scheduled) { + logger.info(`Directory sync scheduled for ${scheduled} provider(s)`); + } + } catch (err) { + logger.error(`Could not schedule directory sync: ${err.message}`); + } +}; + +export default () => + setupDefaultUser() + .then(setupDefaultSettings) + .then(setupAuthProviders) + .then(setupCertbotPlugins) + .then(setupLogrotation); diff --git a/backend/templates/_access.conf b/backend/templates/_access.conf index 81983f3a3c..bdd153ed18 100644 --- a/backend/templates/_access.conf +++ b/backend/templates/_access.conf @@ -1,9 +1,25 @@ {% if access_list_id > 0 %} - {% if access_list.items.length > 0 %} + {% if access_list.provider_auth == 1 or access_list.provider_auth == true %} + # Authorization, checked against the authentication providers this list + # accepts. A directory will not give up password hashes, so these accounts + # cannot live in a htpasswd file; nginx asks the backend per request instead. + set $npm_access_list {{ access_list_id }}; + auth_request /_npm_access_check; + + # Carry the challenge back from the subrequest, so the browser prompts. + # It is empty for a 401 coming from the proxied application itself, and + # nginx omits a header with an empty value, so those pass through untouched. + auth_request_set $npm_auth_challenge $upstream_http_www_authenticate; + auth_request_set $npm_auth_user $upstream_http_x_auth_user; + error_page 401 = @npm_access_challenge; + + {% elsif access_list.items.length > 0 %} # Authorization auth_basic "Authorization required"; auth_basic_user_file /data/access/{{ access_list_id }}; + {% endif %} + {% if access_list.provider_auth == 1 or access_list.provider_auth == true or access_list.items.length > 0 %} {% if access_list.pass_auth == 0 or access_list.pass_auth == false %} proxy_set_header Authorization ""; {% endif %} diff --git a/backend/templates/_access_provider.conf b/backend/templates/_access_provider.conf new file mode 100644 index 0000000000..3857587451 --- /dev/null +++ b/backend/templates/_access_provider.conf @@ -0,0 +1,22 @@ +{% if access_list_id > 0 %} +{% if access_list.provider_auth == 1 or access_list.provider_auth == true %} + # Supporting locations for provider backed authorization. These have to sit at + # server level, while the auth_request that uses them is inside each location. + + location = /_npm_access_check { + internal; + # $npm_access_list is set by whichever location is being protected, so one + # pair of blocks serves every location on this server. + proxy_pass http://127.0.0.1:3000/access-lists/$npm_access_list/verify; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Original-Remote-Addr $remote_addr; + } + + location @npm_access_challenge { + add_header WWW-Authenticate $npm_auth_challenge always; + return 401; + } +{% endif %} +{% endif %} diff --git a/backend/templates/proxy_host.conf b/backend/templates/proxy_host.conf index d23ca46fa2..326cc046d0 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -27,6 +27,8 @@ proxy_http_version 1.1; {{ advanced_config }} +{% include "_access_provider.conf" %} + {{ locations }} {% if use_default_location %} diff --git a/backend/vitest.config.js b/backend/vitest.config.js new file mode 100644 index 0000000000..689545f020 --- /dev/null +++ b/backend/vitest.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["__tests__/**/*.test.js"], + // Auth code is the wrong place for a flaky test to hide + restoreMocks: true, + }, +}); diff --git a/backend/yarn.lock b/backend/yarn.lock index 87f1e555e6..fd4f4c71cc 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -101,11 +101,34 @@ dependencies: minipass "^7.0.4" +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + "@noble/hashes@^2.2.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.3.0.tgz#505fd39c3134a37e67c8c4e6c6049a496154879c" integrity sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ== +"@node-saml/node-saml@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@node-saml/node-saml/-/node-saml-5.1.0.tgz#43d61d4ea882f2960a44c7be5ae0030dafea2382" + integrity sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw== + dependencies: + "@types/debug" "^4.1.12" + "@types/qs" "^6.9.18" + "@types/xml-encryption" "^1.2.4" + "@types/xml2js" "^0.4.14" + "@xmldom/is-dom-node" "^1.0.1" + "@xmldom/xmldom" "^0.8.10" + debug "^4.4.0" + xml-crypto "^6.1.2" + xml-encryption "^3.1.0" + xml2js "^0.6.2" + xmlbuilder "^15.1.1" + xpath "^0.0.34" + "@otplib/core@13.5.0": version "13.5.0" resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.5.0.tgz#629a7fef5b6394f34ff55213b936223b368796a4" @@ -151,16 +174,232 @@ dependencies: "@otplib/core" "13.5.0" +"@oxc-project/types@=0.146.0": + version "0.146.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.146.0.tgz#d57a2591abbf1f6e50981b07ee24ab269530d87a" + integrity sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA== + +"@rolldown/binding-android-arm-eabi@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz#165b80910de7cd33f772d5b7b045b259acb7420c" + integrity sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA== + +"@rolldown/binding-android-arm64@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz#5ed74d4b8fa56c68eb1aeb81d0d207a85b6de05c" + integrity sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig== + +"@rolldown/binding-darwin-arm64@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz#6f27c7060e58ca03061fa7d50f9dc409bc377fe1" + integrity sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww== + +"@rolldown/binding-darwin-x64@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz#74ab897d134ede4072fdc6108f00193e6167ee28" + integrity sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A== + +"@rolldown/binding-freebsd-x64@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz#7d337f9ae4b739674e1938747118ab0676c8ef8a" + integrity sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw== + +"@rolldown/binding-linux-arm-gnueabihf@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz#a8195dc1091ade0f912b0613ef6500941e5615f4" + integrity sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg== + +"@rolldown/binding-linux-arm64-gnu@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz#40fbbb97072b1acaa3e0e8fe9774fe524e860bba" + integrity sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA== + +"@rolldown/binding-linux-arm64-musl@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz#3cfe8b0f7c13de29dace9a9fc6c03081164176ab" + integrity sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA== + +"@rolldown/binding-linux-ppc64-gnu@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz#3bd890d96ae29f93718aa142ed0982f2ee2978ad" + integrity sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA== + +"@rolldown/binding-linux-s390x-gnu@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz#0959a0d5af22741d5787918e27aef70dc8602804" + integrity sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ== + +"@rolldown/binding-linux-x64-gnu@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz#7baa94e826328ac6f457d9e1abacffa0353e8d22" + integrity sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ== + +"@rolldown/binding-linux-x64-musl@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz#5f37597eaf0e22313d1e3d4be7d1be1fd332904e" + integrity sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA== + +"@rolldown/binding-openharmony-arm64@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz#d096567af3f738cbe6aa858ab0a01aae9f357ef4" + integrity sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw== + +"@rolldown/binding-win32-arm64-msvc@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz#d53b911aa4e6b547b789c07747fabab2ac8c237d" + integrity sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw== + +"@rolldown/binding-win32-x64-msvc@1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz#7bbd08cfda6a98de9b756472c772a36ebb7229bc" + integrity sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw== + +"@rolldown/pluginutils@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be" + integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== + "@scure/base@^2.2.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.3.0.tgz#fc7acb5b7084c53e4e2537d83fa72cddac7143fd" integrity sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA== +"@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/debug@^4.1.12": + version "4.1.13" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.13.tgz#22d1cc9d542d3593caea764f974306ab36286ee7" + integrity sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw== + dependencies: + "@types/ms" "*" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@^1.0.0": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/ms@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + +"@types/node@*": + version "26.2.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.2.0.tgz#5a4875a862fda8fdc57de8faa579bb81ecba1685" + integrity sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg== + dependencies: + undici-types "~8.3.0" + +"@types/qs@^6.9.18": + version "6.15.1" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.15.1.tgz#8606884272c63f0db96986bd3548650d8a9388bf" + integrity sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw== + +"@types/xml-encryption@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@types/xml-encryption/-/xml-encryption-1.2.4.tgz#0eceea58c82a89f62c0a2dc383a6461dfc2fe1ba" + integrity sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q== + dependencies: + "@types/node" "*" + +"@types/xml2js@^0.4.14": + version "0.4.14" + resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.14.tgz#5d462a2a7330345e2309c6b549a183a376de8f9a" + integrity sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ== + dependencies: + "@types/node" "*" + +"@vitest/expect@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.11.tgz#5f580d1f9cdbba314dbf23b2d911f8eb23878f5f" + integrity sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw== + dependencies: + "@standard-schema/spec" "^1.1.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.1.11" + "@vitest/utils" "4.1.11" + chai "^6.2.2" + tinyrainbow "^3.1.0" + +"@vitest/mocker@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.11.tgz#8e2906361bc5dfa271757a858ae80643118fcbb4" + integrity sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ== + dependencies: + "@vitest/spy" "4.1.11" + estree-walker "^3.0.3" + magic-string "^0.30.21" + +"@vitest/pretty-format@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.11.tgz#8b28eb8240771d6ea970e33beaeb41384b51868e" + integrity sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw== + dependencies: + tinyrainbow "^3.1.0" + +"@vitest/runner@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.11.tgz#bfbad98c8d6c3f1fb4df12056ad569821ff77f21" + integrity sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw== + dependencies: + "@vitest/utils" "4.1.11" + pathe "^2.0.3" + +"@vitest/snapshot@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.11.tgz#df461eb165924a3155986dde68e13360f53f3d4c" + integrity sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog== + dependencies: + "@vitest/pretty-format" "4.1.11" + "@vitest/utils" "4.1.11" + magic-string "^0.30.21" + pathe "^2.0.3" + +"@vitest/spy@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.11.tgz#0add45cae953afed9c88f98e2f6fc9164558c32a" + integrity sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA== + +"@vitest/utils@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.11.tgz#9b27a4293b827942b223539bfab1bd9f7eada31b" + integrity sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ== + dependencies: + "@vitest/pretty-format" "4.1.11" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" + +"@xmldom/is-dom-node@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz#83b9f3e1260fb008061c6fa787b93a00f9be0629" + integrity sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q== + +"@xmldom/xmldom@^0.8.10", "@xmldom/xmldom@^0.8.5": + version "0.8.15" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.15.tgz#71ebf80729e4e95221d519ac9b1e1eaea7784907" + integrity sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA== + abbrev@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-4.0.0.tgz#ec933f0e27b6cd60e89b5c6b2a304af42209bb05" @@ -255,6 +494,11 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + ast-types@^0.13.4: version "0.13.4" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.4.tgz#ee0d77b343263965ecc3fb62da16e7222b2b6782" @@ -468,6 +712,11 @@ camelcase@^5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== +chai@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + chalk@5.6.2: version "5.6.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" @@ -596,6 +845,11 @@ content-type@^2.0.0, content-type@^2.1.0: resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.1.0.tgz#d9389c43c0a8cf6a355db464d21e07092a40493a" integrity sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag== +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + cookie-signature@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" @@ -686,7 +940,7 @@ depd@^2.0.0, depd@~2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -detect-libc@^2.0.0: +detect-libc@^2.0.0, detect-libc@^2.0.3: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -756,6 +1010,11 @@ es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== +es-module-lexer@^2.0.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz#311fa4f40168c1975c505477c51b23234d41ad55" + integrity sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw== + es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" @@ -804,6 +1063,13 @@ estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -836,6 +1102,11 @@ expand-template@^2.0.3: resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== +expect-type@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== + exponential-backoff@^3.1.1: version "3.1.3" resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6" @@ -963,7 +1234,7 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fsevents@~2.3.2: +fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -1303,6 +1574,87 @@ lazystream@^1.0.0: dependencies: readable-stream "^2.0.5" +ldapts@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/ldapts/-/ldapts-9.0.0.tgz#45c104bc6f7e8c836c15340ab0aa83af6567cd97" + integrity sha512-OaaoYBSuan7g0Nm2e1wsRl+9xol41zY+pDlRRSsBq36iKCd2tG/K8WifevNRDjyEfhz4bvkxKdDvJ6xHXwj7+Q== + dependencies: + strict-event-emitter-types "2.0.0" + +lightningcss-android-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907" + integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== + +lightningcss-darwin-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6" + integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== + +lightningcss-darwin-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361" + integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== + +lightningcss-freebsd-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b" + integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== + +lightningcss-linux-arm-gnueabihf@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de" + integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== + +lightningcss-linux-arm64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa" + integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== + +lightningcss-linux-arm64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6" + integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== + +lightningcss-linux-x64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55" + integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== + +lightningcss-linux-x64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf" + integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== + +lightningcss-win32-arm64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d" + integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== + +lightningcss-win32-x64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz#e343ae152eed3609dc6e11949d1a3bf39a1c946f" + integrity sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== + +lightningcss@^1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.33.0.tgz#c08867d71a79385c6e190214fd72fef3e5f95f0b" + integrity sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.33.0" + lightningcss-darwin-arm64 "1.33.0" + lightningcss-darwin-x64 "1.33.0" + lightningcss-freebsd-x64 "1.33.0" + lightningcss-linux-arm-gnueabihf "1.33.0" + lightningcss-linux-arm64-gnu "1.33.0" + lightningcss-linux-arm64-musl "1.33.0" + lightningcss-linux-x64-gnu "1.33.0" + lightningcss-linux-x64-musl "1.33.0" + lightningcss-win32-arm64-msvc "1.33.0" + lightningcss-win32-x64-msvc "1.33.0" + liquidjs@10.29.0: version "10.29.0" resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.29.0.tgz#ee061890ccf0da26be377aad4f02bd32da0140d8" @@ -1390,6 +1742,13 @@ lru.min@^1.1.0, lru.min@^1.1.4: resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b" integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA== +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -1491,6 +1850,11 @@ named-placeholders@^1.1.6: dependencies: lru.min "^1.1.0" +nanoid@^3.3.17: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== + napi-build-utils@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e" @@ -1595,6 +1959,11 @@ objection@3.1.5: ajv-formats "^2.1.1" db-errors "^0.2.3" +obug@^2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.4.tgz#9090d8a548a522517915d2aa6aae907197ac6cf8" + integrity sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA== + on-finished@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -1727,6 +2096,11 @@ path@^0.12.7: process "^0.11.1" util "^0.10.3" +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + pg-cloudflare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497" @@ -1788,12 +2162,17 @@ pgpass@1.0.5: dependencies: split2 "^4.1.0" +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1: version "2.3.2" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== -picomatch@^4.0.4: +picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== @@ -1811,6 +2190,15 @@ pkg-conf@^2.1.0: find-up "^2.0.0" load-json-file "^4.0.0" +postcss@^8.5.26: + version "8.5.26" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620" + integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ== + dependencies: + nanoid "^3.3.17" + picocolors "^1.1.1" + source-map-js "^1.2.1" + postgres-array@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" @@ -2038,6 +2426,30 @@ resolve@^1.20.0: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +rolldown@~1.2.4: + version "1.2.5" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.2.5.tgz#1f504a7d05260a769e617d950410bbb051498c50" + integrity sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA== + dependencies: + "@oxc-project/types" "=0.146.0" + "@rolldown/pluginutils" "^1.0.0" + optionalDependencies: + "@rolldown/binding-android-arm-eabi" "1.2.5" + "@rolldown/binding-android-arm64" "1.2.5" + "@rolldown/binding-darwin-arm64" "1.2.5" + "@rolldown/binding-darwin-x64" "1.2.5" + "@rolldown/binding-freebsd-x64" "1.2.5" + "@rolldown/binding-linux-arm-gnueabihf" "1.2.5" + "@rolldown/binding-linux-arm64-gnu" "1.2.5" + "@rolldown/binding-linux-arm64-musl" "1.2.5" + "@rolldown/binding-linux-ppc64-gnu" "1.2.5" + "@rolldown/binding-linux-s390x-gnu" "1.2.5" + "@rolldown/binding-linux-x64-gnu" "1.2.5" + "@rolldown/binding-linux-x64-musl" "1.2.5" + "@rolldown/binding-openharmony-arm64" "1.2.5" + "@rolldown/binding-win32-arm64-msvc" "1.2.5" + "@rolldown/binding-win32-x64-msvc" "1.2.5" + router@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" @@ -2064,6 +2476,11 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +sax@>=0.6.0: + version "1.6.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.1.tgz#4c23cf608c0b693ab54b4b5888e92cfe977b9843" + integrity sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q== + semver@^7.3.5, semver@^7.5.3, semver@^7.5.4: version "7.8.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" @@ -2146,6 +2563,11 @@ side-channel@^1.1.1: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + signale@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/signale/-/signale-1.4.0.tgz#c4be58302fb0262ac00fc3d886a7c113759042f1" @@ -2198,6 +2620,11 @@ socks@^2.8.3: ip-address "^10.1.1" smart-buffer "^4.2.0" +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -2225,11 +2652,21 @@ sqlite3@^6.0.1: optionalDependencies: node-gyp "12.x" +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== +std-env@^4.0.0-rc.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" + integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== + streamsearch@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" @@ -2244,6 +2681,11 @@ streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0: fast-fifo "^1.3.2" text-decoder "^1.1.0" +strict-event-emitter-types@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz#05e15549cb4da1694478a53543e4e2f4abcf277f" + integrity sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA== + string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -2376,7 +2818,17 @@ tildify@2.0.0: resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a" integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw== -tinyglobby@^0.2.12: +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^1.0.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.3.0.tgz#aacc1dbb1d4e93e6ad8dd64944e09f9ad147a474" + integrity sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ== + +tinyglobby@^0.2.12, tinyglobby@^0.2.15, tinyglobby@^0.2.17: version "0.2.17" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== @@ -2384,6 +2836,11 @@ tinyglobby@^0.2.12: fdir "^6.5.0" picomatch "^4.0.4" +tinyrainbow@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.1.tgz#c0168387d3d8d70b6b3c2c0936de5fee738cea20" + integrity sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw== + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -2427,6 +2884,11 @@ undefsafe@^2.0.5: resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== +undici-types@~8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" + integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== + undici@^6.25.0: version "6.28.0" resolved "https://registry.yarnpkg.com/undici/-/undici-6.28.0.tgz#9f0e385744fef5021d6596c5bccd783f61193c1c" @@ -2459,6 +2921,45 @@ vary@^1.1.2, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +"vite@^6.0.0 || ^7.0.0 || ^8.0.0": + version "8.2.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.2.2.tgz#399aefad3656145145be110d137a07ea5bb55014" + integrity sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q== + dependencies: + lightningcss "^1.33.0" + picomatch "^4.0.5" + postcss "^8.5.26" + rolldown "~1.2.4" + tinyglobby "^0.2.17" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^4.1.8: + version "4.1.11" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.11.tgz#1653c1521ae917f960d9b21877797c47dfd8bf21" + integrity sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw== + dependencies: + "@vitest/expect" "4.1.11" + "@vitest/mocker" "4.1.11" + "@vitest/pretty-format" "4.1.11" + "@vitest/runner" "4.1.11" + "@vitest/snapshot" "4.1.11" + "@vitest/spy" "4.1.11" + "@vitest/utils" "4.1.11" + es-module-lexer "^2.0.0" + expect-type "^1.3.0" + magic-string "^0.30.21" + obug "^2.1.1" + pathe "^2.0.3" + picomatch "^4.0.3" + std-env "^4.0.0-rc.1" + tinybench "^2.9.0" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.1.0" + vite "^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running "^2.3.0" + which-module@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" @@ -2471,6 +2972,14 @@ which@^6.0.0: dependencies: isexe "^4.0.0" +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" @@ -2485,6 +2994,57 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +xml-crypto@^6.1.2: + version "6.1.2" + resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-6.1.2.tgz#ed93e87d9538f92ad1ad2db442e9ec586723d07d" + integrity sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w== + dependencies: + "@xmldom/is-dom-node" "^1.0.1" + "@xmldom/xmldom" "^0.8.10" + xpath "^0.0.33" + +xml-encryption@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-3.1.0.tgz#f3e91c4508aafd0c21892151ded91013dcd51ca2" + integrity sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q== + dependencies: + "@xmldom/xmldom" "^0.8.5" + escape-html "^1.0.3" + xpath "0.0.32" + +xml2js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" + integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@^15.1.1: + version "15.1.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" + integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + +xpath@0.0.32: + version "0.0.32" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.32.tgz#1b73d3351af736e17ec078d6da4b8175405c48af" + integrity sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw== + +xpath@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.33.tgz#5136b6094227c5df92002e7c3a13516a5074eb07" + integrity sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA== + +xpath@^0.0.34: + version "0.0.34" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.34.tgz#a769255e8816e0938e1e0005f2baa7279be8be12" + integrity sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA== + xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" diff --git a/docker/auth-dev/ldifs/01-tree.ldif b/docker/auth-dev/ldifs/01-tree.ldif new file mode 100644 index 0000000000..e5e5f6f725 --- /dev/null +++ b/docker/auth-dev/ldifs/01-tree.ldif @@ -0,0 +1,36 @@ +dn: dc=example,dc=org +objectClass: dcObject +objectClass: organization +dc: example +o: Example Org + +dn: ou=users,dc=example,dc=org +objectClass: organizationalUnit +ou: users + +dn: ou=groups,dc=example,dc=org +objectClass: organizationalUnit +ou: groups + +dn: cn=alice,ou=users,dc=example,dc=org +objectClass: inetOrgPerson +cn: alice +sn: Anderson +givenName: Alice +uid: alice +mail: alice@example.org +userPassword: alicepass + +dn: cn=bob,ou=users,dc=example,dc=org +objectClass: inetOrgPerson +cn: bob +sn: Baker +givenName: Bob +uid: bob +mail: bob@example.org +userPassword: bobpass + +dn: cn=npm-admins,ou=groups,dc=example,dc=org +objectClass: groupOfNames +cn: npm-admins +member: cn=alice,ou=users,dc=example,dc=org diff --git a/docker/auth-dev/nginx.conf b/docker/auth-dev/nginx.conf new file mode 100644 index 0000000000..98783b33c0 --- /dev/null +++ b/docker/auth-dev/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 8080; + root /usr/share/nginx/html; + + location /api/ { + # nginx resolves this once at startup and caches the address. If you + # recreate the backend container it will get a new address, so restart + # this one too: docker compose restart npmui + proxy_pass http://npmbackend:3000/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/docker/docker-compose.auth-dev.yml b/docker/docker-compose.auth-dev.yml new file mode 100644 index 0000000000..32f9b7428b --- /dev/null +++ b/docker/docker-compose.auth-dev.yml @@ -0,0 +1,151 @@ +# WARNING: This is a DEVELOPMENT compose file, it should not be used for production. +# +# A standalone stack for trying out the authentication providers, with one +# identity provider of each kind already wired up. Unlike docker-compose.dev.yml +# it runs the backend straight from the repo and serves a pre-built frontend, so +# it starts in well under a minute. +# +# cd backend && yarn install +# cd frontend && yarn install && yarn locale-compile && yarn build +# cd docker && docker compose -f docker-compose.auth-dev.yml up -d +# +# Then open http://localhost:8080 and sign in with any of: +# +# local admin@example.com / changeme123 +# LDAP alice / alicepass (in npm-admins, so becomes an admin) +# bob / bobpass (standard user) +# SAML user1 / user1pass (in group1, so becomes an admin) +# OAuth no prompt; signs in as alice.sso@example.org +# +# OAuth additionally needs this line in your hosts file, because the browser and +# the backend both have to reach the issuer under the same name: +# +# 127.0.0.1 oidc.local +services: + npmbackend: + image: docker.io/library/node:22 + container_name: authtest.backend + working_dir: /app + command: sh -c "node index.js" + volumes: + - ../backend:/app + - npmdata:/data + networks: + default: + environment: + NODE_CONFIG_DIR: "/nonexistent" + DB_SQLITE_FILE: "/data/database.sqlite" + DEBUG: "true" + IP_RANGES_FETCH_ENABLED: "false" + INITIAL_ADMIN_EMAIL: "admin@example.com" + INITIAL_ADMIN_PASSWORD: "changeme123" + + # The URL your browser uses. Redirect URIs are built from this. + AUTH_PUBLIC_URL: "http://localhost:8080" + + # --- provider 1: LDAP --------------------------------------------- + AUTH_LDAP_ENABLED: "true" + AUTH_LDAP_NAME: "Example Directory" + AUTH_LDAP_URL: "ldap://authldap:1389" + AUTH_LDAP_BIND_DN: "cn=admin,dc=example,dc=org" + AUTH_LDAP_BIND_PASSWORD: "adminpassword" + AUTH_LDAP_BASE_DN: "dc=example,dc=org" + AUTH_LDAP_USER_FILTER: "(|(uid={{username}})(mail={{username}}))" + # This OpenLDAP has no memberOf overlay, so search groups the other way round + AUTH_LDAP_GROUP_FILTER: "(&(objectClass=groupOfNames)(member={{dn}}))" + AUTH_LDAP_ADMIN_GROUP: "cn=npm-admins,ou=groups,dc=example,dc=org" + AUTH_LDAP_AUTO_CREATE_USER: "true" + AUTH_LDAP_LINK_BY_EMAIL: "true" + # Directory sync: pre-creates accounts and keeps roles current without a login + AUTH_LDAP_SYNC_ENABLED: "true" + AUTH_LDAP_SYNC_INTERVAL: "60" + AUTH_LDAP_SYNC_FILTER: "(objectClass=inetOrgPerson)" + AUTH_LDAP_SYNC_DISABLE_MISSING: "true" + + # --- provider 2: SAML --------------------------------------------- + AUTH_SAML_ENABLED: "true" + AUTH_SAML_NAME: "Test SAML IdP" + AUTH_SAML_ENTRY_POINT: "http://localhost:8090/simplesaml/saml2/idp/SSOService.php" + AUTH_SAML_ISSUER: "nginx-proxy-manager" + AUTH_SAML_EMAIL_ATTRIBUTE: "email" + AUTH_SAML_GROUP_ATTRIBUTE: "eduPersonAffiliation" + AUTH_SAML_ADMIN_GROUP: "group1" + AUTH_SAML_AUTO_CREATE_USER: "true" + AUTH_SAML_IDP_CERT: "MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==" + + # --- provider 3: OAuth / OIDC ------------------------------------- + # Needs "127.0.0.1 oidc.local" in your hosts file, because the browser + # and the backend must both reach the issuer under the same name. + AUTH_OAUTH_ENABLED: "true" + AUTH_OAUTH_NAME: "Mock SSO" + AUTH_OAUTH_ISSUER_URL: "http://oidc.local:9090/default" + AUTH_OAUTH_CLIENT_ID: "npm" + AUTH_OAUTH_CLIENT_SECRET: "npmsecret" + AUTH_OAUTH_GROUP_CLAIM: "groups" + AUTH_OAUTH_ADMIN_GROUP: "npm-admins" + AUTH_OAUTH_AUTO_CREATE_USER: "true" + + npmui: + image: docker.io/library/nginx:alpine + container_name: authtest.ui + ports: + - "8080:8080" + volumes: + - ../frontend/dist:/usr/share/nginx/html:ro + - ./auth-dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + default: + depends_on: + - npmbackend + + authldap: + image: docker.io/bitnamilegacy/openldap:latest + container_name: authtest.ldap + user: "0" + networks: + default: + environment: + LDAP_ROOT: "dc=example,dc=org" + LDAP_ADMIN_USERNAME: "admin" + LDAP_ADMIN_PASSWORD: "adminpassword" + # The seeded users need a mail attribute, which the built in seeding + # doesn't provide, so load a tree of our own instead. + LDAP_CUSTOM_LDIF_DIR: "/ldifs" + volumes: + - ./auth-dev/ldifs:/ldifs:ro + + authsaml: + image: docker.io/kristophjunge/test-saml-idp:1.15 + container_name: authtest.saml + ports: + - "8090:8080" + networks: + default: + environment: + SIMPLESAMLPHP_SP_ENTITY_ID: "nginx-proxy-manager" + SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE: "http://localhost:8080/api/auth/2/callback" + + authoidc: + image: ghcr.io/navikt/mock-oauth2-server:2.1.10 + container_name: authtest.oidc + # Same port inside and out, so the issuer string matches either way + ports: + - "9090:9090" + networks: + default: + aliases: + - oidc.local + environment: + SERVER_PORT: "9090" + JSON_CONFIG: >- + {"interactiveLogin":false,"tokenCallbacks":[{"issuerId":"default","tokenExpiry":600, + "requestMappings":[{"requestParam":"client_id","match":"npm","claims":{ + "sub":"alice-sso","email":"alice.sso@example.org","name":"Alice From SSO", + "preferred_username":"alicesso","groups":["npm-admins","staff"]}}]}]} + +volumes: + npmdata: + +networks: + default: + name: authtest diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b6f6f3f660..1d0aaf2d3f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -82,6 +82,7 @@ export default defineConfig({ { text: "Screenshots", link: "/screenshots/" }, { text: "Setup Instructions", link: "/setup/" }, { text: "Advanced Configuration", link: "/advanced-config/" }, + { text: "Authentication Providers", link: "/authentication/" }, { text: "Upgrading", link: "/upgrading/" }, { text: "Frequently Asked Questions", link: "/faq/" }, { text: "Certbot", link: "/certbot/" }, diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md index a93a1a4d09..e1efaf0fdb 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -237,6 +237,119 @@ Setting these environment variables will create the default user on startup, ski INITIAL_ADMIN_PASSWORD: mypassword1 ``` +## External Authentication + +LDAP, SAML and OAuth/OpenID Connect providers can be added in the admin +interface under **Users → Authentication Providers**, or configured entirely +through environment variables, which is usually what you want for a container +deployed from a compose file. + +Providers configured this way are recreated from the environment on every start, +appear in the interface as read only, and disappear when their variables are +removed. At most one provider of each type can be configured this way; add more +in the interface if you need them. + +See [Authentication Providers](/authentication/) for what these settings mean +and how the pieces fit together. + +```yml + environment: + AUTH_LDAP_ENABLED: "true" + AUTH_LDAP_NAME: "Company Directory" + AUTH_LDAP_URL: "ldaps://ldap.example.com:636" + AUTH_LDAP_BIND_DN: "cn=readonly,dc=example,dc=com" + AUTH_LDAP_BIND_PASSWORD: "secret" + AUTH_LDAP_BASE_DN: "dc=example,dc=com" + AUTH_LDAP_ADMIN_GROUP: "cn=npm-admins,ou=groups,dc=example,dc=com" + AUTH_LDAP_AUTO_CREATE_USER: "true" +``` + +Any secret below can instead be supplied as a docker secret by appending +`__FILE` to the variable name and pointing it at a file, for example +`AUTH_LDAP_BIND_PASSWORD__FILE: /run/secrets/ldap_password`. + +### Global + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `AUTH_DISABLE_LOCAL` | `false` | Turn off email and password sign in. Overrides the setting stored in the interface, in both directions, so it is also the way back in if you lock yourself out. | +| `AUTH_PUBLIC_URL` | derived from the request | The externally reachable base URL, used to build the redirect and callback URIs. Set this if the automatic value is wrong, for example behind another proxy. | + +### Common to every provider + +`` is one of `LDAP`, `SAML` or `OAUTH`. + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `AUTH__ENABLED` | `false` | Whether to configure this provider at all | +| `AUTH__NAME` | the type | Display name shown on the login screen | +| `AUTH__AUTO_CREATE_USER` | `false` | Create a local account on first sign in. With this off, only people already linked to the provider can sign in. | +| `AUTH__LINK_BY_EMAIL` | `false` | Attach a first-time sign in to an existing account holding the same email address. Only enable this for a provider you trust to prove the address belongs to whoever signed in; for OAuth the provider must also return `email_verified: true`. | +| `AUTH__ADMIN_GROUP` | | Group or claim value that grants the admin role. Applied on every sign in, and revoked when somebody leaves the group. | +| `AUTH__DEFAULT_ROLES` | | Comma separated roles given to newly created accounts | + +### LDAP + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `AUTH_LDAP_URL` | | `ldap://host:389` or `ldaps://host:636` | +| `AUTH_LDAP_BIND_DN` | | Read-only service account. Leave blank to search anonymously. | +| `AUTH_LDAP_BIND_PASSWORD` | | | +| `AUTH_LDAP_BASE_DN` | | Where to search from, e.g. `dc=example,dc=com` | +| `AUTH_LDAP_USER_FILTER` | (|(uid={{username}})(mail={{username}})) | {{username}} is replaced with whatever was typed into the login form | +| `AUTH_LDAP_LOGIN_ATTRIBUTES` | | Comma separated attributes accepted at the login prompt, e.g. `uid,mail,sAMAccountName`. Ignored when a user filter is set. | +| `AUTH_LDAP_EMAIL_ATTRIBUTE` | `mail` | Required. Somebody with no email address in the directory cannot sign in. | +| `AUTH_LDAP_NAME_ATTRIBUTE` | `cn` | | +| `AUTH_LDAP_NICKNAME_ATTRIBUTE` | `givenName` | | +| `AUTH_LDAP_GROUP_ATTRIBUTE` | `memberOf` | Read from the user entry, for Active Directory or OpenLDAP with the memberof overlay | +| `AUTH_LDAP_GROUP_BASE_DN` | the base DN | Where to search for groups | +| `AUTH_LDAP_GROUP_FILTER` | | Used when the user entry carries no groups, e.g. (&(objectClass=groupOfNames)(member={{dn}})). {{dn}} and {{username}} are substituted. | +| `AUTH_LDAP_GROUP_NAME_ATTRIBUTE` | `dn` | | +| `AUTH_LDAP_START_TLS` | `false` | Upgrade a plain connection with StartTLS | +| `AUTH_LDAP_TLS_REJECT_UNAUTHORIZED` | `true` | Verify the server's certificate. Only turn this off for a self-signed certificate you trust. | +| `AUTH_LDAP_TIMEOUT` | `10000` | Milliseconds | +| `AUTH_LDAP_PAGE_SIZE` | `500` | Entries fetched per page, so directories past the server's result cap enumerate fully | +| `AUTH_LDAP_SYNC_ENABLED` | `false` | Walk the directory on a schedule, creating accounts before anyone signs in | +| `AUTH_LDAP_SYNC_INTERVAL` | `60` | Minutes between runs; five is the shortest allowed | +| `AUTH_LDAP_SYNC_FILTER` | `(objectClass=person)` | Which directory entries sync considers | +| `AUTH_LDAP_SYNC_GROUP` | | Only sync members of this group | +| `AUTH_LDAP_SYNC_DISABLE_MISSING` | `false` | Disable accounts whose directory entry has gone away. The last administrator is never disabled, and a run returning nothing disables nobody. | + +### SAML + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `AUTH_SAML_ENTRY_POINT` | | The identity provider's sign-in URL | +| `AUTH_SAML_ISSUER` | `nginx-proxy-manager` | The entity ID this instance advertises | +| `AUTH_SAML_IDP_CERT` | | The identity provider's signing certificate | +| `AUTH_SAML_SP_PRIVATE_KEY` | | Key used to sign requests, if your IdP requires it | +| `AUTH_SAML_SIGNATURE_ALGORITHM` | `sha256` | | +| `AUTH_SAML_WANT_ASSERTIONS_SIGNED` | `true` | | +| `AUTH_SAML_WANT_AUTHN_RESPONSE_SIGNED` | `false` | Turn on if your IdP signs the response as well as the assertion | +| `AUTH_SAML_EMAIL_ATTRIBUTE` | auto-detected | Common claim URIs and short names are tried automatically | +| `AUTH_SAML_NAME_ATTRIBUTE` | auto-detected | | +| `AUTH_SAML_NICKNAME_ATTRIBUTE` | auto-detected | | +| `AUTH_SAML_GROUP_ATTRIBUTE` | auto-detected | | +| `AUTH_SAML_IDENTIFIER_ATTRIBUTE` | | Attribute holding a lasting id for each person. Left blank the `NameID` is used, or the email address when the IdP issues a transient `NameID`. | + +### OAuth and OpenID Connect + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `AUTH_OAUTH_ISSUER_URL` | | Endpoints are discovered from here. Leave blank to configure them by hand. | +| `AUTH_OAUTH_AUTHORIZATION_URL` | discovered | | +| `AUTH_OAUTH_TOKEN_URL` | discovered | | +| `AUTH_OAUTH_USERINFO_URL` | discovered | | +| `AUTH_OAUTH_JWKS_URL` | discovered | Needed to verify ID tokens when there is no userinfo endpoint | +| `AUTH_OAUTH_CLIENT_ID` | | | +| `AUTH_OAUTH_CLIENT_SECRET` | | | +| `AUTH_OAUTH_SCOPES` | `openid email profile` | | +| `AUTH_OAUTH_EMAIL_CLAIM` | `email` | | +| `AUTH_OAUTH_NAME_CLAIM` | `name` | | +| `AUTH_OAUTH_NICKNAME_CLAIM` | `preferred_username` | | +| `AUTH_OAUTH_GROUP_CLAIM` | `groups` | | +| `AUTH_OAUTH_USE_BASIC_AUTH` | `false` | Send credentials in the Authorization header, for providers requiring `client_secret_basic` | + ## Disable Nginx Resolver On startup, we generate a resolvers directive for Nginx unless this is defined: diff --git a/docs/src/authentication/index.md b/docs/src/authentication/index.md new file mode 100644 index 0000000000..0acdc324b7 --- /dev/null +++ b/docs/src/authentication/index.md @@ -0,0 +1,464 @@ +--- +outline: deep +--- + +# Authentication Providers + +Out of the box, people sign in to Nginx Proxy Manager with an email address and +password stored in its own database. You can additionally connect one or more +**authentication providers** so that they sign in with credentials you already +manage elsewhere. + +Three kinds of provider are supported: + +| Type | Protocol | How it looks on the login screen | +| ---- | -------- | -------------------------------- | +| **LDAP** | LDAP / LDAPS, optionally with StartTLS | The usual username and password form | +| **SAML** | SAML 2.0 | A "Continue with …" button | +| **OAuth** | OAuth 2.0 / OpenID Connect | A "Continue with …" button | + +LDAP deliberately reuses the normal login form: directory users type their +username (or email) and password just like everyone else, and Nginx Proxy +Manager works out which directory to check. + +Providers can be added in two ways, and both can be used at once: + +- In the admin interface, under **Users → Authentication Providers** +- With environment variables, which is usually what you want for a container + you deploy from a compose file + +## Concepts + +### Matching people to accounts + +Every person still has a local user record here — that is what owns their proxy +hosts and permissions. When somebody signs in through a provider, they are +matched to that record in this order: + +1. An account already linked to that identity at that provider (by LDAP DN, + OIDC `sub`, or SAML `NameID`) +2. An existing account with the same email address — but only if the provider + has **Adopt existing accounts by email address** switched on +3. A brand new account — but only if the provider has **Create users on first + sign in** switched on + +If none of those apply the sign in is refused, and an administrator has to +create the user first. Both switches are off by default: having an account in +your directory is not by itself enough to get into Nginx Proxy Manager. + +::: warning Adopting accounts by email address +Matching on an email address means trusting the provider to have proved the +address belongs to whoever just signed in. A company directory does. A public +OAuth provider that lets anyone type their own address does not, and turning +this on for one would let somebody claim any account here, administrators +included, by signing up with the matching address. + +Leave it off unless you trust the provider that far. For OAuth it is not enough +on its own: the provider must also return `email_verified: true`, and a sign in +is refused if it does not. + +When it is off and somebody signs in whose address already belongs to an +account here, the sign in is refused rather than silently creating a duplicate. +Link the two deliberately instead: have that person sign in once with the +address changed, or remove the stale local account first. +::: + +### Roles + +By default, external providers only prove *who* someone is. Their role and +permissions stay under your control in the Users screen. + +If you would rather drive administrator access from your directory, set an +**Administrator group** on the provider. On every sign in, anyone whose groups +contain that value is given the `admin` role, and anyone who no longer has it +loses the role again. Leave the field blank to manage roles here instead. + +Roles are only recalculated when the group membership could actually be read. +If the directory is reachable enough to check a password but the group lookup +itself fails, existing roles are left exactly as they are and a warning is +logged — an outage never silently demotes your administrators. + +### Turning off password sign in + +Once at least one provider is enabled you can switch off **Allow email and +password sign in**, which hides the password form entirely. + +Two things have to be true before this is allowed: at least one provider is +enabled, and at least one administrator has actually signed in through one. A +provider that nobody has used yet is not a way in, so the setting is refused +until it is proven. + +Three more guards apply afterwards: + +- Removing the last provider turns password sign in back on, rather than + leaving an instance nobody can reach. +- An administrator whose only credential is a password does not count as a + fallback while password sign in is off, so the guards that protect "the last + administrator" will not delete or disable the one who can still get in. +- If it is `AUTH_DISABLE_LOCAL` holding the door shut rather than the stored + setting, that cannot be overridden from inside — set + `AUTH_DISABLE_LOCAL=false` on the container and restart. The container log + says so explicitly when it happens. + +If your identity provider goes down while password sign in is off, nobody can +sign in until it comes back or you restart with `AUTH_DISABLE_LOCAL=false`. +That is the trade being made by turning the password form off at all. + +### Two-factor authentication + +If someone has 2FA enabled on their local account, they are still asked for +their code after signing in through a provider. Most identity providers can +enforce MFA themselves, in which case you probably do not want to enable it +here as well. + +### Directory sync + +Sync is off by default and configured per provider, so on a small or +memory-constrained box you can leave it off entirely and rely on sign-in time +provisioning alone. Nothing is scheduled and no timer runs when it is off, and +the directory is never mirrored locally: the only rows written are a user and a +link for the people who actually sign in. Group-to-role mapping is applied at +that moment either way, so **Administrator group** works exactly the same with +sync off. + +Signing in provisions one account at a time. That is usually enough, but it +means an administrator cannot grant permissions to somebody who has never +logged in, and a group change only takes effect the next time they do. + +Turning on **Sync this directory on a schedule** walks the whole directory +instead, creating the accounts it finds, refreshing their details and their +group-driven roles. LDAP only: SAML and OAuth have no way to enumerate users. + +Enabling sync means accounts are created for everyone the filter matches, +whether or not they have ever signed in, so narrow it with a filter or a group +if that is not what you want. + +**Disable accounts that leave the directory** is off by default. With it on, an +account whose entry has disappeared is disabled on the next run. Two guards +apply: an administrator is never disabled this way while nobody else could +still sign in, and a run that returns no entries at all disables nobody, so a +broken filter or an unreachable server cannot switch off an entire +organisation. + +Use the **Sync** button on the providers list to run one immediately rather than +waiting for the schedule. + +### Removing a provider + +Accounts created by a provider hold no password of their own, so removing the +provider has to say what becomes of them. Doing nothing would leave people with +an account nobody can sign in to and no explanation of why. + +Deleting a provider **in the interface** asks which you want: + +- **Keep them as local accounts.** The link is dropped and the accounts stay, + with their hosts, permissions and ownership intact. They hold no password yet, + so set one for them from the Users screen and they can sign in again. +- **Delete them.** The accounts go too. The dialog says up front how many would + actually be removed, because two kinds are always kept regardless: anyone who + can still sign in another way, such as with a password or through a second + provider, and any administrator whose removal would leave nobody able to sign + in and administer the instance. + +Removing a provider's **environment variables** converts its accounts to local +automatically. Nobody confirmed anything in that case, and a variable +disappearing from a compose file must not quietly take people's accounts with +it. The container log names the provider and says how many accounts were +converted. + +## LDAP + +Nginx Proxy Manager binds to your directory with an optional read-only service +account, searches for the person who is signing in, and then re-binds as that +person's DN to check their password. The password is never read out of the +directory. + +| Field | Notes | +| ----- | ----- | +| Server URL | `ldap://host:389` or `ldaps://host:636` | +| Base DN | Where to search from, e.g. `dc=example,dc=com` | +| Bind DN / password | A read-only service account. Leave blank to search anonymously. | +| User filter | {{username}} is replaced with whatever was typed into the login form | +| Email attribute | Required. Someone with no email address in the directory cannot sign in. | +| Group attribute | Read from the user entry, for directories with the `memberOf` overlay or Active Directory | +| Group filter | Used instead when the user entry carries no groups. {{dn}} and {{username}} are substituted. | + +The user filter defaults to (|(uid={{username}})(mail={{username}})), which +lets people sign in with either their username or their email address. Values +are escaped before substitution, so a filter cannot be broken out of. + +### Group membership + +Directories expose group membership in one of two ways, and both are supported: + +- **On the user** — Active Directory, FreeIPA and OpenLDAP with the `memberof` + overlay all set `memberOf` on the user entry. Leave the group attribute as + `memberOf` and you are done. +- **On the group** — plain OpenLDAP stores members on the group instead. Set a + group filter such as (&(objectClass=groupOfNames)(member={{dn}})) and the + directory is searched the other way around. + +### Example + +```yaml +environment: + AUTH_LDAP_ENABLED: "true" + AUTH_LDAP_NAME: "Company Directory" + AUTH_LDAP_URL: "ldaps://ldap.example.com:636" + AUTH_LDAP_BIND_DN: "cn=readonly,dc=example,dc=com" + AUTH_LDAP_BIND_PASSWORD: "secret" + AUTH_LDAP_BASE_DN: "dc=example,dc=com" + AUTH_LDAP_USER_FILTER: "(|(uid={{username}})(mail={{username}}))" + AUTH_LDAP_ADMIN_GROUP: "cn=npm-admins,ou=groups,dc=example,dc=com" + AUTH_LDAP_AUTO_CREATE_USER: "true" +``` + +## OAuth and OpenID Connect + +For any provider that supports OpenID Connect discovery — Authentik, Keycloak, +Authelia, Google, Entra ID, Okta and friends — you only need the issuer URL, +a client ID and a client secret. Everything else is discovered. + +The authorization code flow is used with PKCE, a single-use `state` and a +`nonce`. ID tokens are verified against the provider's JWKS, and the userinfo +endpoint is consulted as well, because providers differ in which claims they +put where. + +Register this redirect URI with your OAuth application: + +``` +https://your-npm-host/api/auth//callback +``` + +The exact URL is shown in the provider dialog once it has been saved. + +### Example + +```yaml +environment: + AUTH_OAUTH_ENABLED: "true" + AUTH_OAUTH_NAME: "Company SSO" + AUTH_OAUTH_ISSUER_URL: "https://sso.example.com/application/o/npm/" + AUTH_OAUTH_CLIENT_ID: "npm" + AUTH_OAUTH_CLIENT_SECRET: "secret" + AUTH_OAUTH_SCOPES: "openid email profile" + AUTH_OAUTH_GROUP_CLAIM: "groups" + AUTH_OAUTH_ADMIN_GROUP: "npm-admins" + AUTH_OAUTH_AUTO_CREATE_USER: "true" +``` + +If your provider does not offer discovery, leave the issuer URL blank and set +`AUTH_OAUTH_AUTHORIZATION_URL`, `AUTH_OAUTH_TOKEN_URL`, `AUTH_OAUTH_USERINFO_URL` +and, if it issues ID tokens, `AUTH_OAUTH_JWKS_URL` instead. + +## SAML + +Give your identity provider the service provider metadata, which is published +unauthenticated at: + +``` +https://your-npm-host/api/auth//metadata +``` + +Then configure the provider here with the IdP's sign-in URL and its signing +certificate. Assertions must be signed; responses may be signed as well if your +IdP does that. + +Attribute names vary a lot between identity providers, so the common claim URIs +and short names are tried automatically. Set the attribute fields explicitly if +your IdP uses something unusual. + +People are remembered by the assertion's `NameID`, unless your IdP issues a +transient one — a per-session pseudonym that would make everybody look like a +new person on every sign in. In that case the email address is used instead, or +whatever you name in **Identifier attribute** if you have something better, such +as an employee number. + +Sign in has to start here: every assertion must name the request it answers, and +each request can only be answered once. That rules out IdP-initiated sign in +(starting from a tile in your IdP's portal) — send people to the Nginx Proxy +Manager login page instead — and it means a captured assertion cannot be +replayed, since a signature stays valid for whoever presents it. + +### Example + +```yaml +environment: + AUTH_SAML_ENABLED: "true" + AUTH_SAML_NAME: "Company SSO" + AUTH_SAML_ENTRY_POINT: "https://sso.example.com/idp/sso" + AUTH_SAML_ISSUER: "nginx-proxy-manager" + AUTH_SAML_IDP_CERT: "MIIDXTCCAkWgAwIBAgIJ..." + AUTH_SAML_EMAIL_ATTRIBUTE: "email" + AUTH_SAML_GROUP_ATTRIBUTE: "groups" + AUTH_SAML_ADMIN_GROUP: "npm-admins" + AUTH_SAML_AUTO_CREATE_USER: "true" +``` + +## Protecting proxied sites + +Providers are not only for signing in to the admin interface. An **access list** +can accept the same directory accounts, so the people who already exist in your +directory can reach a proxied site without anyone maintaining a second list of +usernames and passwords for it. + +Open an access list, go to the **Providers** tab, and tick the directories it +should accept. Visitors are still prompted for a username and password exactly +as before; those credentials are checked against the directory instead of the +list's own entries. + +Only LDAP can be used here. SAML and OAuth sign people in by redirecting a +browser to the identity provider, which cannot happen for an arbitrary proxied +request — an image or an API call has nowhere to redirect to. + +### Restricting to a group + +Leave **Restrict to groups** empty and any account the directory authenticates +is allowed through. Fill it in, one group per line, and a user must belong to at +least one of them: + +``` +cn=vpn-users,ou=groups,dc=example,dc=com +cn=contractors,ou=groups,dc=example,dc=com +``` + +Usernames typed into the **Authorizations** tab are unaffected by this and keep +working, which is a convenient way to leave one break-glass account that does +not depend on the directory being reachable. + +### How it works, and what it costs + +A directory will not hand over password hashes, so its users cannot be written +into the htpasswd file nginx normally uses. Instead nginx asks the backend, per +request, whether a set of credentials is acceptable. + +To keep that affordable, a decision is cached for five minutes, so a page and +all of its images cost one directory lookup rather than dozens. A refused +attempt is cached for thirty seconds only, and every cached decision for a list +is discarded the moment that list is saved, so revoking somebody's access takes +effect immediately. + +Two headers are passed to the proxied application on success, which saves it +from asking the directory itself: + +| Header | Contents | +| ------ | -------- | +| `X-Auth-User` | the username that was supplied | +| `X-Auth-Email` | the email address on the directory entry | + +If the directory is unreachable the request is refused. A protected site does +not fall open because a server is down. + +## Environment variables + +Providers configured this way are recreated from the environment every time the +container starts, appear in the admin interface as read-only, and disappear +again when their variables are removed. At most one provider of each type can +be configured with environment variables; add more in the interface if you need +them. + +Any secret can also be supplied as a docker secret by appending `__FILE` to the +variable name and pointing it at a file, for example +`AUTH_LDAP_BIND_PASSWORD__FILE=/run/secrets/ldap_password`. + +### Common + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `AUTH_DISABLE_LOCAL` | `false` | Turns off email and password sign in, and overrides the setting in the interface | +| `AUTH_PUBLIC_URL` | derived from the request | The externally reachable base URL, used to build redirect URIs. Set this if the automatic value is wrong. | + +Each provider type accepts the same four options, with `` being `LDAP`, +`SAML` or `OAUTH`: + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `AUTH__ENABLED` | `false` | Whether to configure this provider at all | +| `AUTH__NAME` | the type | The display name shown on the login screen | +| `AUTH__AUTO_CREATE_USER` | `false` | Create a local user on first sign in | +| `AUTH__ADMIN_GROUP` | empty | Group or claim value that grants the admin role | +| `AUTH__DEFAULT_ROLES` | empty | Comma separated roles given to newly created users | + +### LDAP + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `AUTH_LDAP_URL` | | `ldap://host:389` or `ldaps://host:636` | +| `AUTH_LDAP_BIND_DN` | | Read-only service account; blank searches anonymously | +| `AUTH_LDAP_BIND_PASSWORD` | | | +| `AUTH_LDAP_BASE_DN` | | Where to search from | +| `AUTH_LDAP_USER_FILTER` | (|(uid={{username}})(mail={{username}})) | {{username}} is replaced with what was typed | +| `AUTH_LDAP_LOGIN_ATTRIBUTES` | | Comma separated attributes accepted at the login prompt, instead of writing a filter | +| `AUTH_LDAP_EMAIL_ATTRIBUTE` | `mail` | Required; an entry without one cannot sign in | +| `AUTH_LDAP_NAME_ATTRIBUTE` | `cn` | | +| `AUTH_LDAP_NICKNAME_ATTRIBUTE` | `givenName` | | +| `AUTH_LDAP_GROUP_ATTRIBUTE` | `memberOf` | Read from the user entry | +| `AUTH_LDAP_GROUP_BASE_DN` | the base DN | Where to search for groups | +| `AUTH_LDAP_GROUP_FILTER` | | Reverse lookup for directories without `memberOf`; {{dn}} and {{username}} are substituted | +| `AUTH_LDAP_GROUP_NAME_ATTRIBUTE` | `dn` | | +| `AUTH_LDAP_START_TLS` | `false` | Upgrade a plain connection with StartTLS | +| `AUTH_LDAP_TLS_REJECT_UNAUTHORIZED` | `true` | Verify the server certificate | +| `AUTH_LDAP_TIMEOUT` | `10000` | Milliseconds | +| `AUTH_LDAP_PAGE_SIZE` | `500` | Entries per page, for directories that cap search results | + +Directory sync, described under [Directory sync](#directory-sync): + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `AUTH_LDAP_SYNC_ENABLED` | `false` | Walk the directory on a schedule | +| `AUTH_LDAP_SYNC_INTERVAL` | `60` | Minutes between runs; five is the minimum | +| `AUTH_LDAP_SYNC_FILTER` | `(objectClass=person)` | Which entries to consider | +| `AUTH_LDAP_SYNC_GROUP` | | Only sync members of this group | +| `AUTH_LDAP_SYNC_DISABLE_MISSING` | `false` | Disable accounts whose entry has gone away | + +### SAML + +| Variable | Default | +| -------- | ------- | +| `AUTH_SAML_ENTRY_POINT` | | +| `AUTH_SAML_ISSUER` | `nginx-proxy-manager` | +| `AUTH_SAML_IDP_CERT` | | +| `AUTH_SAML_SP_PRIVATE_KEY` | | +| `AUTH_SAML_SIGNATURE_ALGORITHM` | `sha256` | +| `AUTH_SAML_WANT_ASSERTIONS_SIGNED` | `true` | +| `AUTH_SAML_WANT_AUTHN_RESPONSE_SIGNED` | `false` | +| `AUTH_SAML_EMAIL_ATTRIBUTE` | auto-detected | +| `AUTH_SAML_NAME_ATTRIBUTE` | auto-detected | +| `AUTH_SAML_NICKNAME_ATTRIBUTE` | auto-detected | +| `AUTH_SAML_GROUP_ATTRIBUTE` | auto-detected | + +### OAuth + +| Variable | Default | +| -------- | ------- | +| `AUTH_OAUTH_ISSUER_URL` | | +| `AUTH_OAUTH_AUTHORIZATION_URL` | discovered | +| `AUTH_OAUTH_TOKEN_URL` | discovered | +| `AUTH_OAUTH_USERINFO_URL` | discovered | +| `AUTH_OAUTH_JWKS_URL` | discovered | +| `AUTH_OAUTH_CLIENT_ID` | | +| `AUTH_OAUTH_CLIENT_SECRET` | | +| `AUTH_OAUTH_SCOPES` | `openid email profile` | +| `AUTH_OAUTH_EMAIL_CLAIM` | `email` | +| `AUTH_OAUTH_NAME_CLAIM` | `name` | +| `AUTH_OAUTH_NICKNAME_CLAIM` | `preferred_username` | +| `AUTH_OAUTH_GROUP_CLAIM` | `groups` | +| `AUTH_OAUTH_USE_BASIC_AUTH` | `false` | + +## Troubleshooting + +**"There is no account here for this user"** — the provider is not allowed to +create accounts. Either turn on auto-creation, or create the user in the Users +screen with the same email address the provider reports. + +**LDAP sign in silently falls back to "Invalid email or password"** — a +directory that cannot be reached is logged and skipped, so the login looks like +a wrong password. Use the **Test** button on the provider, and check the +container log for a line from `Auth`. + +**The redirect comes back to the wrong host** — set `AUTH_PUBLIC_URL` to the +URL your users actually visit. + +**Someone is missing the admin role** — the group value has to match exactly +(case is ignored). For LDAP this is normally the group's full DN. Signing in +again picks up any change. diff --git a/frontend/src/api/backend/authProviders.ts b/frontend/src/api/backend/authProviders.ts new file mode 100644 index 0000000000..edf2b108ac --- /dev/null +++ b/frontend/src/api/backend/authProviders.ts @@ -0,0 +1,138 @@ +import * as api from "./base"; +import type { + AuthCredentialTest, + AuthProvider, + AuthSyncResult, + AuthSyncStatus, + LoginOptions, + NewAuthProvider, +} from "./models"; +import type { TokenResponse, TwoFactorChallengeResponse } from "./responseTypes"; + +/** + * The sign in methods offered on the login screen. Unauthenticated. + */ +export async function getLoginOptions(): Promise { + return await api.get({ url: "/auth/providers" }); +} + +/** + * Swaps the single use code handed back by a SAML or OAuth login for a token. + */ +export async function exchangeSsoCode(code: string): Promise { + return await api.post({ + url: "/auth/exchange", + data: { code }, + noAuth: true, + }); +} + +/** + * Where to send the browser to begin a redirect based login. + */ +export function providerLoginUrl(providerId: number): string { + return `/api/auth/${providerId}/login`; +} + +/** + * Where the identity provider can fetch this instance's SAML metadata. + */ +export function providerMetadataUrl(providerId: number): string { + return `/api/auth/${providerId}/metadata`; +} + +export async function getAuthProviders(): Promise { + return await api.get({ url: "/auth-providers" }); +} + +export async function createAuthProvider(item: NewAuthProvider): Promise { + return await api.post({ url: "/auth-providers", data: item }); +} + +export async function updateAuthProvider(id: number, item: Partial): Promise { + return await api.put({ url: `/auth-providers/${id}`, data: item }); +} + +export interface AuthProviderUserImpact { + /** Accounts currently linked to this provider */ + users: number; + /** Of those, how many have no other way to sign in */ + removable: number; +} + +export interface DeleteAuthProviderResult { + deletedProvider: boolean; + converted?: number; + deleted?: number; + kept?: { id: number; email: string; reason: string }[]; +} + +/** How many accounts deleting this provider would affect */ +export async function getAuthProviderUsers(id: number): Promise { + return await api.get({ url: `/auth-providers/${id}/users` }); +} + +/** + * @param users what to do with the accounts this provider created. Defaults to + * keeping them, because losing access should never be the result + * of leaving an argument off. + */ +export async function deleteAuthProvider( + id: number, + users: "convert" | "delete" = "convert", +): Promise { + return await api.del({ url: `/auth-providers/${id}`, params: { users } }); +} + +export interface ConfigTestResult { + valid: boolean; + /** What succeeded, when it did */ + detail?: string; + /** Why it failed, when it did */ + error?: string; +} + +/** + * Check connection settings that have not been saved yet. Pass the id of an + * existing provider so its stored secrets fill in any field left blank. + */ +export async function testAuthProviderConfig(data: { + type: string; + meta: Record; + id?: number; + name?: string; +}): Promise { + return await api.post({ url: "/auth-providers/test", data }); +} + +export async function testAuthProvider(id: number): Promise<{ valid: boolean }> { + return await api.post({ url: `/auth-providers/${id}/test` }); +} + +/** Verify a real username and password against a directory, without signing in. */ +export async function testAuthProviderCredentials( + id: number, + username: string, + password: string, +): Promise { + return await api.post({ + url: `/auth-providers/${id}/test-credentials`, + data: { username, password }, + }); +} + +export async function getAuthProviderSync(id: number): Promise { + return await api.get({ url: `/auth-providers/${id}/sync` }); +} + +export async function runAuthProviderSync(id: number): Promise { + return await api.post({ url: `/auth-providers/${id}/sync` }); +} + +export async function getLocalAuth(): Promise<{ localEnabled: boolean }> { + return await api.get({ url: "/auth-providers/local" }); +} + +export async function setLocalAuth(localEnabled: boolean): Promise<{ localEnabled: boolean }> { + return await api.put({ url: "/auth-providers/local", data: { localEnabled } }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..3c905fc7bd 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -1,3 +1,4 @@ +export * from "./authProviders"; export * from "./checkVersion"; export * from "./createAccessList"; export * from "./createCertificate"; @@ -50,6 +51,7 @@ export * from "./toggleProxyHost"; export * from "./toggleRedirectionHost"; export * from "./toggleStream"; export * from "./toggleUser"; +export * from "./twoFactor"; export * from "./updateAccessList"; export * from "./updateAuth"; export * from "./updateDeadHost"; @@ -60,4 +62,3 @@ export * from "./updateStream"; export * from "./updateUser"; export * from "./uploadCertificate"; export * from "./validateCertificate"; -export * from "./twoFactor"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..2050622221 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -29,6 +29,8 @@ export interface User { avatar: string; roles: string[]; permissions?: UserPermissions; + /** Only returned when listing users */ + authSources?: AuthSource[]; } export interface AuditLog { @@ -54,6 +56,12 @@ export interface AccessList { satisfyAny: boolean; passAuth: boolean; proxyHostCount?: number; + /** Providers whose users may authenticate against sites using this list */ + authProviderIds?: number[]; + /** When set, a provider user must also be in one of these groups */ + allowedGroups?: string[]; + /** Derived: true when this list defers to a provider rather than a htpasswd file */ + providerAuth?: boolean; // Expansions: owner?: User; items?: AccessListItem[]; @@ -208,3 +216,145 @@ export interface DNSProvider { name: string; credentials: string; } + +/** Where a user is able to sign in from, shown in the Users list */ +export interface AuthSource { + type: "local" | "ldap" | "saml" | "oauth"; + providerId?: number | null; + /** Provider display name; null for local, or if the provider was removed */ + name?: string | null; +} + +export type AuthProviderType = "ldap" | "saml" | "oauth"; + +/** + * Provider configuration. The shape depends on the provider type; secrets are + * never returned by the API, instead a `Set` boolean says whether one is + * stored. + */ +export interface AuthProviderMeta { + // Common + autoCreateUser?: boolean; + defaultRoles?: string[]; + adminGroup?: string; + linkByEmail?: boolean; + identifierAttribute?: string; + + // LDAP + url?: string; + bindDn?: string; + bindPassword?: string; + bindPasswordSet?: boolean; + baseDn?: string; + userFilter?: string; + emailAttribute?: string; + nameAttribute?: string; + nicknameAttribute?: string; + groupAttribute?: string; + groupBaseDn?: string; + groupFilter?: string; + groupNameAttribute?: string; + loginAttributes?: string; + pageSize?: number; + syncEnabled?: boolean; + syncInterval?: number; + syncFilter?: string; + syncGroup?: string; + syncDisableMissing?: boolean; + startTls?: boolean; + tlsRejectUnauthorized?: boolean; + timeout?: number; + + // SAML + entryPoint?: string; + issuer?: string; + idpCert?: string; + spPrivateKey?: string; + spPrivateKeySet?: boolean; + signatureAlgorithm?: string; + wantAssertionsSigned?: boolean; + wantAuthnResponseSigned?: boolean; + + // OAuth + issuerUrl?: string; + authorizationUrl?: string; + tokenUrl?: string; + userinfoUrl?: string; + jwksUrl?: string; + clientId?: string; + clientSecret?: string; + clientSecretSet?: boolean; + scopes?: string; + emailClaim?: string; + nameClaim?: string; + nicknameClaim?: string; + groupClaim?: string; + useBasicAuth?: boolean; + + [key: string]: any; +} + +export interface AuthProvider { + id: number; + createdOn: string; + modifiedOn: string; + isDeleted?: boolean; + isEnabled: boolean; + isEnvManaged: boolean; + slug: string; + name: string; + type: AuthProviderType; + sortOrder: number; + meta: AuthProviderMeta; +} + +export interface NewAuthProvider { + name: string; + type: AuthProviderType; + isEnabled?: boolean; + sortOrder?: number; + meta?: AuthProviderMeta; +} + +/** A provider as advertised on the (unauthenticated) login screen */ +export interface LoginProvider { + id: number; + name: string; + type: "saml" | "oauth"; +} + +export interface AuthSyncResult { + providerId: number; + startedOn?: string; + finishedOn?: string; + ok: boolean; + error?: string; + entries?: number; + created?: number; + updated?: number; + disabled?: number; + skipped?: number; + failed?: number; +} + +export interface AuthSyncStatus { + supported: boolean; + enabled: boolean; + running: boolean; + lastResult?: AuthSyncResult | null; +} + +export interface AuthCredentialTest { + valid: boolean; + dn?: string; + email?: string; + name?: string; + identifierSource?: string; + groups?: string[]; +} + +export interface LoginOptions { + localEnabled: boolean; + ldapEnabled: boolean; + providers: LoginProvider[]; +} diff --git a/frontend/src/components/Form/AccessProviderFields.tsx b/frontend/src/components/Form/AccessProviderFields.tsx new file mode 100644 index 0000000000..0c128adf38 --- /dev/null +++ b/frontend/src/components/Form/AccessProviderFields.tsx @@ -0,0 +1,89 @@ +import { Field, useFormikContext } from "formik"; +import Alert from "react-bootstrap/Alert"; +import { useAuthProviders } from "src/hooks"; +import { intl, T } from "src/locale"; + +/** + * Lets an access list accept accounts from an authentication provider, instead + * of only the usernames typed into the list itself. + * + * Only LDAP providers are offered. SAML and OAuth authenticate by redirecting a + * browser to the identity provider, which cannot happen inside the subrequest + * nginx makes to check a request's credentials. + */ +export function AccessProviderFields() { + const { values, setFieldValue } = useFormikContext(); + const { data: providers, isLoading } = useAuthProviders(); + + const usable = (providers || []).filter((p) => p.type === "ldap" && p.isEnabled); + const selected: number[] = values.authProviderIds || []; + + const toggle = (id: number) => { + setFieldValue("authProviderIds", selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]); + }; + + if (isLoading) { + return null; + } + + if (!usable.length) { + return ( + + + + ); + } + + return ( + <> +

+ +

+ +
+ {usable.map((provider) => ( + + ))} +
+ + {selected.length ? ( + + {({ field, form }: any) => ( +
+ +