From 3105a78623e331ab1b399fd79d30efeb181d476f Mon Sep 17 00:00:00 2001 From: Timothe Stoifl Date: Sat, 22 Aug 2026 11:25:54 +0200 Subject: [PATCH 1/9] Add LDAP, SAML and OAuth authentication providers Users can now sign in with credentials from an existing directory or identity provider, configured either under Users -> Authentication Providers or entirely through environment variables. LDAP reuses the normal login form, so directory users type their username or email like everyone else. SAML and OAuth add a "Continue with ..." button that redirects out to the provider and back. Backend: - New auth_provider table, plus provider_id/identifier on auth so a user can be linked back to their upstream account - One driver per protocol in lib/auth, with lib/auth/provision.js turning a verified identity into a local user - Providers described by AUTH_* environment variables are reconciled into the database on boot and shown read only in the interface - Public endpoints under /auth for the redirect flows, admin CRUD under /auth-providers, both with full OpenAPI schemas Behaviour: - Auto-creating users is opt in per provider; otherwise an administrator has to create the account first and it is matched by email address - An optional admin group grants the admin role on every sign in, and revokes it again when someone leaves the group - Local password sign in can be switched off once a provider is enabled, guarded so that it cannot leave an instance with no way in. AUTH_DISABLE_LOCAL overrides the stored setting either way Secrets are write only: they are never returned by the API, are kept when an update omits them, and stay out of the audit log. OAuth uses the authorization code flow with PKCE, a single use state and a nonce, and verifies ID tokens against the provider's JWKS. Completed SSO logins hand the frontend a single use code rather than putting a token in a URL. Also fixes twoFactor.isEnabled(), which threw for any user without a local password row and so would have rejected every external login. --- backend/internal/2fa.js | 23 +- backend/internal/auth-provider.js | 370 ++++++++++++ backend/internal/auth.js | 156 +++++ backend/internal/token.js | 144 +++-- backend/internal/user.js | 26 +- backend/lib/access/auth_providers-create.json | 7 + backend/lib/access/auth_providers-delete.json | 7 + backend/lib/access/auth_providers-get.json | 7 + backend/lib/access/auth_providers-list.json | 7 + backend/lib/access/auth_providers-update.json | 7 + backend/lib/auth/definitions.js | 134 +++++ backend/lib/auth/env.js | 181 ++++++ backend/lib/auth/ldap.js | 226 +++++++ backend/lib/auth/oauth.js | 292 +++++++++ backend/lib/auth/provision.js | 201 +++++++ backend/lib/auth/saml.js | 167 ++++++ backend/lib/auth/state.js | 68 +++ backend/logger.js | 3 +- .../20260821120000_auth_providers.js | 86 +++ backend/models/auth_provider.js | 51 ++ backend/package.json | 2 + backend/routes/auth-providers.js | 164 ++++++ backend/routes/auth.js | 133 +++++ backend/routes/main.js | 4 + .../schema/components/auth-login-options.json | 44 ++ .../schema/components/auth-provider-list.json | 7 + .../components/auth-provider-object.json | 76 +++ backend/schema/paths/auth-providers/get.json | 45 ++ .../paths/auth-providers/local/get.json | 37 ++ .../paths/auth-providers/local/put.json | 60 ++ backend/schema/paths/auth-providers/post.json | 65 ++ .../auth-providers/providerID/delete.json | 40 ++ .../paths/auth-providers/providerID/get.json | 35 ++ .../paths/auth-providers/providerID/put.json | 66 +++ .../auth-providers/providerID/test/post.json | 50 ++ backend/schema/paths/auth/exchange/post.json | 49 ++ .../paths/auth/providerID/callback/get.json | 24 + .../paths/auth/providerID/callback/post.json | 43 ++ .../paths/auth/providerID/login/get.json | 24 + .../paths/auth/providerID/metadata/get.json | 30 + backend/schema/paths/auth/providers/get.json | 33 ++ backend/schema/swagger.json | 68 +++ backend/setup.js | 28 +- backend/yarn.lock | 139 +++++ docker/auth-dev/ldifs/01-tree.ldif | 36 ++ docker/auth-dev/nginx.conf | 14 + docker/docker-compose.auth-dev.yml | 145 +++++ docs/.vitepress/config.mts | 1 + docs/src/advanced-config/index.md | 13 + docs/src/authentication/index.md | 300 ++++++++++ frontend/src/api/backend/authProviders.ts | 63 ++ frontend/src/api/backend/index.ts | 3 +- frontend/src/api/backend/models.ts | 95 +++ .../Table/Formatter/EventFormatter.tsx | 22 +- frontend/src/context/AuthContext.tsx | 19 +- frontend/src/hooks/index.ts | 3 + frontend/src/hooks/useAuthProviders.ts | 13 + frontend/src/hooks/useLocalAuth.ts | 13 + frontend/src/hooks/useLoginOptions.ts | 18 + frontend/src/locale/src/en.json | 240 ++++++++ .../src/modals/AuthProviderModal.module.css | 12 + frontend/src/modals/AuthProviderModal.tsx | 554 ++++++++++++++++++ frontend/src/modals/index.ts | 1 + frontend/src/pages/Login/index.tsx | 169 +++++- frontend/src/pages/Users/AuthProviders.tsx | 249 ++++++++ frontend/src/pages/Users/Layout.tsx | 34 ++ frontend/src/pages/Users/TableWrapper.tsx | 101 ++-- frontend/src/pages/Users/index.tsx | 4 +- 68 files changed, 5394 insertions(+), 157 deletions(-) create mode 100644 backend/internal/auth-provider.js create mode 100644 backend/internal/auth.js create mode 100644 backend/lib/access/auth_providers-create.json create mode 100644 backend/lib/access/auth_providers-delete.json create mode 100644 backend/lib/access/auth_providers-get.json create mode 100644 backend/lib/access/auth_providers-list.json create mode 100644 backend/lib/access/auth_providers-update.json create mode 100644 backend/lib/auth/definitions.js create mode 100644 backend/lib/auth/env.js create mode 100644 backend/lib/auth/ldap.js create mode 100644 backend/lib/auth/oauth.js create mode 100644 backend/lib/auth/provision.js create mode 100644 backend/lib/auth/saml.js create mode 100644 backend/lib/auth/state.js create mode 100644 backend/migrations/20260821120000_auth_providers.js create mode 100644 backend/models/auth_provider.js create mode 100644 backend/routes/auth-providers.js create mode 100644 backend/routes/auth.js create mode 100644 backend/schema/components/auth-login-options.json create mode 100644 backend/schema/components/auth-provider-list.json create mode 100644 backend/schema/components/auth-provider-object.json create mode 100644 backend/schema/paths/auth-providers/get.json create mode 100644 backend/schema/paths/auth-providers/local/get.json create mode 100644 backend/schema/paths/auth-providers/local/put.json create mode 100644 backend/schema/paths/auth-providers/post.json create mode 100644 backend/schema/paths/auth-providers/providerID/delete.json create mode 100644 backend/schema/paths/auth-providers/providerID/get.json create mode 100644 backend/schema/paths/auth-providers/providerID/put.json create mode 100644 backend/schema/paths/auth-providers/providerID/test/post.json create mode 100644 backend/schema/paths/auth/exchange/post.json create mode 100644 backend/schema/paths/auth/providerID/callback/get.json create mode 100644 backend/schema/paths/auth/providerID/callback/post.json create mode 100644 backend/schema/paths/auth/providerID/login/get.json create mode 100644 backend/schema/paths/auth/providerID/metadata/get.json create mode 100644 backend/schema/paths/auth/providers/get.json create mode 100644 docker/auth-dev/ldifs/01-tree.ldif create mode 100644 docker/auth-dev/nginx.conf create mode 100644 docker/docker-compose.auth-dev.yml create mode 100644 docs/src/authentication/index.md create mode 100644 frontend/src/api/backend/authProviders.ts create mode 100644 frontend/src/hooks/useAuthProviders.ts create mode 100644 frontend/src/hooks/useLocalAuth.ts create mode 100644 frontend/src/hooks/useLoginOptions.ts create mode 100644 frontend/src/modals/AuthProviderModal.module.css create mode 100644 frontend/src/modals/AuthProviderModal.tsx create mode 100644 frontend/src/pages/Users/AuthProviders.tsx create mode 100644 frontend/src/pages/Users/Layout.tsx diff --git a/backend/internal/2fa.js b/backend/internal/2fa.js index 43307e02c3..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; }, @@ -161,12 +164,12 @@ const internal2fa = { } const result = await verify({ - token: code, - secret: auth.meta.totp_secret, - guardrails: createGuardrails({ - MIN_SECRET_BYTES: 10, - }), - }); + token: code, + secret: auth.meta.totp_secret, + guardrails: createGuardrails({ + MIN_SECRET_BYTES: 10, + }), + }); if (!result.valid) { throw new errs.AuthError("Invalid verification code"); @@ -288,11 +291,7 @@ const internal2fa = { }, getUserPasswordAuth: async (userId) => { - const auth = await authModel - .query() - .where("user_id", userId) - .andWhere("type", "password") - .first(); + const auth = await authModel.query().where("user_id", userId).andWhere("type", "password").first(); if (!auth) { throw new errs.ItemNotFoundError("Auth not found"); diff --git a/backend/internal/auth-provider.js b/backend/internal/auth-provider.js new file mode 100644 index 0000000000..a9ef5ea227 --- /dev/null +++ b/backend/internal/auth-provider.js @@ -0,0 +1,370 @@ +import { normalizeMeta, PROVIDER_TYPES, redactProvider, SECRET_FIELDS } from "../lib/auth/definitions.js"; +import { localAuthDisabledByEnv } from "../lib/auth/env.js"; +import * as ldap from "../lib/auth/ldap.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 errs from "../lib/error.js"; +import { auth as logger } from "../logger.js"; +import authProviderModel from "../models/auth_provider.js"; +import settingModel from "../models/setting.js"; +import internalAuditLog from "./audit-log.js"; + +const LOCAL_AUTH_SETTING = "auth-local"; + +/** + * 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 = { + /** + * @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), + }); + + 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), + }); + + 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) => { + await access.can("auth_providers:delete", id); + + 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.", + ); + } + + 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), + }); + + return true; + }, + + /** + * 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 }; + }, + + /** + * 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: 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 || row.value !== "disabled"; + }, + + /** + * @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", + ); + } + } + + 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..da81151869 --- /dev/null +++ b/backend/internal/auth.js @@ -0,0 +1,156 @@ +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) { + throw new errs.AuthError( + `The identity provider rejected the sign in: ${params.error_description || params.error}`, + ); + } + + 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 126283e2dd..7062c01804 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -5,61 +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(); + 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 @@ -82,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({ @@ -92,8 +94,8 @@ export default { attrs: { id: user.id, }, - scope: [data.scope], - expiresIn: data.expiry, + scope: [thisScope], + expiresIn: thisExpiry, }); return { @@ -102,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] @@ -148,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"); }, /** @@ -183,10 +246,7 @@ export default { // Verify 2FA code const valid = await twoFactor.verifyForLogin(userId, code); if (!valid) { - throw new errs.AuthError( - ERROR_MESSAGE_INVALID_2FA, - ERROR_MESSAGE_INVALID_2FA_I18N, - ); + throw new errs.AuthError(ERROR_MESSAGE_INVALID_2FA, ERROR_MESSAGE_INVALID_2FA_I18N); } // Create full token @@ -235,3 +295,5 @@ export default { }; }, }; + +export default internalToken; diff --git a/backend/internal/user.js b/backend/internal/user.js index 56a5ea8598..c811e51862 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -257,11 +257,9 @@ const internalUser = { }, deleteAll: async () => { - await userModel - .query() - .patch({ - is_deleted: 1, - }); + await userModel.query().patch({ + is_deleted: 1, + }); }, /** @@ -365,19 +363,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/definitions.js b/backend/lib/auth/definitions.js new file mode 100644 index 0000000000..ad261be878 --- /dev/null +++ b/backend/lib/auth/definitions.js @@ -0,0 +1,134 @@ +/** + * 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: "", +}; + +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", + start_tls: false, + tls_reject_unauthorized: true, + timeout: 10000, + }, + [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: "", + }, + [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 { LDAP, SAML, OAUTH, PROVIDER_TYPES, SECRET_FIELDS, DEFAULTS, normalizeMeta, redactProvider }; diff --git a/backend/lib/auth/env.js b/backend/lib/auth/env.js new file mode 100644 index 0000000000..0d466f44c2 --- /dev/null +++ b/backend/lib/auth/env.js @@ -0,0 +1,181 @@ +import { auth as logger } from "../../logger.js"; +import authProviderModel from "../../models/auth_provider.js"; +import { LDAP, normalizeMeta, OAUTH, PROVIDER_TYPES, SAML } from "./definitions.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`) || "", + }; + + 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"), + 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), + }); + + 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"), + }); + + 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 + const stale = existing.filter((row) => !wantedSlugs.includes(row.slug) && !row.is_deleted); + for (const row of stale) { + await authProviderModel.query().where("id", row.id).patch({ is_deleted: true, is_enabled: false }); + logger.info(`Removed environment configured auth provider: ${row.slug}`); + } + + 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})`); + } + } + + return wanted.length; +}; + +/** + * Whether local email/password sign in has been switched off by environment. + * When unset, the database setting decides. + * + * @returns {Boolean|null} + */ +const localAuthDisabledByEnv = () => { + if (typeof process.env.AUTH_DISABLE_LOCAL === "undefined" || process.env.AUTH_DISABLE_LOCAL === "") { + return null; + } + return toBool(process.env.AUTH_DISABLE_LOCAL, false); +}; + +export { getEnvProviders, syncEnvProviders, localAuthDisabledByEnv }; diff --git a/backend/lib/auth/ldap.js b/backend/lib/auth/ldap.js new file mode 100644 index 0000000000..5704bb2470 --- /dev/null +++ b/backend/lib/auth/ldap.js @@ -0,0 +1,226 @@ +import { Client } from "ldapts"; +import { auth as logger } from "../../logger.js"; +import errs from "../error.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"; + } + }); + +/** + * 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; + +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); +}; + +/** + * 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]>} + */ +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 { searchEntries } = await client.search(meta.group_base_dn || meta.base_dn || "", { + scope: "sub", + filter, + }); + + return searchEntries + .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. + logger.warn(`LDAP group search failed for ${userDn}: ${err.message}`); + return []; + } +}; + +/** + * Authenticates a username/password pair against an LDAP directory. + * + * The directory is searched using the (optional) service account first, then we + * re-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 = createClient(meta); + + try { + if (meta.start_tls) { + await client.startTLS({ rejectUnauthorized: meta.tls_reject_unauthorized !== false }); + } + + // Bind as the service account (or anonymously) to run the search + if (meta.bind_dn) { + await client.bind(meta.bind_dn, meta.bind_password || ""); + } + + const filter = (meta.user_filter || "(uid={{username}})").replace( + /\{\{username\}\}/g, + escapeFilterValue(username), + ); + + const { searchEntries } = await client.search(meta.base_dn || "", { + scope: "sub", + filter, + sizeLimit: 2, + }); + + if (searchEntries.length !== 1) { + logger.debug( + `LDAP search for "${username}" on provider ${provider.id} returned ${searchEntries.length} entries`, + ); + return null; + } + + const entry = searchEntries[0]; + const userDn = entry.dn; + + // 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(userDn, password); + } catch (_) { + return null; + } finally { + await userClient.unbind().catch(() => {}); + } + + const email = firstAttributeValue(entry, meta.email_attribute || "mail"); + if (!email) { + throw new errs.AuthError( + `LDAP entry ${userDn} has no "${meta.email_attribute || "mail"}" attribute, which is required`, + ); + } + + return { + identifier: userDn, + email, + name: firstAttributeValue(entry, meta.name_attribute) || email, + nickname: firstAttributeValue(entry, meta.nickname_attribute) || null, + groups: await resolveGroups(client, meta, entry, userDn, username), + }; + } finally { + await client.unbind().catch(() => { + // Nothing useful to do if the socket is already gone + }); + } +}; + +/** + * 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 = 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 || ""); + } + await client.search(meta.base_dn || "", { scope: "base", filter: "(objectClass=*)", sizeLimit: 1 }); + } finally { + await client.unbind().catch(() => {}); + } +}; + +export { authenticate, test, escapeFilterValue }; diff --git a/backend/lib/auth/oauth.js b/backend/lib/auth/oauth.js new file mode 100644 index 0000000000..3393b4a4b8 --- /dev/null +++ b/backend/lib/auth/oauth.js @@ -0,0 +1,292 @@ +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), + 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..ad33928dc2 --- /dev/null +++ b/backend/lib/auth/provision.js @@ -0,0 +1,201 @@ +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"; + +/** + * 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; + } + + 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); +}; + +/** + * 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 (DN, sub, nameID) + * @param {String} identity.email + * @param {String} [identity.name] + * @param {String} [identity.nickname] + * @param {[String]} [identity.groups] + * @returns {Promise} the user row + */ +const resolveUser = async (provider, identity) => { + 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 match an existing local user by email address + if (!user) { + user = await userModel.query().where("email", email).andWhere("is_deleted", 0).first(); + } + + // 3. Otherwise create one, if the provider is allowed to + if (!user) { + if (!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 meta = { + email: identity.email, + name: identity.name || null, + groups: identity.groups || [], + provider_slug: provider.slug, + }; + + const existing = await authModel + .query() + .where("user_id", user.id) + .andWhere("provider_id", provider.id) + .andWhere("is_deleted", 0) + .first(); + + 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, + }); +}; + +export { resolveUser, resolveRoles, linkIdentity }; diff --git a/backend/lib/auth/saml.js b/backend/lib/auth/saml.js new file mode 100644 index 0000000000..6b908fcacd --- /dev/null +++ b/backend/lib/auth/saml.js @@ -0,0 +1,167 @@ +import { SAML } from "@node-saml/node-saml"; +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", +]; + +const GROUP_FALLBACKS = ["groups", "memberOf", "Role", "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"]; + +/** + * 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, + // We tie the response back to the login request with our own single use + // RelayState value, so node-saml does not need an InResponseTo cache. + validateInResponseTo: "never", + 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.", + ); + } + + return { + identifier: String(profile.nameID || email), + 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), + }; +}; + +/** + * 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, test }; diff --git a/backend/lib/auth/state.js b/backend/lib/auth/state.js new file mode 100644 index 0000000000..3bc912ace5 --- /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 { TransientStore, loginFlows, exchangeCodes }; 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..2581361821 --- /dev/null +++ b/backend/migrations/20260821120000_auth_providers.js @@ -0,0 +1,86 @@ +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. + return knex.schema.alterTable("auth", (table) => { + table.integer("provider_id").notNull().unsigned().defaultTo(0); + table.string("identifier", 255).notNull().defaultTo(""); + }); + }) + .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.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/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 2c91ca4412..f6bc4d1c46 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.3.5", + "@node-saml/node-saml": "^5.1.0", "ajv": "^8.20.0", "archiver": "^8.0.0", "batchflow": "^0.4.0", @@ -27,6 +28,7 @@ "gravatar": "^1.8.2", "jsonwebtoken": "^9.0.3", "knex": "3.2.10", + "ldapts": "^9.0.0", "liquidjs": "10.27.0", "lodash": "^4.18.1", "moment": "^2.30.1", diff --git a/backend/routes/auth-providers.js b/backend/routes/auth-providers.js new file mode 100644 index 0000000000..2a1d0d9c5a --- /dev/null +++ b/backend/routes/auth-providers.js @@ -0,0 +1,164 @@ +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); + } + }); + +/** + * /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 { + const result = await internalAuthProvider.delete(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); + } + }); + +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..c637073bee 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -5,6 +5,8 @@ import logRequest from "../lib/express/log-request.js"; import pjson from "../package.json" with { type: "json" }; import { isSetup } from "../setup.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"; @@ -48,6 +50,8 @@ router.get("/", async (_, res /*, next*/) => { 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/auth-login-options.json b/backend/schema/components/auth-login-options.json new file mode 100644 index 0000000000..33b375d46f --- /dev/null +++ b/backend/schema/components/auth-login-options.json @@ -0,0 +1,44 @@ +{ + "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" + } + } + } + } + } +} 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..fbcd56d408 --- /dev/null +++ b/backend/schema/components/auth-provider-object.json @@ -0,0 +1,76 @@ +{ + "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 + } + } + } +} 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..ca834764ea --- /dev/null +++ b/backend/schema/paths/auth-providers/post.json @@ -0,0 +1,65 @@ +{ + "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 + } + } + } + } + }, + "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..f1b8fd6524 --- /dev/null +++ b/backend/schema/paths/auth-providers/providerID/delete.json @@ -0,0 +1,40 @@ +{ + "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 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": true + } + }, + "schema": { + "type": "boolean" + } + } + } + } + } +} 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/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/exchange/post.json b/backend/schema/paths/auth/exchange/post.json new file mode 100644 index 0000000000..0c985a5190 --- /dev/null +++ b/backend/schema/paths/auth/exchange/post.json @@ -0,0 +1,49 @@ +{ + "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" + } + ] + } + } + } + } + } +} 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..4bb9a34193 --- /dev/null +++ b/backend/schema/paths/auth/providerID/callback/post.json @@ -0,0 +1,43 @@ +{ + "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" + }, + "RelayState": { + "type": "string" + } + } + } + } + } + }, + "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/swagger.json b/backend/schema/swagger.json index 4222f19ddd..a01ee264f5 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,66 @@ "$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/{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}/test": { + "post": { + "$ref": "./paths/auth-providers/providerID/test/post.json" + } + }, "/nginx/access-lists": { "get": { "$ref": "./paths/nginx/access-lists/get.json" diff --git a/backend/setup.js b/backend/setup.js index c0418e170b..2ba6280979 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -1,3 +1,4 @@ +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"; @@ -161,4 +162,29 @@ 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}`); + } +}; + +export default () => + setupDefaultUser() + .then(setupDefaultSettings) + .then(setupAuthProviders) + .then(setupCertbotPlugins) + .then(setupLogrotation); diff --git a/backend/yarn.lock b/backend/yarn.lock index 49c54d1c5a..15087044e1 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -105,6 +105,24 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.2.0.tgz#22da1d16a469954fce877055d559900a6c73b63b" integrity sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg== +"@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.4.0": version "13.4.0" resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.4.0.tgz#14db803de5bd09f7c412eba86c7d193a09d57187" @@ -155,11 +173,59 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== +"@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/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" "*" + +"@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.14" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.14.tgz#96c3cbb30cb305f8f81d1d5965bf03e9ab5d6af6" + integrity sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ== + abbrev@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-4.0.0.tgz#ec933f0e27b6cd60e89b5c6b2a304af42209bb05" @@ -1272,6 +1338,13 @@ 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" + liquidjs@10.27.0: version "10.27.0" resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.27.0.tgz#e31dc4c539e1a26aee46c847b4e60a6ede32564a" @@ -2030,6 +2103,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.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" @@ -2210,6 +2288,11 @@ streamx@^2.15.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" @@ -2385,6 +2468,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.25.0" resolved "https://registry.yarnpkg.com/undici/-/undici-6.25.0.tgz#8c4efb8c998dc187fc1cfb5dde1ef19a211849fb" @@ -2438,6 +2526,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..bed6964a10 --- /dev/null +++ b/docker/auth-dev/nginx.conf @@ -0,0 +1,14 @@ +server { + listen 8080; + root /usr/share/nginx/html; + + location /api/ { + 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..ae74503818 --- /dev/null +++ b/docker/docker-compose.auth-dev.yml @@ -0,0 +1,145 @@ +# 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" + + # --- 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..cfe2546e0a 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -237,6 +237,19 @@ 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 configured in the admin +interface or entirely through environment variables. See +[Authentication Providers](/authentication/) for the full list. + +```yml + environment: + AUTH_LDAP_ENABLED: "true" + AUTH_LDAP_URL: "ldaps://ldap.example.com:636" + AUTH_LDAP_BASE_DN: "dc=example,dc=com" +``` + ## 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..6cefb5140c --- /dev/null +++ b/docs/src/authentication/index.md @@ -0,0 +1,300 @@ +--- +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, which then becomes linked +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. Leaving auto-creation off is the safer default: it means +having an account in your directory is not by itself enough to get into Nginx +Proxy Manager. + +::: warning +Step 2 links accounts by email address, which means a provider that lets people +choose their own unverified email could be used to take over an existing +account. Only connect providers whose email addresses you trust, which is the +normal case for a company directory or a self-hosted identity provider. +::: + +### 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. + +### 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. + +Be careful with this. Make sure you have signed in successfully through a +provider *before* turning it off. If you do lock yourself out, set +`AUTH_DISABLE_LOCAL=false` on the container and restart — the environment +variable overrides the stored setting. + +### 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. + +## 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. + +### 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" +``` + +## 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 | +| -------- | ------- | +| `AUTH_LDAP_URL` | | +| `AUTH_LDAP_BIND_DN` | | +| `AUTH_LDAP_BIND_PASSWORD` | | +| `AUTH_LDAP_BASE_DN` | | +| `AUTH_LDAP_USER_FILTER` | `(\|(uid={{username}})(mail={{username}}))` | +| `AUTH_LDAP_EMAIL_ATTRIBUTE` | `mail` | +| `AUTH_LDAP_NAME_ATTRIBUTE` | `cn` | +| `AUTH_LDAP_NICKNAME_ATTRIBUTE` | `givenName` | +| `AUTH_LDAP_GROUP_ATTRIBUTE` | `memberOf` | +| `AUTH_LDAP_GROUP_BASE_DN` | the base DN | +| `AUTH_LDAP_GROUP_FILTER` | | +| `AUTH_LDAP_GROUP_NAME_ATTRIBUTE` | `dn` | +| `AUTH_LDAP_START_TLS` | `false` | +| `AUTH_LDAP_TLS_REJECT_UNAUTHORIZED` | `true` | +| `AUTH_LDAP_TIMEOUT` | `10000` | + +### 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..e9b32bf650 --- /dev/null +++ b/frontend/src/api/backend/authProviders.ts @@ -0,0 +1,63 @@ +import * as api from "./base"; +import type { AuthProvider, 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 async function deleteAuthProvider(id: number): Promise { + return await api.del({ url: `/auth-providers/${id}` }); +} + +export async function testAuthProvider(id: number): Promise<{ valid: boolean }> { + return await api.post({ url: `/auth-providers/${id}/test` }); +} + +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..d3b9259c1c 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -208,3 +208,98 @@ export interface DNSProvider { name: string; credentials: string; } + +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; + + // 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; + 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 LoginOptions { + localEnabled: boolean; + ldapEnabled: boolean; + providers: LoginProvider[]; +} diff --git a/frontend/src/components/Table/Formatter/EventFormatter.tsx b/frontend/src/components/Table/Formatter/EventFormatter.tsx index 1220fa0961..3e9887593d 100644 --- a/frontend/src/components/Table/Formatter/EventFormatter.tsx +++ b/frontend/src/components/Table/Formatter/EventFormatter.tsx @@ -1,4 +1,14 @@ -import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react"; +import { + IconArrowsCross, + IconBolt, + IconBoltOff, + IconDisc, + IconLock, + IconSettings, + IconShield, + IconShieldLock, + IconUser, +} from "@tabler/icons-react"; import cn from "classnames"; import type { AuditLog } from "src/api/backend"; import { useLocaleState } from "src/context"; @@ -17,6 +27,10 @@ const getEventValue = (event: AuditLog) => { return event.meta?.incomingPort || "N/A"; case "certificate": return event.meta?.domainNames?.join(", ") || event.meta?.niceName || "N/A"; + case "auth-provider": + return event.meta?.name || "N/A"; + case "setting": + return event.meta?.id || "N/A"; default: return `UNKNOWN EVENT TYPE: ${event.objectType}`; } @@ -58,6 +72,12 @@ const getIcon = (row: AuditLog) => { case "certificate": ico = ; break; + case "auth-provider": + ico = ; + break; + case "setting": + ico = ; + break; } return ico; diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 34a67ec48d..38319b890d 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -2,12 +2,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { createContext, type ReactNode, useContext, useState } from "react"; import { useIntervalWhen } from "rooks"; import { + exchangeSsoCode, getToken, isTwoFactorChallenge, loginAsUser, refreshToken, - verify2FA, type TokenResponse, + verify2FA, } from "src/api/backend"; import AuthStore from "src/modules/AuthStore"; @@ -21,6 +22,7 @@ export interface AuthContextType { authenticated: boolean; twoFactorChallenge: TwoFactorChallenge | null; login: (username: string, password: string) => Promise; + loginWithSsoCode: (code: string) => Promise; verifyTwoFactor: (code: string) => Promise; cancelTwoFactor: () => void; loginAs: (id: number) => Promise; @@ -56,6 +58,20 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) handleTokenUpdate(response); }; + /** + * Completes a SAML or OAuth login. The provider callback redirects back to + * the app with a single use code, which is swapped for a real token here so + * that the token itself never appears in a URL. + */ + const loginWithSsoCode = async (code: string) => { + const response = await exchangeSsoCode(code); + if (isTwoFactorChallenge(response)) { + setTwoFactorChallenge({ challengeToken: response.challengeToken }); + return; + } + handleTokenUpdate(response); + }; + const verifyTwoFactor = async (code: string) => { if (!twoFactorChallenge) { throw new Error("No 2FA challenge pending"); @@ -106,6 +122,7 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) authenticated, twoFactorChallenge, login, + loginWithSsoCode, verifyTwoFactor, cancelTwoFactor, loginAs, diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 744190ade1..504bdefe91 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -2,6 +2,7 @@ export * from "./useAccessList"; export * from "./useAccessLists"; export * from "./useAuditLog"; export * from "./useAuditLogs"; +export * from "./useAuthProviders"; export * from "./useCertificate"; export * from "./useCertificates"; export * from "./useCheckVersion"; @@ -10,6 +11,8 @@ export * from "./useDeadHosts"; export * from "./useDnsProviders"; export * from "./useHealth"; export * from "./useHostReport"; +export * from "./useLocalAuth"; +export * from "./useLoginOptions"; export * from "./useProxyHost"; export * from "./useProxyHosts"; export * from "./useRedirectionHost"; diff --git a/frontend/src/hooks/useAuthProviders.ts b/frontend/src/hooks/useAuthProviders.ts new file mode 100644 index 0000000000..d16e31fc4a --- /dev/null +++ b/frontend/src/hooks/useAuthProviders.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { type AuthProvider, getAuthProviders } from "src/api/backend"; + +const useAuthProviders = (options = {}) => { + return useQuery({ + queryKey: ["auth-providers"], + queryFn: getAuthProviders, + staleTime: 60 * 1000, + ...options, + }); +}; + +export { useAuthProviders }; diff --git a/frontend/src/hooks/useLocalAuth.ts b/frontend/src/hooks/useLocalAuth.ts new file mode 100644 index 0000000000..2b3819e000 --- /dev/null +++ b/frontend/src/hooks/useLocalAuth.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { getLocalAuth } from "src/api/backend"; + +const useLocalAuth = (options = {}) => { + return useQuery<{ localEnabled: boolean }, Error>({ + queryKey: ["auth-local"], + queryFn: getLocalAuth, + staleTime: 60 * 1000, + ...options, + }); +}; + +export { useLocalAuth }; diff --git a/frontend/src/hooks/useLoginOptions.ts b/frontend/src/hooks/useLoginOptions.ts new file mode 100644 index 0000000000..cd5601309e --- /dev/null +++ b/frontend/src/hooks/useLoginOptions.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { getLoginOptions, type LoginOptions } from "src/api/backend"; + +/** + * The sign in methods available on the login screen. Fetched without a token, + * so it is also used to decide whether the password form is shown at all. + */ +const useLoginOptions = (options = {}) => { + return useQuery({ + queryKey: ["login-options"], + queryFn: getLoginOptions, + staleTime: 60 * 1000, + retry: false, + ...options, + }); +}; + +export { useLoginOptions }; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..44087a5166 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -134,6 +134,213 @@ "auditlogs": { "defaultMessage": "Audit Logs" }, + "auth-provider": { + "defaultMessage": "Authentication Provider" + }, + "auth-provider.admin-group": { + "defaultMessage": "Administrator group" + }, + "auth-provider.admin-group-help": { + "defaultMessage": "Anyone in this group is given the admin role on every sign in, and loses it when they leave the group. Leave blank to manage roles here instead." + }, + "auth-provider.auto-create-user": { + "defaultMessage": "Create users on first sign in" + }, + "auth-provider.auto-create-user-help": { + "defaultMessage": "When off, someone must already have an account here with a matching email address before they can sign in." + }, + "auth-provider.disabled": { + "defaultMessage": "Disabled" + }, + "auth-provider.empty-summary": { + "defaultMessage": "Add an LDAP directory, a SAML identity provider or an OAuth/OpenID Connect application to get started." + }, + "auth-provider.enabled": { + "defaultMessage": "Enabled" + }, + "auth-provider.env-managed": { + "defaultMessage": "From environment" + }, + "auth-provider.env-managed-help": { + "defaultMessage": "This provider is configured with environment variables and can only be changed by editing them." + }, + "auth-provider.intro": { + "defaultMessage": "Let people sign in with your existing directory or identity provider." + }, + "auth-provider.ldap.base-dn": { + "defaultMessage": "Base DN" + }, + "auth-provider.ldap.bind-dn": { + "defaultMessage": "Bind DN" + }, + "auth-provider.ldap.bind-dn-help": { + "defaultMessage": "A read-only service account used to look users up. Leave blank to search anonymously." + }, + "auth-provider.ldap.bind-password": { + "defaultMessage": "Bind password" + }, + "auth-provider.ldap.email-attribute": { + "defaultMessage": "Email attribute" + }, + "auth-provider.ldap.group-attribute": { + "defaultMessage": "Group attribute" + }, + "auth-provider.ldap.group-attribute-help": { + "defaultMessage": "Read from the user entry, if your directory provides it." + }, + "auth-provider.ldap.group-filter": { + "defaultMessage": "Group filter" + }, + "auth-provider.ldap.group-filter-help": { + "defaultMessage": "Used when the user entry has no group attribute. '{{dn}}' and '{{username}}' are substituted before searching." + }, + "auth-provider.ldap.name-attribute": { + "defaultMessage": "Name attribute" + }, + "auth-provider.ldap.nickname-attribute": { + "defaultMessage": "Nickname attribute" + }, + "auth-provider.ldap.start-tls": { + "defaultMessage": "Upgrade the connection with StartTLS" + }, + "auth-provider.ldap.url": { + "defaultMessage": "Server URL" + }, + "auth-provider.ldap.user-filter": { + "defaultMessage": "User filter" + }, + "auth-provider.ldap.user-filter-help": { + "defaultMessage": "'{{username}}' is replaced with whatever was typed into the login form." + }, + "auth-provider.ldap.verify-tls": { + "defaultMessage": "Verify the TLS certificate" + }, + "auth-provider.ldap.verify-tls-help": { + "defaultMessage": "Only turn this off for a directory using a self-signed certificate you trust." + }, + "auth-provider.local-enabled": { + "defaultMessage": "Allow email and password sign in" + }, + "auth-provider.local-enabled-help": { + "defaultMessage": "Turn this off to require an authentication provider. Make sure you can sign in with one first." + }, + "auth-provider.local-required-help": { + "defaultMessage": "Add and enable a provider before you can turn this off." + }, + "auth-provider.name": { + "defaultMessage": "Display name" + }, + "auth-provider.name-help": { + "defaultMessage": "Shown on the login screen." + }, + "auth-provider.oauth.authorization-url": { + "defaultMessage": "Authorization URL" + }, + "auth-provider.oauth.client-id": { + "defaultMessage": "Client ID" + }, + "auth-provider.oauth.client-secret": { + "defaultMessage": "Client secret" + }, + "auth-provider.oauth.email-claim": { + "defaultMessage": "Email claim" + }, + "auth-provider.oauth.group-claim": { + "defaultMessage": "Group claim" + }, + "auth-provider.oauth.issuer-url": { + "defaultMessage": "Issuer URL" + }, + "auth-provider.oauth.issuer-url-help": { + "defaultMessage": "Endpoints are discovered from here. Leave blank to configure them by hand below." + }, + "auth-provider.oauth.jwks-url": { + "defaultMessage": "JWKS URL" + }, + "auth-provider.oauth.manual-endpoints": { + "defaultMessage": "Configure endpoints manually" + }, + "auth-provider.oauth.name-claim": { + "defaultMessage": "Name claim" + }, + "auth-provider.oauth.redirect-uri": { + "defaultMessage": "Redirect URI" + }, + "auth-provider.oauth.redirect-uri-help": { + "defaultMessage": "Add this to the allowed redirect URIs of your OAuth application." + }, + "auth-provider.oauth.scopes": { + "defaultMessage": "Scopes" + }, + "auth-provider.oauth.token-url": { + "defaultMessage": "Token URL" + }, + "auth-provider.oauth.use-basic-auth": { + "defaultMessage": "Send credentials in the Authorization header" + }, + "auth-provider.oauth.use-basic-auth-help": { + "defaultMessage": "Some providers require client_secret_basic instead of sending the secret in the request body." + }, + "auth-provider.oauth.userinfo-url": { + "defaultMessage": "Userinfo URL" + }, + "auth-provider.saml.email-attribute": { + "defaultMessage": "Email attribute" + }, + "auth-provider.saml.entry-point": { + "defaultMessage": "Sign-in URL" + }, + "auth-provider.saml.group-attribute": { + "defaultMessage": "Group attribute" + }, + "auth-provider.saml.idp-cert": { + "defaultMessage": "Identity provider certificate" + }, + "auth-provider.saml.issuer": { + "defaultMessage": "Entity ID" + }, + "auth-provider.saml.issuer-help": { + "defaultMessage": "How this instance identifies itself to the identity provider." + }, + "auth-provider.saml.metadata": { + "defaultMessage": "Service provider metadata" + }, + "auth-provider.saml.metadata-help": { + "defaultMessage": "Give this URL to your identity provider to configure the connection." + }, + "auth-provider.saml.name-attribute": { + "defaultMessage": "Name attribute" + }, + "auth-provider.saml.sp-private-key": { + "defaultMessage": "Signing key" + }, + "auth-provider.saml.want-assertions-signed": { + "defaultMessage": "Require signed assertions" + }, + "auth-provider.saml.want-response-signed": { + "defaultMessage": "Require a signed response" + }, + "auth-provider.secret-stored": { + "defaultMessage": "A value is stored. Leave blank to keep it." + }, + "auth-provider.test": { + "defaultMessage": "Test" + }, + "auth-provider.type.ldap": { + "defaultMessage": "LDAP" + }, + "auth-provider.type.oauth": { + "defaultMessage": "OAuth / OpenID Connect" + }, + "auth-provider.type.saml": { + "defaultMessage": "SAML" + }, + "auth-provider.view": { + "defaultMessage": "View" + }, + "auth-providers": { + "defaultMessage": "Authentication Providers" + }, "auto": { "defaultMessage": "Auto" }, @@ -245,6 +452,9 @@ "certificates.request.title": { "defaultMessage": "Request a new Certificate" }, + "close": { + "defaultMessage": "Close" + }, "column.access": { "defaultMessage": "Access" }, @@ -287,6 +497,9 @@ "column.provider": { "defaultMessage": "Provider" }, + "column.provisioning": { + "defaultMessage": "Provisioning" + }, "column.roles": { "defaultMessage": "Roles" }, @@ -314,6 +527,9 @@ "column.status": { "defaultMessage": "Status" }, + "column.type": { + "defaultMessage": "Type" + }, "created-on": { "defaultMessage": "Created: {date}" }, @@ -368,6 +584,9 @@ "domains.use-dns": { "defaultMessage": "Use DNS Challenge" }, + "edit": { + "defaultMessage": "Edit" + }, "email-address": { "defaultMessage": "Email address" }, @@ -410,6 +629,9 @@ "error.minimum": { "defaultMessage": "Minimum is {min}" }, + "error.no-account-for-external-user": { + "defaultMessage": "There is no account here for this user. Ask an administrator to create one." + }, "error.passwords-must-match": { "defaultMessage": "Passwords must match" }, @@ -476,9 +698,21 @@ "login.2fa-verify": { "defaultMessage": "Verify" }, + "login.continue-with": { + "defaultMessage": "Continue with {name}" + }, + "login.no-methods": { + "defaultMessage": "No sign in methods are configured. Set one up with environment variables to get back in." + }, + "login.or": { + "defaultMessage": "or" + }, "login.title": { "defaultMessage": "Login to your account" }, + "login.username-or-email": { + "defaultMessage": "Username or email address" + }, "nginx-config.label": { "defaultMessage": "Custom Nginx Configuration" }, @@ -515,6 +749,9 @@ "notification.object-saved": { "defaultMessage": "{object} has been saved" }, + "notification.object-tested": { + "defaultMessage": "{object} settings look good" + }, "notification.success": { "defaultMessage": "Success" }, @@ -554,6 +791,9 @@ "object.event.updated": { "defaultMessage": "Updated {object}" }, + "object.view": { + "defaultMessage": "View {object}" + }, "offline": { "defaultMessage": "Offline" }, diff --git a/frontend/src/modals/AuthProviderModal.module.css b/frontend/src/modals/AuthProviderModal.module.css new file mode 100644 index 0000000000..4d9c13401c --- /dev/null +++ b/frontend/src/modals/AuthProviderModal.module.css @@ -0,0 +1,12 @@ +/* + * Formik renders a
between .modal-content and the header/body/footer, + * which breaks the flex chain that .modal-dialog-scrollable relies on to give + * .modal-body a bounded height. Without this the body never scrolls and the + * footer buttons end up clipped below the viewport. + */ +.form { + display: flex; + flex-direction: column; + min-height: 0; + max-height: 100%; +} diff --git a/frontend/src/modals/AuthProviderModal.tsx b/frontend/src/modals/AuthProviderModal.tsx new file mode 100644 index 0000000000..f9f4401786 --- /dev/null +++ b/frontend/src/modals/AuthProviderModal.tsx @@ -0,0 +1,554 @@ +import { useQueryClient } from "@tanstack/react-query"; +import EasyModal, { type InnerModalProps } from "ez-modal-react"; +import { Field, Form, Formik } from "formik"; +import { type ReactNode, useState } from "react"; +import { Alert } from "react-bootstrap"; +import Modal from "react-bootstrap/Modal"; +import { + type AuthProvider, + type AuthProviderType, + createAuthProvider, + providerMetadataUrl, + updateAuthProvider, +} from "src/api/backend"; +import { Button } from "src/components"; +import { intl, T } from "src/locale"; +import { validateString } from "src/modules/Validations"; +import { showObjectSuccess } from "src/notifications"; +import styles from "./AuthProviderModal.module.css"; + +const showAuthProviderModal = (provider: AuthProvider | AuthProviderType) => { + EasyModal.show(AuthProviderModal, { provider }); +}; + +interface TextFieldProps { + name: string; + label: string; + help?: ReactNode; + type?: string; + placeholder?: string; + required?: boolean; + rows?: number; + disabled?: boolean; +} +function TextField({ name, label, help, type, placeholder, required, rows, disabled }: TextFieldProps) { + return ( + + {({ field, form }: any) => { + const invalid = form.errors[name] && form.touched[name]; + return ( +
+ + {rows ? ( +