diff --git a/docker/sql/init.sql b/docker/sql/init.sql index 581ae39..e4bc3b4 100644 --- a/docker/sql/init.sql +++ b/docker/sql/init.sql @@ -28,10 +28,12 @@ CREATE TABLE IF NOT EXISTS users ( uid_number INT UNIQUE, gid_number INT, home_directory VARCHAR(200), + auth_backends VARCHAR(255) DEFAULT NULL, FOREIGN KEY (gid_number) REFERENCES `groups`(gid_number) ); -- 4. Now insert users (gid_number matches existing groups) +-- Passwords are SHA-512 crypt(3) hashes ($6$): ann=maya, abrol=abrol, evan=evan, hrits=maya, chris=chris INSERT INTO users (username, password, full_name, email, uid_number, gid_number, home_directory) VALUES ('ann', '$6$Zmtz1yzJJyslWIK/$OoLdG1FNvPbSsyqekHGNIKdx.X1IlMQBVqACvr/WI8IFze.jzvLDzB1y3/Tigjk1mzcGKgowZ1lwCVF8EXv2q.', 'Ann', 'ann@mieweb.com', 1001, 1001, '/home/ann'), ('abrol','$6$7KbEtgYeI.FFS7sw$EcFip4Ros8inRQQ2nGhBa32s3qA7h2pFXGfrP8x0NRMIM0bGaZ8bIObVh207yhQ.YW1KkMW2o7RIkEuDWG3wb/', 'Abrol', 'abrol@mieweb.com', 1002, 1002, '/home/abrol'), diff --git a/docs/MULTI-REALM-ARCHITECTURE.md b/docs/MULTI-REALM-ARCHITECTURE.md new file mode 100644 index 0000000..4007910 --- /dev/null +++ b/docs/MULTI-REALM-ARCHITECTURE.md @@ -0,0 +1,501 @@ +# Multi-Realm LDAP Gateway + +## Overview + +The LDAP Gateway supports **multi-realm architecture**, enabling a single gateway instance to serve multiple directory backends, each with its own baseDN and authentication chain. + +**Capabilities:** +- Serve users from multiple domains (e.g., `dc=mieweb,dc=com`, `dc=bluehive,dc=com`) +- Apply different authentication requirements per user population (MFA for employees, password-only for service accounts) +- Isolate directory data across organizational boundaries +- Override authentication per user via the `auth_backends` database column +- Maintain full backward compatibility with single-realm deployments + +### Architecture Overview + +```mermaid +flowchart TB + subgraph "Client Layer" + C1["SSSD Client\ndc=mieweb,dc=com"] + C2["SSSD Client\ndc=bluehive,dc=com"] + end + + subgraph "LDAP Gateway" + GW["BaseDN Router\n1 baseDN = 1 Realm"] + end + + subgraph "Realm Layer" + R1["Realm: mieweb\ndc=mieweb,dc=com"] + R2["Realm: bluehive\ndc=bluehive,dc=com"] + + R1 --> DIR1["SQL Directory\nusers table"] + R1 --> AUTH1["Auth: SQL + MFA"] + + R2 --> DIR2["SQL Directory\nbluehive_users"] + R2 --> AUTH2["Auth: SQL"] + end + + C1 -->|"bind/search"| GW + C2 -->|"bind/search"| GW + + GW -->|"dc=mieweb,dc=com"| R1 + GW -->|"dc=bluehive,dc=com"| R2 +``` + +## Prerequisites + +- LDAP Gateway v2.0+ +- A `realms.json` configuration file (or `REALM_CONFIG` environment variable) +- Configured backend databases or directory sources for each realm +- Understanding of your organization's LDAP baseDN structure + +## Core Concepts + +### Realm + +A **realm** is an isolated authentication and directory domain consisting of: + +| Property | Description | Example | +|----------|-------------|---------| +| `name` | Unique identifier | `"mieweb-employees"` | +| `baseDn` | LDAP subtree root (unique per realm) | `"dc=mieweb,dc=com"` | +| `default` | Mark as default for SSSD discovery (max one) | `true` or omitted | +| `directory` | Backend for user/group lookups | SQL, MongoDB, Proxmox | +| `auth.backends` | Ordered list of authentication providers | SQL, Notification (MFA) | + +**Each baseDN maps to exactly one realm (1:1).** The gateway rejects configurations where two realms share the same baseDN at startup. This eliminates cross-realm ambiguity in bind and search operations. + +> **Default realm:** Only one realm may be marked `"default": true`. If no realm is explicitly marked, the first realm in the configuration array is used as the default for SSSD discovery (`defaultNamingContext`). + +### Routing Behavior + +| Operation | Behavior | +|-----------|----------| +| **Bind (auth)** | ldapjs routes by DN suffix to the single realm owning that baseDN | +| **Search** | Queries the single realm's directory provider directly | +| **RootDSE** | Returns all baseDNs in `namingContexts`, plus `defaultNamingContext` for the default realm | + +### Authentication Chain + +Each realm defines a sequential authentication chain. **All providers in the chain must succeed** for authentication to pass: + +```json +"auth": { + "backends": [ + { "type": "sql" }, + { "type": "notification" } + ] +} +``` + +In the example above, SQL password validation must pass first, then the MFA notification provider must also succeed. + +### Authentication Flow + +When a client binds (authenticates), the gateway routes by baseDN to the single owning realm, looks up the user, and runs the authentication chain: + +```mermaid +sequenceDiagram + participant Client + participant Gateway as LDAP Gateway + participant Realm as Realm: mieweb + participant Auth as Auth Chain + + Client->>Gateway: BIND uid=apant,dc=mieweb,dc=com + + Gateway->>Gateway: Route by baseDN → realm 'mieweb' + + Gateway->>Realm: findUser('apant') + Realm-->>Gateway: Found (auth_backends: null) + + Gateway->>Gateway: Resolve auth chain + + alt auth_backends is null + Note over Gateway: Use realm default: [SQL, MFA] + else auth_backends = 'sql' + Note over Gateway: Per-user override: [SQL only] + end + + Gateway->>Auth: SQL password check + Auth-->>Gateway: Pass + Gateway->>Auth: MFA notification + Auth-->>Gateway: Approved + + Gateway-->>Client: BIND SUCCESS (realm: mieweb) +``` + +### Search Flow + +When a client searches, the query is routed to the single realm owning that baseDN: + +```mermaid +sequenceDiagram + participant Client + participant Gateway as LDAP Gateway + participant Realm as Realm: mieweb + + Client->>Gateway: SEARCH base=dc=mieweb,dc=com + + Gateway->>Gateway: Route by baseDN → realm 'mieweb' + + Gateway->>Realm: getAllUsers() + Realm-->>Gateway: [uid=apant, uid=horner, uid=ldap-reader] + + Gateway-->>Client: 3 entries +``` + +## Configuration + +### Multi-Realm Mode + +Set the `REALM_CONFIG` environment variable to enable multi-realm mode: + +**Option 1: File Path** +```bash +REALM_CONFIG=/etc/ldap-gateway/realms.json +``` + +**Option 2: Inline JSON** +```bash +REALM_CONFIG='[{"name":"mieweb","baseDn":"dc=mieweb,dc=com",...}]' +``` + +### Realm Configuration Structure + +**`realms.json` example:** + +```json +[ + { + "name": "mieweb-employees", + "baseDn": "dc=mieweb,dc=com", + "default": true, + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.mieweb.com:3306/company_prod", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?", + "sqlQueryAllUsers": "SELECT * FROM users", + "sqlQueryAllGroups": "SELECT * FROM groups", + "sqlQueryGroupsByMember": "SELECT * FROM groups g WHERE JSON_CONTAINS(g.member_uids, JSON_QUOTE(?))" + } + }, + "auth": { + "backends": [ + { + "type": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.mieweb.com:3306/company_prod", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?" + } + }, + { + "type": "notification", + "options": { + "notificationUrl": "https://push.mieweb.com/notify" + } + } + ] + } + }, + { + "name": "bluehive", + "baseDn": "dc=bluehive,dc=com", + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.bluehive.com:3306/bluehive_prod", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?", + "sqlQueryAllUsers": "SELECT * FROM users", + "sqlQueryAllGroups": "SELECT * FROM groups", + "sqlQueryGroupsByMember": "SELECT * FROM groups g WHERE JSON_CONTAINS(g.member_uids, JSON_QUOTE(?))" + } + }, + "auth": { + "backends": [ + { + "type": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.bluehive.com:3306/bluehive_prod", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?" + } + } + ] + } + } +] +``` + +A full example configuration is available at [`server/realms.example.json`](../server/realms.example.json). + +### Configuration Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique realm identifier | +| `baseDn` | string | Yes | LDAP base DN for this realm (must be unique across all realms) | +| `default` | boolean | No | If `true`, this realm's baseDN is advertised as `defaultNamingContext` in RootDSE (for SSSD discovery). Only one realm may be marked as default. | +| `directory.backend` | string | Yes | Directory provider type (`sql`, `mongo`, `proxmox`) | +| `directory.options` | object | No | Provider-specific options (connection strings, queries) | +| `auth.backends[]` | array | Yes | Ordered list of auth backends | +| `auth.backends[].type` | string | Yes | Auth provider type (`sql`, `notification`, etc.) | +| `auth.backends[].options` | object | No | Provider-specific auth options | + +### Legacy Single-Realm Mode (Backward Compatible) + +If `REALM_CONFIG` is **not set**, the gateway operates in legacy mode using flat environment variables: + +```bash +AUTH_BACKENDS=sql,notification +DIRECTORY_BACKEND=sql +LDAP_BASE_DN=dc=mieweb,dc=com +SQL_URI=mysql://user:pass@localhost:3306/ldap_db +``` + +The gateway automatically wraps these into a single realm named `"default"`. No code or client changes are needed. + +## Per-User Authentication Override + +Individual users can override their realm's default authentication chain via the `auth_backends` database column. This allows fine-grained control — for example, service accounts can skip MFA while employees go through the full chain. + +### Database Schema + +Add to your user table: + +```sql +ALTER TABLE users ADD COLUMN auth_backends VARCHAR(255) NULL + COMMENT 'Comma-separated auth backend types. NULL = use realm default.'; +``` + +### How Override Resolution Works + +When a user authenticates, the gateway checks the `auth_backends` field on their user record. Resolution is **strictly realm-scoped** — the override can only reference backend types configured in the user's own realm: + +```mermaid +flowchart TD + START["User has auth_backends = 'sql'"] --> PARSE["Split: ['sql']"] + PARSE --> LOOP["For each backend name:"] + + LOOP --> STEP1["Look up in realm.authBackendTypes\n(type name → provider instance)"] + STEP1 -->|"found"| USE["Use this provider"] + STEP1 -->|"not found"| FAIL["Deny authentication:\nunknown backend"] + + USE --> CHAIN["Add to auth chain"] + CHAIN --> NEXT{"More\nbackends?"} + NEXT -->|"yes"| LOOP + NEXT -->|"no"| AUTH["Run auth chain sequentially"] +``` + +Each realm maintains an `authBackendTypes` map built at startup from the `auth.backends[].type` values in the realm config. The type names come directly from the backend module exports (e.g., `"sql"`, `"notification"`, `"ldap"`). + +**Key security property**: If `auth_backends` references a backend type not configured in the user's realm, authentication is **immediately denied**. There is no cross-realm fallback. + +If `auth_backends` is `NULL` or empty, the realm's default auth chain is used. + +### Examples + +```sql +-- Service account: skip MFA, only validate password +UPDATE users SET auth_backends = 'sql' WHERE username = 'ci-deployment-bot'; + +-- Regular employee: use realm default (sql + mfa) +UPDATE users SET auth_backends = NULL WHERE username = 'apant'; + +-- Executive: require additional hardware token +UPDATE users SET auth_backends = 'sql,mfa,hardware-token' WHERE username = 'ceo'; +``` + +> **Note:** Backend names in `auth_backends` must match the `type` values from your `auth.backends` configuration (case-insensitive). For example, if your realm has `{"type": "sql"}` and `{"type": "notification"}`, valid override values are `'sql'`, `'notification'`, or `'sql,notification'`. + +## Use Cases + +### 1. Multi-Domain Organization + +Serve users from different acquired companies with complete isolation: + +```json +[ + { + "name": "mieweb", + "baseDn": "dc=mieweb,dc=com", + "default": true, + "directory": { "backend": "sql", "options": { "database": "mieweb_users" } }, + "auth": { "backends": [{ "type": "sql" }] } + }, + { + "name": "bluehive", + "baseDn": "dc=bluehive,dc=com", + "directory": { "backend": "sql", "options": { "database": "bluehive_users" } }, + "auth": { "backends": [{ "type": "sql" }] } + } +] +``` + +### 2. Service Account MFA Bypass (Per-User Override) + +Use the `auth_backends` database column to let service accounts skip MFA within the same realm: + +```json +[ + { + "name": "company", + "baseDn": "dc=company,dc=com", + "default": true, + "directory": { "backend": "sql" }, + "auth": { "backends": [{ "type": "sql" }, { "type": "notification" }] } + } +] +``` + +```sql +-- Service account: skip MFA, only validate password +UPDATE users SET auth_backends = 'sql' WHERE username = 'ci-deployment-bot'; + +-- Regular employee: use realm default (sql + notification MFA) +UPDATE users SET auth_backends = NULL WHERE username = 'apant'; +``` + +### 3. Hybrid Authentication + +Mix database users with LDAP federation using separate baseDNs: + +```json +[ + { + "name": "local-users", + "baseDn": "dc=company,dc=com", + "default": true, + "directory": { "backend": "sql" }, + "auth": { "backends": [{ "type": "sql" }] } + }, + { + "name": "corporate-ad", + "baseDn": "dc=corp,dc=company,dc=com", + "directory": { "backend": "ldap", "options": { "host": "ad.corp.local" } }, + "auth": { "backends": [{ "type": "ldap" }] } + } +] +``` + +## Migration Guide + +### From Single-Realm to Multi-Realm + +**Before (flat environment variables):** +```bash +AUTH_BACKENDS=sql,notification +DIRECTORY_BACKEND=sql +LDAP_BASE_DN=dc=mieweb,dc=com +SQL_URI=mysql://localhost/ldap_db +``` + +**After (multi-realm config):** + +1. Create `realms.json`: +```json +[ + { + "name": "default", + "baseDn": "dc=mieweb,dc=com", + "default": true, + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://localhost/ldap_db" + } + }, + "auth": { + "backends": [ + { "type": "sql", "options": { "sqlUri": "mysql://localhost/ldap_db" } }, + { "type": "notification" } + ] + } + } +] +``` + +2. Set the environment variable: +```bash +REALM_CONFIG=/path/to/realms.json +``` + +3. Restart the gateway. Behavior is identical — zero downtime. + +### Adding New Realms + +Append to the realms array with a **unique baseDN** and restart. No changes to existing realm configurations are needed. + +## Verifying Your Configuration + +After deploying a new realm configuration: + +**1. Check startup logs for realm initialization:** +``` +Initializing multi-realm mode with 2 realm(s) +Realm 'mieweb-employees': baseDN=dc=mieweb,dc=com, directory=sql, auth=[sql, notification] (default) +Realm 'bluehive': baseDN=dc=bluehive,dc=com, directory=sql, auth=[sql] +``` + +**2. Verify RootDSE discovery (SSSD compatibility):** +```bash +ldapsearch -x -H ldaps://localhost:636 -b "" -s base "(objectClass=*)" namingContexts defaultNamingContext +# Should show all baseDNs in namingContexts and the default realm's baseDN in defaultNamingContext +``` + +**3. Test search against each baseDN:** +```bash +ldapsearch -x -H ldaps://localhost:636 -b "dc=mieweb,dc=com" "(uid=testuser)" +``` + +**4. Test authentication (bind):** +```bash +ldapwhoami -x -H ldaps://localhost:636 \ + -D "uid=testuser,ou=users,dc=mieweb,dc=com" -w password +``` + +**5. Verify realm isolation** (different baseDN should not return users from another realm): +```bash +ldapsearch -x -H ldaps://localhost:636 -b "dc=bluehive,dc=com" "(uid=mieweb-user)" +# Should return 0 results +``` + +## Troubleshooting + +### User not found during authentication + +- **Verify baseDN** matches what the client is sending (case-insensitive) — each baseDN routes to exactly one realm +- **Check directory backend connectivity** — database connection errors are logged +- **Ensure the user exists** in the realm's directory backend + +### Per-user `auth_backends` override not working + +- Verify the backend type name matches a type configured in the user's realm (lookups are case-insensitive) +- Check logs for `"Unknown auth backend"` errors — the backend must be configured in the realm's `auth.backends` array +- Ensure the `auth_backends` column value is comma-separated with no extra whitespace + +### MFA still triggering for service accounts + +- Set `auth_backends = 'sql'` on the service account's user record to skip the `notification` backend +- Verify the user record actually has the column set (not `NULL`) + +### SSSD auto-discovery not working with multiple realms + +- SSSD requires a single `defaultNamingContext` in the RootDSE. Mark one realm as `"default": true` in your `realms.json` +- If no realm is marked as default, the first realm is used + +### Startup failure: duplicate baseDN + +- Each baseDN must be unique across all realms. If you need multiple user populations under one baseDN, use a single realm with per-user `auth_backends` overrides instead + +## Security Considerations + +- **1:1 baseDN-to-realm**: Each baseDN maps to exactly one realm. There is no ambiguity about which realm handles a request — cross-realm user confusion or authentication bypass is not possible. +- **Strictly realm-scoped auth override**: Per-user `auth_backends` can only reference backend types configured in the user's own realm. Unknown backends immediately deny authentication. There is no cross-realm fallback. +- **Auth chain integrity**: All providers in the chain must succeed (sequential AND logic). A single failure rejects the bind. +- **Data isolation**: Different baseDNs provide complete directory isolation. Searches on one baseDN never return results from another realm. + +## Further Reading + +- [Example realm configuration](../server/realms.example.json) +- [Custom backend template](../server/backends/template.js) +- [Multi-realm planning document](../Multi-realm.md) diff --git a/nfpm/scripts/postinstall.sh b/nfpm/scripts/postinstall.sh index 0cc63be..04fbbf9 100755 --- a/nfpm/scripts/postinstall.sh +++ b/nfpm/scripts/postinstall.sh @@ -20,7 +20,7 @@ after_install () { } if [ "$1" = "configure" -a -z "$2" ] || \ - [ "$1" = "abort-remove" ] || \ + [ "$1" = "abort-remove" ]; \ then after_install elif [ "$1" = "configure" -a -n "$2" ]; then diff --git a/npm/src/AuthProvider.js b/npm/src/AuthProvider.js index a067acf..fa52f6c 100644 --- a/npm/src/AuthProvider.js +++ b/npm/src/AuthProvider.js @@ -3,6 +3,13 @@ * Implement this interface to add custom authentication backends */ class AuthProvider { + /** + * @param {Object} options - Provider configuration options (overrides env vars) + */ + constructor(options = {}) { + this.options = options; + } + /** * Authenticate a user with username and password * @param {string} username - The username to authenticate diff --git a/npm/src/DirectoryProvider.js b/npm/src/DirectoryProvider.js index fa0f625..5a888ff 100644 --- a/npm/src/DirectoryProvider.js +++ b/npm/src/DirectoryProvider.js @@ -3,6 +3,13 @@ * Implement this interface to add custom directory backends */ class DirectoryProvider { + /** + * @param {Object} options - Provider configuration options (overrides env vars) + */ + constructor(options = {}) { + this.options = options; + } + /** * Find a single user by username * @param {string} username - The username to search for diff --git a/npm/src/LdapEngine.js b/npm/src/LdapEngine.js index 4d303e5..07f089b 100644 --- a/npm/src/LdapEngine.js +++ b/npm/src/LdapEngine.js @@ -10,7 +10,14 @@ const { /** * Core LDAP Engine for the LDAP Gateway - * Handles LDAP server setup, bind operations, and search operations + * Handles LDAP server setup, bind operations, and search operations. + * + * Supports multi-realm mode: each realm pairs a directory backend + auth chain + * with a baseDN. Each baseDN maps to exactly one realm (1:1). Searches and binds + * are routed by baseDN to the owning realm. + * + * Backward compatible: when no `realms` option is provided, the engine wraps + * the legacy `authProviders`/`directoryProvider`/`baseDn` into one implicit realm. */ class LdapEngine extends EventEmitter { constructor(options = {}) { @@ -29,21 +36,97 @@ class LdapEngine extends EventEmitter { ...options }; - this.authProviders = options.authProviders; - this.directoryProvider = options.directoryProvider; - this.server = null; this.logger = options.logger || console; + + // Build realm data structures + this._initRealms(options); + + // Legacy single-provider refs (for backward compat in tests/external code) + this.authProviders = options.authProviders || this.allRealms[0]?.authProviders; + this.directoryProvider = options.directoryProvider || this.allRealms[0]?.directoryProvider; + + // Default realm for RootDSE defaultNamingContext (SSSD discovery) + this.defaultRealm = options.defaultRealm || this.allRealms[0] || null; + + this.server = null; this._stopping = false; } + /** + * Initialize realm data structures from options + * @private + */ + _initRealms(options) { + if (options.realms && Array.isArray(options.realms) && options.realms.length > 0) { + // Multi-realm mode: realms provided explicitly + this.allRealms = options.realms.map(r => ({ + name: r.name, + baseDn: r.baseDn, + directoryProvider: r.directoryProvider, + authProviders: r.authProviders || [], + // Explicit map of backend type name → provider instance for per-user auth override + authBackendTypes: r.authBackendTypes || new Map() + })); + } else if (options.authProviders && options.directoryProvider) { + // Legacy single-realm mode: wrap into one implicit realm + const baseDn = options.baseDn || 'dc=localhost'; + this.allRealms = [{ + name: 'default', + baseDn, + directoryProvider: options.directoryProvider, + authProviders: options.authProviders, + authBackendTypes: options.authBackendTypes || new Map() + }]; + } else { + this.allRealms = []; + } + + // Warn about realms with auth providers but no type map (per-user overrides won't work) + for (const realm of this.allRealms) { + if (realm.authProviders.length > 0 && realm.authBackendTypes.size === 0) { + this.logger.warn( + `Realm '${realm.name}' has auth providers but no authBackendTypes map — per-user auth overrides will not work` + ); + } + } + + // Index realms by baseDN (lowercased) — each baseDN maps to exactly one realm + this.realmsByBaseDn = new Map(); + for (const realm of this.allRealms) { + const key = realm.baseDn.toLowerCase(); + if (this.realmsByBaseDn.has(key)) { + const existing = this.realmsByBaseDn.get(key); + throw new Error( + `Duplicate baseDN '${realm.baseDn}': realm '${realm.name}' conflicts with realm '${existing.name}'. ` + + `Each baseDN must map to exactly one realm.` + ); + } + this.realmsByBaseDn.set(key, realm); + } + } + /** * Initialize and start the LDAP server * @returns {Promise} */ async start() { - this.directoryProvider.initialize(); - for (const authProvider of this.authProviders) { - authProvider.initialize(); + if (this.allRealms.length === 0) { + throw new Error( + 'Cannot start LDAP server: no realms configured. ' + + 'Provide either a realms array or authProviders/directoryProvider.' + ); + } + + // Initialize all realm providers + for (const realm of this.allRealms) { + if (realm.directoryProvider && typeof realm.directoryProvider.initialize === 'function') { + realm.directoryProvider.initialize(); + } + for (const authProvider of realm.authProviders) { + if (typeof authProvider.initialize === 'function') { + authProvider.initialize(); + } + } } // Create server options @@ -98,9 +181,12 @@ class LdapEngine extends EventEmitter { reject(normalizedError); } else { this.logger.info(`LDAP Server listening on port ${this.config.port}`); + const baseDns = [...new Set(this.allRealms.map(r => r.baseDn))]; this.emit('started', { port: this.config.port, baseDn: this.config.baseDn, + baseDns, + realms: this.allRealms.map(r => r.name), hasCertificate: !!(this.config.certificate && this.config.key) }); resolve(); @@ -141,31 +227,31 @@ class LdapEngine extends EventEmitter { } /** - * Cleanup all configured providers + * Cleanup all configured providers across all realms * @private */ async _cleanupProviders() { - // Cleanup directory provider - if (this.directoryProvider && typeof this.directoryProvider.cleanup === 'function') { - this.logger.debug('Cleaning up directory provider...'); - try { - await this.directoryProvider.cleanup(); - this.logger.debug('Directory provider cleaned up'); - } catch (err) { - this.logger.error('Error cleaning up directory provider:', err); + for (const realm of this.allRealms) { + // Cleanup directory provider + if (realm.directoryProvider && typeof realm.directoryProvider.cleanup === 'function') { + this.logger.debug(`Cleaning up directory provider for realm '${realm.name}'...`); + try { + await realm.directoryProvider.cleanup(); + this.logger.debug(`Directory provider for realm '${realm.name}' cleaned up`); + } catch (err) { + this.logger.error(`Error cleaning up directory provider for realm '${realm.name}':`, err); + } } - } - // Cleanup all auth providers - if (this.authProviders && Array.isArray(this.authProviders)) { - for (const [index, authProvider] of this.authProviders.entries()) { + // Cleanup auth providers + for (const [index, authProvider] of realm.authProviders.entries()) { if (authProvider && typeof authProvider.cleanup === 'function') { - this.logger.debug(`Cleaning up auth provider ${index + 1}...`); + this.logger.debug(`Cleaning up auth provider ${index + 1} for realm '${realm.name}'...`); try { await authProvider.cleanup(); - this.logger.debug(`Auth provider ${index + 1} cleaned up`); + this.logger.debug(`Auth provider ${index + 1} for realm '${realm.name}' cleaned up`); } catch (err) { - this.logger.error(`Error cleaning up auth provider ${index + 1}:`, err); + this.logger.error(`Error cleaning up auth provider ${index + 1} for realm '${realm.name}':`, err); } } } @@ -174,6 +260,7 @@ class LdapEngine extends EventEmitter { /** * Setup bind handlers for authentication + * Registers one handler per unique baseDN across all realms. * @private */ _setupBindHandlers() { @@ -186,54 +273,149 @@ class LdapEngine extends EventEmitter { return next(); }); - // Authenticated bind - catch all DNs under our base - this.server.bind(this.config.baseDn, (req, res, next) => { - const { username, password } = this._extractCredentials(req); - this.logger.debug("Authenticated bind request", { username, dn: req.dn.toString() }); + // Register one bind handler per baseDN (1:1 with realm) + for (const [baseDn, realm] of this.realmsByBaseDn) { + this.server.bind(baseDn, (req, res, next) => { + const { username, password } = this._extractCredentials(req); + this.logger.debug("Authenticated bind request", { username, dn: req.dn.toString() }); + + this.emit('bindRequest', { username, anonymous: false }); + + this._authenticateInRealm(realm, username, password, req) + .then(({ authenticated }) => { + if (!authenticated) { + this.emit('bindFail', { username, reason: 'invalid_credentials' }); + const error = new ldap.InvalidCredentialsError('Invalid credentials'); + return next(error); + } + + this.logger.debug(`User ${username} authenticated via realm '${realm.name}'`); + this.emit('bindSuccess', { username, anonymous: false, realm: realm.name }); + res.end(); + return next(); + }) + .catch(error => { + this.logger.error("Bind error", { error, username }); + const { normalizeAuthError } = require('./utils/errorUtils'); + const normalizedError = normalizeAuthError(error); + this.emit('bindError', { username, error: normalizedError }); + return next(normalizedError); + }); + }); + } + } + + /** + * Authenticate a user within a single realm. + * Looks up the user in the realm's directory, resolves the auth chain + * (per-user override or realm default), and authenticates sequentially. + * + * @private + * @param {Object} realm - The realm to authenticate against + * @param {string} username + * @param {string} password + * @param {Object} req - LDAP request + * @returns {Promise<{authenticated: boolean}>} + */ + async _authenticateInRealm(realm, username, password, req) { + let user; + try { + user = await realm.directoryProvider.findUser(username); + } catch (err) { + this.logger.error(`Error finding user '${username}' in realm '${realm.name}':`, err); + return { authenticated: false }; + } + + if (!user) { + this.logger.debug(`User '${username}' not found in realm '${realm.name}'`); + return { authenticated: false }; + } + + // Resolve the auth chain: per-user override or realm default + const authChain = this._resolveAuthChain(realm, user, username); + + // Reject auth if no providers are configured (directory-only realm) + if (authChain.length === 0) { + this.logger.warn(`Realm '${realm.name}' has no auth providers configured — rejecting bind for '${username}'`); + return { authenticated: false }; + } + + // Authenticate sequentially against the resolved auth chain + for (const provider of authChain) { + const result = await provider.authenticate(username, password, req); + if (result !== true) { + return { authenticated: false }; + } + } + + return { authenticated: true }; + } + + /** + * Resolve the auth provider chain for a user. + * + * If the user record has an `auth_backends` field (comma-separated provider + * type names), look up each name in the realm's authBackendTypes map. + * If `auth_backends` is null/undefined/empty, fall back to the realm's + * default auth providers. + * + * Resolution is strictly realm-scoped — if a backend name cannot be resolved + * within the realm, authentication fails immediately. + * + * @private + * @param {Object} realm - The matched realm + * @param {Object} user - The user record from the directory provider + * @param {string} username - Username (for logging) + * @returns {Array} Array of auth providers to authenticate against + */ + _resolveAuthChain(realm, user, username) { + const userBackends = user.auth_backends; - this.emit('bindRequest', { username, anonymous: false }); + if (!userBackends || (typeof userBackends === 'string' && userBackends.trim() === '')) { + // No per-user override — use realm defaults + return realm.authProviders; + } + + // Parse comma-separated backend names + const backendNames = typeof userBackends === 'string' + ? userBackends.split(',').map(s => s.trim()).filter(Boolean) + : []; + + if (backendNames.length === 0) { + return realm.authProviders; + } + + // Resolve each name strictly from the realm's auth backend types + const overrideChain = []; + for (const name of backendNames) { + const normalizedName = name.toLowerCase(); + const provider = realm.authBackendTypes.get(normalizedName); - // Authenticate against all auth providers sequentially - all must return true - // Stop on first failure to prevent subsequent providers from executing - const authenticateSequentially = async () => { - for (const provider of this.authProviders) { - const result = await provider.authenticate(username, password, req); - if (result !== true) { - return false; - } - } - return true; - }; - - return authenticateSequentially() - .then(isAuthenticated => { - if (!isAuthenticated) { - this.emit('bindFail', { username, reason: 'invalid_credentials' }); - const error = new ldap.InvalidCredentialsError('Invalid credentials'); - return next(error); - } + if (!provider) { + this.logger.error( + `User '${username}' has auth_backends='${userBackends}' but backend '${normalizedName}' ` + + `is not found in realm '${realm.name}' (available: [${[...realm.authBackendTypes.keys()].join(', ')}]). ` + + `Failing authentication for security.` + ); + throw new Error(`Unknown auth backend '${name}' for user '${username}'`); + } + overrideChain.push(provider); + } - this.emit('bindSuccess', { username, anonymous: false }); - res.end(); - return next(); - }) - .catch(error => { - this.logger.error("Bind error", { error, username }); - const { normalizeAuthError } = require('./utils/errorUtils'); - const normalizedError = normalizeAuthError(error); - this.emit('bindError', { username, error: normalizedError }); - return next(normalizedError); - }); - }); + this.logger.debug( + `User '${username}' using per-user auth override: [${backendNames.join(', ')}]` + ); + + return overrideChain; } /** - * Setup search handlers for directory operations + * Setup search handlers for directory operations. + * Registers one handler per unique baseDN across all realms. * @private */ _setupSearchHandlers() { // RootDSE handler - handles queries to empty base DN ("") per RFC 4512 section 5.1 - // This must be registered as a separate route handler this.server.search('', (req, res, next) => this._handleRootDSE(req, res, next)); // Authorization middleware (if enabled) for normal searches @@ -242,7 +424,6 @@ class LdapEngine extends EventEmitter { return next(); } - // Check if connection has authenticated bindDN (not anonymous) const bindDN = req.connection.ldap.bindDN; const bindDNStr = bindDN ? bindDN.toString() : 'null'; const isAnonymous = !bindDN || bindDNStr === 'cn=anonymous'; @@ -256,46 +437,48 @@ class LdapEngine extends EventEmitter { return next(); }; - // Search handler with authorization middleware for normal directory searches - this.server.search(this.config.baseDn, authorizeSearch, async (req, res, next) => { - const filterStr = req.filter.toString(); - this.logger.debug(`LDAP Search - Filter: ${filterStr}, Attributes: ${req.attributes}`); - - let entryCount = 0; - const startTime = Date.now(); - - try { - this.emit('searchRequest', { - filter: filterStr, - attributes: req.attributes, - baseDn: req.baseObject.toString(), - scope: req.scope - }); - - entryCount = await this._handleSearch(filterStr, req.attributes, res); - - const duration = Date.now() - startTime; - this.emit('searchResponse', { - filter: filterStr, - attributes: req.attributes, - entryCount, - duration - }); - - this.logger.debug(`Search completed: ${entryCount} entries in ${duration}ms`); - res.end(); - } catch (error) { - this.logger.error("Search error", { error, filter: filterStr }); - const { normalizeSearchError } = require('./utils/errorUtils'); - const normalizedError = normalizeSearchError(error); - this.emit('searchError', { - filter: filterStr, - error: normalizedError, - duration: Date.now() - startTime - }); - return next(normalizedError); - } - }); + // Register one search handler per baseDN (1:1 with realm) + for (const [baseDn, realm] of this.realmsByBaseDn) { + this.server.search(baseDn, authorizeSearch, async (req, res, next) => { + const filterStr = req.filter.toString(); + this.logger.debug(`LDAP Search - Filter: ${filterStr}, Attributes: ${req.attributes}`); + + let entryCount = 0; + const startTime = Date.now(); + + try { + this.emit('searchRequest', { + filter: filterStr, + attributes: req.attributes, + baseDn: req.baseObject.toString(), + scope: req.scope + }); + + entryCount = await this._handleRealmSearch(realm, filterStr, req.attributes, res); + + const duration = Date.now() - startTime; + this.emit('searchResponse', { + filter: filterStr, + attributes: req.attributes, + entryCount, + duration + }); + + this.logger.debug(`Search completed: ${entryCount} entries in ${duration}ms`); + res.end(); + } catch (error) { + this.logger.error("Search error", { error, filter: filterStr }); + const { normalizeSearchError } = require('./utils/errorUtils'); + const normalizedError = normalizeSearchError(error); + this.emit('searchError', { + filter: filterStr, + error: normalizedError, + duration: Date.now() - startTime + }); + return next(normalizedError); + } + }); + } } /** @@ -319,72 +502,71 @@ class LdapEngine extends EventEmitter { } /** - * Handle search operations with proper filter parsing and entry creation + * Handle search operations for a single realm and send results. * @private + * @param {Object} realm - Realm object with directoryProvider and baseDn + * @param {string} filterStr - LDAP filter string + * @param {Array} attributes - Requested attributes + * @param {Object} res - ldapjs response object * @returns {number} Number of entries sent */ - async _handleSearch(filterStr, attributes, res) { - let entryCount = 0; + async _handleRealmSearch(realm, filterStr, attributes, res) { + const { directoryProvider, baseDn, name: realmName } = realm; const username = getUsernameFromFilter(filterStr); + let entryCount = 0; + + const sendEntry = (entry, type) => { + this.emit('entryFound', { type, entry: entry.dn, realm: realmName }); + res.send(entry); + entryCount++; + }; // Handle specific user requests if (username) { - this.logger.debug(`Searching for specific user: ${username}`); - const user = await this.directoryProvider.findUser(username); + this.logger.debug(`[${realmName}] Searching for specific user: ${username}`); + const user = await directoryProvider.findUser(username); if (user) { - const entry = createLdapEntry(user, this.config.baseDn); - this.emit('entryFound', { type: 'user', entry: entry.dn }); - res.send(entry); - entryCount = 1; + sendEntry(createLdapEntry(user, baseDn), 'user'); } return entryCount; } // Handle all users requests if (isAllUsersRequest(filterStr, attributes)) { - this.logger.debug(`Searching for all users with filter: ${filterStr}`); - const users = await this.directoryProvider.getAllUsers(); - this.logger.debug(`Found ${users.length} users`); + this.logger.debug(`[${realmName}] Searching for all users with filter: ${filterStr}`); + const users = await directoryProvider.getAllUsers(); + this.logger.debug(`[${realmName}] Found ${users.length} users`); for (const user of users) { - const entry = createLdapEntry(user, this.config.baseDn); - this.emit('entryFound', { type: 'user', entry: entry.dn }); - res.send(entry); - entryCount++; + sendEntry(createLdapEntry(user, baseDn), 'user'); } return entryCount; } // Handle group search requests if (isGroupSearchRequest(filterStr, attributes)) { - this.logger.debug(`Searching for groups with filter: ${filterStr}`); - const groups = await this.directoryProvider.findGroups(filterStr); - this.logger.debug(`Found ${groups.length} groups`); + this.logger.debug(`[${realmName}] Searching for groups with filter: ${filterStr}`); + const groups = await directoryProvider.findGroups(filterStr); + this.logger.debug(`[${realmName}] Found ${groups.length} groups`); for (const group of groups) { - const entry = createLdapGroupEntry(group, this.config.baseDn); - this.emit('entryFound', { type: 'group', entry: entry.dn }); - res.send(entry); - entryCount++; + sendEntry(createLdapGroupEntry(group, baseDn), 'group'); } return entryCount; } // Handle mixed searches (both users and groups) if (isMixedSearchRequest(filterStr)) { - this.logger.debug(`Mixed search request with filter: ${filterStr}`); + this.logger.debug(`[${realmName}] Mixed search request with filter: ${filterStr}`); - // Parse cn value from filter for filtering const cnMatch = filterStr.match(/cn=([^)&|]+)/i); const cnFilter = cnMatch ? cnMatch[1].trim() : null; const isWildcard = cnFilter === '*'; - // Return users first - const users = await this.directoryProvider.getAllUsers(); - this.logger.debug(`Found ${users.length} users for mixed search`); + const users = await directoryProvider.getAllUsers(); + this.logger.debug(`[${realmName}] Found ${users.length} users for mixed search`); for (const user of users) { - // Filter by cn if specified (cn is the user's common name) if (cnFilter && !isWildcard) { const userCn = user.firstname && user.lastname ? `${user.firstname} ${user.lastname}` @@ -394,31 +576,23 @@ class LdapEngine extends EventEmitter { } } - const entry = createLdapEntry(user, this.config.baseDn); - this.emit('entryFound', { type: 'user', entry: entry.dn }); - res.send(entry); - entryCount++; + sendEntry(createLdapEntry(user, baseDn), 'user'); } - // Then return groups - const groups = await this.directoryProvider.getAllGroups(); - this.logger.debug(`Found ${groups.length} groups for mixed search`); + const groups = await directoryProvider.getAllGroups(); + this.logger.debug(`[${realmName}] Found ${groups.length} groups for mixed search`); for (const group of groups) { - // Filter by cn if specified (cn is the group name) if (cnFilter && !isWildcard && group.name.toLowerCase() !== cnFilter.toLowerCase()) { continue; } - const entry = createLdapGroupEntry(group, this.config.baseDn); - this.emit('entryFound', { type: 'group', entry: entry.dn }); - res.send(entry); - entryCount++; + sendEntry(createLdapGroupEntry(group, baseDn), 'group'); } return entryCount; } - this.logger.debug(`No matching search pattern found for filter: ${filterStr}`); + this.logger.debug(`[${realmName}] No matching search pattern found for filter: ${filterStr}`); return entryCount; } @@ -439,40 +613,51 @@ class LdapEngine extends EventEmitter { try { // Check scope - ldapjs uses numeric constants: 0='base', 1='one', 2='sub' - // We check both forms for compatibility with different ldapjs versions if (scope === 'base' || scope === 0) { this.emit('rootDSERequest', { filter: filterStr, attributes: requestedAttrs }); - // Determine which attributes to return based on request + // Collect unique baseDNs (preserving original casing from realm config) + const allBaseDns = this.allRealms.map(r => r.baseDn); + const defaultBaseDn = this.defaultRealm ? this.defaultRealm.baseDn : allBaseDns[0]; + // RootDSE attribute filtering rules (per RFC 4512): - // - No attributes = all attributes (both user and operational) - // - '*' with '+' = all user and operational attributes - // - '+' only = operational attributes only (namingContexts, supportedLDAPVersion) + objectClass - // - '*' only or '*' with specific names = user attributes + any specifically requested operational attributes - // - Specific names only = only those attributes + objectClass (which is always returned) + // - No attributes requested = return all (user + operational) + // - '*' + '+' = all user and operational attributes + // - '+' only = operational attributes only + // - '*' only = user attrs + specifically requested operational attrs + // - Specific names only = only those attributes const hasWildcard = requestedAttrs.includes('*'); const hasPlus = requestedAttrs.includes('+'); const noAttrsRequested = requestedAttrs.length === 0; + + // Helper to populate operational attributes into the attributes object + const addOperationalAttr = (attrLower, attributes) => { + if (attrLower === 'namingcontexts') { + attributes.namingContexts = allBaseDns; + } else if (attrLower === 'defaultnamingcontext' && defaultBaseDn) { + attributes.defaultNamingContext = defaultBaseDn; + } else if (attrLower === 'supportedldapversion') { + attributes.supportedLDAPVersion = ['3']; + } + }; - // Build the entry attributes const attributes = { - objectClass: ['top'] // objectClass is always returned + objectClass: ['top'] }; - // Determine what to include - if (hasWildcard && !hasPlus) { - // Specific attributes requested (no wildcards) - requestedAttrs.forEach(attr => { - const attrLower = attr.toLowerCase(); - if (attrLower === 'namingcontexts') { - attributes.namingContexts = [this.config.baseDn]; - } else if (attrLower === 'supportedldapversion') { - attributes.supportedLDAPVersion = ['3']; - } - }); - } else { - attributes.namingContexts = [this.config.baseDn]; + if (noAttrsRequested || (hasWildcard && hasPlus) || (hasPlus && !hasWildcard)) { + // Return all operational attributes + attributes.namingContexts = allBaseDns; + if (defaultBaseDn) { + attributes.defaultNamingContext = defaultBaseDn; + } attributes.supportedLDAPVersion = ['3']; + } else if (hasWildcard) { + // '*' only: user attrs + specifically requested operational attrs + requestedAttrs.forEach(attr => addOperationalAttr(attr.toLowerCase(), attributes)); + } else { + // Specific attributes only — return only what was requested + requestedAttrs.forEach(attr => addOperationalAttr(attr.toLowerCase(), attributes)); } const rootDSEEntry = { @@ -488,7 +673,7 @@ class LdapEngine extends EventEmitter { // Replace '+' with actual operational attribute names (lowercase for ldapjs matching) const idx = res.attributes.indexOf('+'); if (idx !== -1) { - res.attributes.splice(idx, 1, 'namingcontexts', 'supportedldapversion'); + res.attributes.splice(idx, 1, 'namingcontexts', 'defaultnamingcontext', 'supportedldapversion'); } } else if (requestedAttrs.length > 0 && !hasWildcard) { // For specific attribute requests, add them to res.attributes in lowercase diff --git a/npm/test/fixtures/mockProviders.js b/npm/test/fixtures/mockProviders.js index 4218cc9..e9ca52a 100644 --- a/npm/test/fixtures/mockProviders.js +++ b/npm/test/fixtures/mockProviders.js @@ -11,7 +11,7 @@ const { testUsers, testGroups } = require('./testData'); // Simple implementation for testing auth flows class MockAuthProvider extends AuthProvider { constructor(options = {}) { - super(); + super(options); this.name = options.name || 'mock-auth'; this.shouldSucceed = options.shouldSucceed !== undefined ? options.shouldSucceed : true; this.delay = options.delay || 0; @@ -56,7 +56,7 @@ class MockAuthProvider extends AuthProvider { // Simple implementation for testing directory lookups class MockDirectoryProvider extends DirectoryProvider { constructor(options = {}) { - super(); + super(options); this.name = options.name || 'mock-directory'; this.users = options.users || testUsers.map(u => ({ ...u })); this.groups = options.groups || testGroups.map(g => ({ ...g })); @@ -155,7 +155,7 @@ class MockDirectoryProvider extends DirectoryProvider { // Always succeeds if notification succeeds, regardless of password class MockNotificationAuthProvider extends AuthProvider { constructor(options = {}) { - super(); + super(options); this.name = 'mock-notification'; this.notificationShouldSucceed = options.notificationShouldSucceed !== undefined ? options.notificationShouldSucceed diff --git a/npm/test/fixtures/testData.js b/npm/test/fixtures/testData.js index 8ad4446..737124d 100644 --- a/npm/test/fixtures/testData.js +++ b/npm/test/fixtures/testData.js @@ -38,6 +38,19 @@ const testUsers = [ userPassword: 'test123', homeDirectory: '/home/jdoe', loginShell: '/bin/bash' + }, + { + username: 'mfauser', + uid: 1003, + gidNumber: 1001, + cn: 'MFA User', + sn: 'MFA', + givenName: 'MFA', + mail: 'mfauser@example.com', + userPassword: 'mfa123', + homeDirectory: '/home/mfauser', + loginShell: '/bin/bash', + auth_backends: 'mock-auth' } ]; diff --git a/npm/test/unit/LdapEngine.realms.test.js b/npm/test/unit/LdapEngine.realms.test.js new file mode 100644 index 0000000..387d22c --- /dev/null +++ b/npm/test/unit/LdapEngine.realms.test.js @@ -0,0 +1,721 @@ +// Unit Tests for LdapEngine Multi-Realm Support +// Tests realm routing, search aggregation, and backward compatibility + +const LdapEngine = require('../../src/LdapEngine'); +const { MockAuthProvider, MockDirectoryProvider, MockNotificationAuthProvider } = require('../fixtures/mockProviders'); +const { baseDN } = require('../fixtures/testData'); +const net = require('net'); +const ldap = require('ldapjs'); + +function canConnect(port, host = '127.0.0.1', timeoutMs = 500) { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + const cleanup = () => { socket.removeAllListeners(); socket.destroy(); }; + socket.setTimeout(timeoutMs); + socket.once('connect', () => { cleanup(); resolve(true); }); + socket.once('timeout', () => { cleanup(); reject(new Error('Timeout')); }); + socket.once('error', (err) => { cleanup(); reject(err); }); + socket.connect(port, host); + }); +} + +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() +}; + +// Helper to create an ldap client for testing +function createClient(port) { + return ldap.createClient({ + url: `ldap://127.0.0.1:${port}`, + timeout: 5000, + connectTimeout: 5000 + }); +} + +// Promisified ldap client operations +function bindAsync(client, dn, password) { + return new Promise((resolve, reject) => { + client.bind(dn, password, (err) => err ? reject(err) : resolve()); + }); +} + +function searchAsync(client, base, opts) { + return new Promise((resolve, reject) => { + const entries = []; + client.search(base, opts, (err, res) => { + if (err) return reject(err); + res.on('searchEntry', (entry) => entries.push(entry)); + res.on('error', (err) => reject(err)); + res.on('end', () => resolve(entries)); + }); + }); +} + +function unbindAsync(client) { + return new Promise((resolve) => { + client.unbind((err) => resolve()); + }); +} + +describe('LdapEngine Multi-Realm', () => { + let engine; + const TEST_PORT = 3895; + + afterEach(async () => { + if (engine && engine.server) { + await engine.stop(); + } + }); + + describe('Initialization (_initRealms)', () => { + test('should initialize with explicit realms array', () => { + const auth1 = new MockAuthProvider({ name: 'realm1-auth' }); + const dir1 = new MockDirectoryProvider({ name: 'realm1-dir' }); + const auth2 = new MockAuthProvider({ name: 'realm2-auth' }); + const dir2 = new MockDirectoryProvider({ name: 'realm2-dir' }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { + name: 'company-a', + baseDn: 'dc=company-a,dc=com', + directoryProvider: dir1, + authProviders: [auth1] + }, + { + name: 'company-b', + baseDn: 'dc=company-b,dc=com', + directoryProvider: dir2, + authProviders: [auth2] + } + ] + }); + + expect(engine.allRealms).toHaveLength(2); + expect(engine.realmsByBaseDn.size).toBe(2); + expect(engine.realmsByBaseDn.has('dc=company-a,dc=com')).toBe(true); + expect(engine.realmsByBaseDn.has('dc=company-b,dc=com')).toBe(true); + }); + + test('should reject duplicate baseDN across realms', () => { + const auth1 = new MockAuthProvider({ name: 'realm1-auth' }); + const dir1 = new MockDirectoryProvider({ name: 'realm1-dir' }); + const auth2 = new MockAuthProvider({ name: 'realm2-auth' }); + const dir2 = new MockDirectoryProvider({ name: 'realm2-dir' }); + + expect(() => new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { + name: 'realm-1', + baseDn: baseDN, + directoryProvider: dir1, + authProviders: [auth1] + }, + { + name: 'realm-2', + baseDn: baseDN, + directoryProvider: dir2, + authProviders: [auth2] + } + ] + })).toThrow(/Duplicate baseDN/); + }); + + test('should auto-wrap legacy options into single default realm', () => { + const auth = new MockAuthProvider(); + const dir = new MockDirectoryProvider(); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + baseDn: baseDN, + authProviders: [auth], + directoryProvider: dir, + logger: mockLogger + }); + + expect(engine.allRealms).toHaveLength(1); + expect(engine.allRealms[0].name).toBe('default'); + expect(engine.allRealms[0].baseDn).toBe(baseDN); + expect(engine.realmsByBaseDn.size).toBe(1); + // Legacy references should be preserved + expect(engine.authProviders).toEqual([auth]); + expect(engine.directoryProvider).toBe(dir); + }); + + test('should normalize baseDN to lowercase in realmsByBaseDn keys', () => { + const auth = new MockAuthProvider(); + const dir = new MockDirectoryProvider(); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { + name: 'test', + baseDn: 'DC=Example,DC=Com', + directoryProvider: dir, + authProviders: [auth] + } + ] + }); + + expect(engine.realmsByBaseDn.has('dc=example,dc=com')).toBe(true); + }); + }); + + describe('Multi-Realm Bind', () => { + test('should authenticate against the correct realm by baseDN', async () => { + const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; + const usersB = [{ username: 'bob', uid_number: 3001, gid_number: 3000, first_name: 'Bob', last_name: 'B' }]; + + const baseDnA = 'dc=company-a,dc=com'; + const baseDnB = 'dc=company-b,dc=com'; + + const authA = new MockAuthProvider({ + name: 'auth-a', + validCredentials: new Map([['alice', 'pass-a']]) + }); + const dirA = new MockDirectoryProvider({ name: 'dir-a', users: usersA, groups: [] }); + + const authB = new MockAuthProvider({ + name: 'auth-b', + validCredentials: new Map([['bob', 'pass-b']]) + }); + const dirB = new MockDirectoryProvider({ name: 'dir-b', users: usersB, groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'realm-a', baseDn: baseDnA, directoryProvider: dirA, authProviders: [authA] }, + { name: 'realm-b', baseDn: baseDnB, directoryProvider: dirB, authProviders: [authB] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + // Alice should authenticate through realm-a + await bindAsync(client, `uid=alice,ou=users,${baseDnA}`, 'pass-a'); + expect(authA.callCount).toBe(1); + expect(authB.callCount).toBe(0); // realm-b should NOT be tried + + // Bob should authenticate through realm-b + authA.reset(); + const client2 = createClient(TEST_PORT); + try { + await bindAsync(client2, `uid=bob,ou=users,${baseDnB}`, 'pass-b'); + expect(dirB.callCounts.findUser).toBeGreaterThanOrEqual(1); + expect(authB.callCount).toBe(1); + } finally { + await unbindAsync(client2); + } + } finally { + await unbindAsync(client); + } + }); + + test('should reject bind when user not found in any realm', async () => { + const authA = new MockAuthProvider({ name: 'auth-a' }); + const dirA = new MockDirectoryProvider({ name: 'dir-a', users: [], groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [authA] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + await expect( + bindAsync(client, `uid=nonexistent,ou=users,${baseDN}`, 'anypass') + ).rejects.toThrow(); + } finally { + await unbindAsync(client); + } + }); + }); + + describe('Multi-Realm Search', () => { + test('should search different baseDNs independently', async () => { + const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; + const usersB = [{ username: 'bob', uid_number: 3001, gid_number: 3000, first_name: 'Bob', last_name: 'B' }]; + + const baseDnA = 'dc=company-a,dc=com'; + const baseDnB = 'dc=company-b,dc=com'; + + const dirA = new MockDirectoryProvider({ name: 'dir-a', users: usersA, groups: [] }); + const dirB = new MockDirectoryProvider({ name: 'dir-b', users: usersB, groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + requireAuthForSearch: false, + realms: [ + { name: 'company-a', baseDn: baseDnA, directoryProvider: dirA, authProviders: [new MockAuthProvider()] }, + { name: 'company-b', baseDn: baseDnB, directoryProvider: dirB, authProviders: [new MockAuthProvider()] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + // Search company-a - should only get alice + const entriesA = await searchAsync(client, baseDnA, { + filter: '(objectClass=posixAccount)', + scope: 'sub' + }); + expect(entriesA.length).toBe(1); + + // Search company-b - should only get bob + const entriesB = await searchAsync(client, baseDnB, { + filter: '(objectClass=posixAccount)', + scope: 'sub' + }); + expect(entriesB.length).toBe(1); + } finally { + await unbindAsync(client); + } + }); + }); + + describe('RootDSE Multi-Realm', () => { + test('should return all baseDNs in namingContexts', async () => { + const baseDnA = 'dc=company-a,dc=com'; + const baseDnB = 'dc=company-b,dc=com'; + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + requireAuthForSearch: false, + realms: [ + { name: 'company-a', baseDn: baseDnA, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] }, + { name: 'company-b', baseDn: baseDnB, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + const entries = await searchAsync(client, '', { + filter: '(objectClass=*)', + scope: 'base', + attributes: ['+'] + }); + + expect(entries.length).toBe(1); + const rootDSE = entries[0]; + + // ldapjs returns attributes as array of {type, values} objects + const ncAttr = rootDSE.attributes.find(a => a.type === 'namingContexts'); + const contexts = ncAttr ? ncAttr.values : []; + + // Should contain both baseDNs + expect(contexts).toContain(baseDnA); + expect(contexts).toContain(baseDnB); + } finally { + await unbindAsync(client); + } + }); + }); + + describe('Backward Compatibility', () => { + test('should work identically with legacy single-provider options', async () => { + const auth = new MockAuthProvider(); + const testUsers = [ + { username: 'testuser', uid_number: 1001, gid_number: 1001, first_name: 'Test', last_name: 'User' }, + { username: 'admin', uid_number: 1000, gid_number: 1000, first_name: 'Admin', last_name: 'User' } + ]; + const dir = new MockDirectoryProvider({ users: testUsers, groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + baseDn: baseDN, + authProviders: [auth], + directoryProvider: dir, + logger: mockLogger, + requireAuthForSearch: false + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + // Bind should work + await bindAsync(client, `uid=testuser,ou=users,${baseDN}`, 'password123'); + expect(auth.callCount).toBe(1); + + // Search should work + const entries = await searchAsync(client, baseDN, { + filter: '(uid=testuser)', + scope: 'sub' + }); + expect(entries.length).toBe(1); + } finally { + await unbindAsync(client); + } + }); + + test('should preserve legacy directoryProvider and authProviders refs', () => { + const auth = new MockAuthProvider(); + const dir = new MockDirectoryProvider(); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + baseDn: baseDN, + authProviders: [auth], + directoryProvider: dir, + logger: mockLogger + }); + + expect(engine.directoryProvider).toBe(dir); + expect(engine.authProviders).toEqual([auth]); + }); + }); + + describe('Started Event', () => { + test('should emit started event with baseDns and realm names', async () => { + const startedInfo = await new Promise(async (resolve) => { + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'realm-a', baseDn: 'dc=a,dc=com', directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] }, + { name: 'realm-b', baseDn: 'dc=b,dc=com', directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + ] + }); + + engine.on('started', (info) => resolve(info)); + await engine.start(); + }); + + expect(startedInfo.baseDns).toContain('dc=a,dc=com'); + expect(startedInfo.baseDns).toContain('dc=b,dc=com'); + expect(startedInfo.realms).toContain('realm-a'); + expect(startedInfo.realms).toContain('realm-b'); + }); + }); + + describe('Per-User Auth Override (Phase 3)', () => { + describe('_resolveAuthChain', () => { + test('should return realm default providers when user has no auth_backends', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + const realm = engine.allRealms[0]; + const user = { username: 'testuser', auth_backends: null }; + + const chain = engine._resolveAuthChain(realm, user, 'testuser'); + expect(chain).toEqual([realmAuth]); + }); + + test('should return realm default providers when auth_backends is empty string', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: '' }, 'testuser'); + expect(chain).toEqual([realmAuth]); + }); + + test('should return realm default providers when auth_backends is undefined', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser' }, 'testuser'); + expect(chain).toEqual([realmAuth]); + }); + + test('should resolve per-user override from realm authBackendTypes', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + const overrideAuth = new MockAuthProvider({ name: 'custom-auth' }); + const authBackendTypes = new Map([['custom-auth', overrideAuth]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth], authBackendTypes } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'custom-auth' }, 'testuser'); + expect(chain).toEqual([overrideAuth]); + expect(chain).not.toContain(realmAuth); + }); + + test('should resolve multiple comma-separated backends', () => { + const providerA = new MockAuthProvider({ name: 'auth-a' }); + const providerB = new MockAuthProvider({ name: 'auth-b' }); + const authBackendTypes = new Map([['auth-a', providerA], ['auth-b', providerB]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()], authBackendTypes } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'auth-a, auth-b' }, 'testuser'); + expect(chain).toHaveLength(2); + expect(chain[0]).toBe(providerA); + expect(chain[1]).toBe(providerB); + }); + + test('should throw for unknown backend in auth_backends (fail-loud)', () => { + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()], authBackendTypes: new Map() } + ] + }); + + expect(() => { + engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'nonexistent' }, 'testuser'); + }).toThrow("Unknown auth backend 'nonexistent'"); + }); + + test('should resolve auth_backends case-insensitively', () => { + const sqlProvider = new MockAuthProvider({ name: 'sql-provider' }); + const authBackendTypes = new Map([['sql', sqlProvider]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()], authBackendTypes } + ] + }); + + // User record has uppercase auth_backends value + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'SQL' }, 'testuser'); + expect(chain).toHaveLength(1); + expect(chain[0]).toBe(sqlProvider); + }); + }); + + describe('End-to-end per-user auth override', () => { + test('should use per-user auth override for bind when auth_backends is set', async () => { + const overrideAuth = new MockAuthProvider({ + name: 'override-auth', + validCredentials: new Map([['mfauser', 'override-pass']]) + }); + const realmAuth = new MockAuthProvider({ + name: 'realm-auth', + validCredentials: new Map([['testuser', 'password123']]) + }); + + const users = [ + { username: 'testuser', uid_number: 1001, gid_number: 1001, first_name: 'Test', last_name: 'User' }, + { username: 'mfauser', uid_number: 1003, gid_number: 1001, first_name: 'MFA', last_name: 'User', auth_backends: 'override-auth' } + ]; + + const authBackendTypes = new Map([['override-auth', overrideAuth], ['realm-auth', realmAuth]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [realmAuth], authBackendTypes } + ] + }); + + await engine.start(); + + // testuser should use realm default auth (realmAuth) + const client1 = createClient(TEST_PORT); + try { + await bindAsync(client1, `uid=testuser,ou=users,${baseDN}`, 'password123'); + expect(realmAuth.callCount).toBe(1); + expect(overrideAuth.callCount).toBe(0); + } finally { + await unbindAsync(client1); + } + + realmAuth.reset(); + overrideAuth.reset(); + + // mfauser should use per-user override auth (overrideAuth) + const client2 = createClient(TEST_PORT); + try { + await bindAsync(client2, `uid=mfauser,ou=users,${baseDN}`, 'override-pass'); + expect(overrideAuth.callCount).toBe(1); + expect(realmAuth.callCount).toBe(0); // realm auth should NOT be called + } finally { + await unbindAsync(client2); + } + }); + + test('should reject bind when per-user override auth fails', async () => { + const overrideAuth = new MockAuthProvider({ + name: 'override-auth', + validCredentials: new Map([['mfauser', 'correct-pass']]) + }); + + const users = [ + { username: 'mfauser', uid_number: 1003, gid_number: 1001, first_name: 'MFA', last_name: 'User', auth_backends: 'override-auth' } + ]; + + const authBackendTypes = new Map([['override-auth', overrideAuth]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [new MockAuthProvider()], authBackendTypes } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + await expect( + bindAsync(client, `uid=mfauser,ou=users,${baseDN}`, 'wrong-pass') + ).rejects.toThrow(); + expect(overrideAuth.callCount).toBe(1); + } finally { + await unbindAsync(client); + } + }); + + test('should reject bind when auth_backends references unknown provider', async () => { + const users = [ + { username: 'baduser', uid_number: 1099, gid_number: 1001, first_name: 'Bad', last_name: 'User', auth_backends: 'nonexistent-backend' } + ]; + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [new MockAuthProvider()], authBackendTypes: new Map() } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + await expect( + bindAsync(client, `uid=baduser,ou=users,${baseDN}`, 'anypass') + ).rejects.toThrow(); + } finally { + await unbindAsync(client); + } + }); + + test('should use MFA bypass pattern: per-user override skips notification provider', async () => { + // Realm default: sql + notification (MFA) + const sqlAuth = new MockAuthProvider({ + name: 'sql-auth', + validCredentials: new Map([['normaluser', 'pass123'], ['serviceuser', 'svc-pass']]) + }); + const notificationAuth = new MockNotificationAuthProvider({ + notificationShouldSucceed: true + }); + + // Service user has auth_backends='sql-auth' — skips notification MFA + const users = [ + { username: 'normaluser', uid_number: 2001, gid_number: 2000, first_name: 'Normal', last_name: 'User' }, + { username: 'serviceuser', uid_number: 2002, gid_number: 2000, first_name: 'Service', last_name: 'Account', auth_backends: 'sql-auth' } + ]; + + const authBackendTypes = new Map([['sql-auth', sqlAuth], ['notification', notificationAuth]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { + name: 'mfa-realm', + baseDn: baseDN, + directoryProvider: new MockDirectoryProvider({ users, groups: [] }), + authProviders: [sqlAuth, notificationAuth], + authBackendTypes + } + ] + }); + + await engine.start(); + + // Normal user goes through both sql + notification + const client1 = createClient(TEST_PORT); + try { + await bindAsync(client1, `uid=normaluser,ou=users,${baseDN}`, 'pass123'); + expect(sqlAuth.callCount).toBe(1); + expect(notificationAuth.callCount).toBe(1); + } finally { + await unbindAsync(client1); + } + + sqlAuth.reset(); + notificationAuth.callCount = 0; + + // Service user only goes through sql (MFA bypassed) + const client2 = createClient(TEST_PORT); + try { + await bindAsync(client2, `uid=serviceuser,ou=users,${baseDN}`, 'svc-pass'); + expect(sqlAuth.callCount).toBe(1); + expect(notificationAuth.callCount).toBe(0); // MFA skipped! + } finally { + await unbindAsync(client2); + } + }); + }); + }); +}); diff --git a/server/backends/custom-auth.example.js b/server/backends/custom-auth.example.js index 2563347..ea3ae3c 100644 --- a/server/backends/custom-auth.example.js +++ b/server/backends/custom-auth.example.js @@ -17,12 +17,12 @@ const http = require('http'); class ApiAuthBackend extends AuthProvider { constructor(options = {}) { - super(); + super(options); - // Load configuration from environment - this.apiUrl = process.env.API_AUTH_URL || 'https://api.example.com/auth'; - this.apiToken = process.env.API_AUTH_TOKEN; - this.timeout = parseInt(process.env.API_AUTH_TIMEOUT || '5000', 10); + // Use options with env var fallback — enables multi-realm support + this.apiUrl = options.apiUrl ?? process.env.API_AUTH_URL ?? 'https://api.example.com/auth'; + this.apiToken = options.apiToken ?? process.env.API_AUTH_TOKEN; + this.timeout = options.timeout ?? parseInt(process.env.API_AUTH_TIMEOUT || '5000', 10); if (!this.apiUrl) { console.warn('[ApiAuthBackend] No API_AUTH_URL configured'); diff --git a/server/backends/custom-directory.example.js b/server/backends/custom-directory.example.js index 17b55fb..cf3a1ba 100644 --- a/server/backends/custom-directory.example.js +++ b/server/backends/custom-directory.example.js @@ -40,11 +40,11 @@ const path = require('path'); class JsonDirectoryBackend extends DirectoryProvider { constructor(options = {}) { - super(); + super(options); - // Load configuration from environment - this.usersPath = process.env.JSON_USERS_PATH || path.join(process.cwd(), 'users.json'); - this.groupsPath = process.env.JSON_GROUPS_PATH || path.join(process.cwd(), 'groups.json'); + // Use options with env var fallback — enables multi-realm support + this.usersPath = options.usersPath ?? process.env.JSON_USERS_PATH ?? path.join(process.cwd(), 'users.json'); + this.groupsPath = options.groupsPath ?? process.env.JSON_GROUPS_PATH ?? path.join(process.cwd(), 'groups.json'); // Cache for loaded data this.usersCache = null; @@ -52,7 +52,7 @@ class JsonDirectoryBackend extends DirectoryProvider { this.lastLoad = null; // Cache duration (5 minutes) - this.cacheDuration = parseInt(process.env.JSON_CACHE_DURATION || '300000', 10); + this.cacheDuration = options.cacheDuration ?? parseInt(process.env.JSON_CACHE_DURATION || '300000', 10); console.log(`[JsonDirectoryBackend] Initialized with users: ${this.usersPath}, groups: ${this.groupsPath}`); } diff --git a/server/backends/ldap.auth.js b/server/backends/ldap.auth.js index fedc056..69d7687 100644 --- a/server/backends/ldap.auth.js +++ b/server/backends/ldap.auth.js @@ -5,8 +5,11 @@ const resolveLDAPHosts = require('../utils/resolveLdapHosts'); const logger = require('../utils/logger'); class LDAPBackend extends AuthProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); + this.ldapBindDn = options.ldapBindDn ?? process.env.LDAP_BIND_DN; + this.ldapBindPassword = options.ldapBindPassword ?? process.env.LDAP_BIND_PASSWORD; + this.ldapAuthBaseDn = options.ldapAuthBaseDn ?? process.env.LDAP_AUTH_BASE_DN; this.serverPool = []; this.failedServers = new Map(); this.initialized = false; @@ -111,7 +114,7 @@ class LDAPBackend extends AuthProvider { attributes: ['dn'] }; - client.bind(process.env.LDAP_BIND_DN, process.env.LDAP_BIND_PASSWORD, (err) => { + client.bind(this.ldapBindDn, this.ldapBindPassword, (err) => { if (err) { logger.error("Service bind failed", err); return reject(new Error("Service bind failed: " + err)); @@ -119,7 +122,7 @@ class LDAPBackend extends AuthProvider { logger.debug("Service bind successful, searching for user..."); let foundDN = null; - client.search(process.env.LDAP_AUTH_BASE_DN, opts, (err, res) => { + client.search(this.ldapAuthBaseDn, opts, (err, res) => { if (err) return reject(err); res.on('searchEntry', (entry) => { diff --git a/server/backends/mongodb.auth.js b/server/backends/mongodb.auth.js index 42adb6b..0041e38 100644 --- a/server/backends/mongodb.auth.js +++ b/server/backends/mongodb.auth.js @@ -8,12 +8,12 @@ const bcrypt = require('bcrypt'); * Handles user authentication against MongoDB database */ class MongoDBAuthProvider extends AuthProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); this.config = { type: 'mongodb', - uri: process.env.MONGO_URI || "mongodb://localhost:27017/ldap_user_db", - database: process.env.MONGO_DATABASE || "ldap_user_db" + uri: options.mongoUri ?? process.env.MONGO_URI ?? "mongodb://localhost:27017/ldap_user_db", + database: options.mongoDatabase ?? process.env.MONGO_DATABASE ?? "ldap_user_db" }; this.initialized = false; } diff --git a/server/backends/mongodb.directory.js b/server/backends/mongodb.directory.js index 9b28829..7f5def6 100644 --- a/server/backends/mongodb.directory.js +++ b/server/backends/mongodb.directory.js @@ -7,13 +7,14 @@ const logger = require('../utils/logger'); * Handles user and group directory operations against MongoDB database */ class MongoDBDirectoryProvider extends DirectoryProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); this.config = { type: 'mongodb', - uri: process.env.MONGO_URI || "mongodb://localhost:27017/ldap_user_db", - database: process.env.MONGO_DATABASE || "ldap_user_db" + uri: options.mongoUri ?? process.env.MONGO_URI ?? "mongodb://localhost:27017/ldap_user_db", + database: options.mongoDatabase ?? process.env.MONGO_DATABASE ?? "ldap_user_db" }; + this.ldapBaseDn = options.ldapBaseDn ?? process.env.LDAP_BASE_DN; this.initialized = false; } @@ -99,7 +100,7 @@ class MongoDBDirectoryProvider extends DirectoryProvider { memberUids: [user.username], gid_number: gidNum, gidNumber: gidNum, - dn: `cn=${user.username},${process.env.LDAP_BASE_DN}`, + dn: `cn=${user.username},${this.ldapBaseDn}`, objectClass: ["posixGroup"], }]; } diff --git a/server/backends/notification.auth.js b/server/backends/notification.auth.js index 0737ba7..ea1ac7e 100644 --- a/server/backends/notification.auth.js +++ b/server/backends/notification.auth.js @@ -7,8 +7,9 @@ const logger = require('../utils/logger'); * Works as a standalone auth provider in the chain (doesn't wrap other providers) */ class NotificationAuthProvider extends AuthProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); + this.notificationUrl = options.notificationUrl ?? process.env.NOTIFICATION_URL ?? null; this.initialized = false; } @@ -23,7 +24,7 @@ class NotificationAuthProvider extends AuthProvider { try { logger.debug(`[NotificationAuthProvider] Sending MFA notification for ${username}`); - const response = await NotificationService.sendAuthenticationNotification(username); + const response = await NotificationService.sendAuthenticationNotification(username, this.notificationUrl); if (response.action === "approve") { logger.debug(`[NotificationAuthProvider] MFA approved for ${username}`); diff --git a/server/backends/proxmox.auth.js b/server/backends/proxmox.auth.js index ddd897e..36f82d3 100644 --- a/server/backends/proxmox.auth.js +++ b/server/backends/proxmox.auth.js @@ -6,14 +6,14 @@ const { AuthProvider } = require('@ldap-gateway/core'); const logger = require('../utils/logger'); class ProxmoxBackend extends AuthProvider { - constructor(directoryProvider = null) { - super(); - this.shadowPath = process.env.PROXMOX_SHADOW_CFG || null; + constructor(options = {}) { + super(options); + this.shadowPath = options.proxmoxShadowCfg ?? process.env.PROXMOX_SHADOW_CFG ?? null; this.shadowCache = null; this.fileWatcher = null; this.reloadTimeout = null; this.initialized = false; - this.directoryProvider = directoryProvider; + this.directoryProvider = options.directoryProvider ?? null; } async initialize() { diff --git a/server/backends/proxmox.directory.js b/server/backends/proxmox.directory.js index b29b0e0..1f62def 100644 --- a/server/backends/proxmox.directory.js +++ b/server/backends/proxmox.directory.js @@ -7,9 +7,10 @@ const logger = require('../utils/logger'); const { name } = require('./proxmox.auth'); class ProxmoxDirectory extends DirectoryProvider { - constructor() { - super(); - this.configPath = process.env.PROXMOX_USER_CFG || null; + constructor(options = {}) { + super(options); + this.configPath = options.proxmoxUserCfg ?? process.env.PROXMOX_USER_CFG ?? null; + this.ldapBaseDn = options.ldapBaseDn ?? process.env.LDAP_BASE_DN; this.users = []; this.groups = []; this.watcher = null; @@ -201,7 +202,7 @@ class ProxmoxDirectory extends DirectoryProvider { memberUids, gid_number: gidBase, gidNumber: gidBase, - dn: `cn=${groupName},${process.env.LDAP_BASE_DN}`, + dn: `cn=${groupName},${this.ldapBaseDn}`, objectClass: ["posixGroup"], }); gidBase++; @@ -218,7 +219,7 @@ class ProxmoxDirectory extends DirectoryProvider { memberUids: allUsernames, gid_number: 9999, gidNumber: 9999, - dn: `cn=proxmox-sudo,${process.env.LDAP_BASE_DN}`, + dn: `cn=proxmox-sudo,${this.ldapBaseDn}`, objectClass: ["posixGroup"], }); @@ -274,7 +275,7 @@ class ProxmoxDirectory extends DirectoryProvider { memberUids: [user.username], gid_number: gidNum, gidNumber: gidNum, - dn: `cn=${user.username},${process.env.LDAP_BASE_DN}`, + dn: `cn=${user.username},${this.ldapBaseDn}`, objectClass: ["posixGroup"], }]; } diff --git a/server/backends/sql.auth.js b/server/backends/sql.auth.js index 9b6de13..01bccdf 100644 --- a/server/backends/sql.auth.js +++ b/server/backends/sql.auth.js @@ -1,34 +1,23 @@ const { AuthProvider } = require('@ldap-gateway/core'); const logger = require('../utils/logger'); const { Sequelize } = require('sequelize'); +const { buildSequelizeOptions } = require('../utils/sqlUtils'); const argon2 = require('argon2'); const bcrypt = require('bcrypt'); const unixcrypt = require('unixcrypt'); -/** - * Build Sequelize options with optional SSL configuration - * Set SQL_SSL=false to disable TLS for testing with local databases - */ -function buildSequelizeOptions() { - const options = { logging: msg => logger.debug(msg) }; - - if (process.env.SQL_SSL === 'false') { - options.dialectOptions = { ssl: false }; - } - - return options; -} - /** * SQL Authentication Provider * Handles user authentication against SQL database */ class SQLAuthProvider extends AuthProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); + this.sqlUri = options.sqlUri ?? process.env.SQL_URI; + this.sqlQueryOneUser = options.sqlQueryOneUser ?? process.env.SQL_QUERY_ONE_USER; this.sequelize = new Sequelize( - process.env.SQL_URI, - buildSequelizeOptions() + this.sqlUri, + buildSequelizeOptions(options) ); } @@ -84,7 +73,7 @@ class SQLAuthProvider extends AuthProvider { try { logger.debug(`[SQLAuthProvider] Authenticating user: ${username}`); const [results, _] = await this.sequelize.query( - process.env.SQL_QUERY_ONE_USER, + this.sqlQueryOneUser, { replacements: [username] } ); diff --git a/server/backends/sql.directory.js b/server/backends/sql.directory.js index bc424cc..c8c91f0 100644 --- a/server/backends/sql.directory.js +++ b/server/backends/sql.directory.js @@ -1,20 +1,7 @@ const { DirectoryProvider, filterUtils } = require('@ldap-gateway/core'); const logger = require('../utils/logger'); const { Sequelize } = require('sequelize'); - -/** - * Build Sequelize options with optional SSL configuration - * Set SQL_SSL=false to disable TLS for testing with local databases - */ -function buildSequelizeOptions() { - const options = { logging: msg => logger.debug(msg) }; - - if (process.env.SQL_SSL === 'false') { - options.dialectOptions = { ssl: false }; - } - - return options; -} +const { buildSequelizeOptions } = require('../utils/sqlUtils'); /** * Normalize member_uids field from database @@ -40,11 +27,17 @@ function normalizeMemberUids(group) { * Handles user and group directory operations against SQL database */ class SQLDirectoryProvider extends DirectoryProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); + this.sqlUri = options.sqlUri ?? process.env.SQL_URI; + this.sqlQueryOneUser = options.sqlQueryOneUser ?? process.env.SQL_QUERY_ONE_USER; + this.sqlQueryAllUsers = options.sqlQueryAllUsers ?? process.env.SQL_QUERY_ALL_USERS; + this.sqlQueryAllGroups = options.sqlQueryAllGroups ?? process.env.SQL_QUERY_ALL_GROUPS; + this.sqlQueryGroupsByMember = options.sqlQueryGroupsByMember ?? process.env.SQL_QUERY_GROUPS_BY_MEMBER; + this.ldapBaseDn = options.ldapBaseDn ?? process.env.LDAP_BASE_DN; this.sequelize = new Sequelize( - process.env.SQL_URI, - buildSequelizeOptions() + this.sqlUri, + buildSequelizeOptions(options) ); } @@ -55,7 +48,7 @@ class SQLDirectoryProvider extends DirectoryProvider { try { logger.debug(`[SQLDirectoryProvider] Finding user: ${username}`); const [results, _] = await this.sequelize.query( - process.env.SQL_QUERY_ONE_USER, + this.sqlQueryOneUser, { replacements: [username] } ); @@ -85,7 +78,7 @@ class SQLDirectoryProvider extends DirectoryProvider { const username = filterConditions.memberUid; logger.debug(`[SQLDirectoryProvider] Finding groups for member: ${username}`); const [groups, _] = await this.sequelize.query( - process.env.SQL_QUERY_GROUPS_BY_MEMBER, + this.sqlQueryGroupsByMember, { replacements: [username] } ); @@ -128,7 +121,7 @@ class SQLDirectoryProvider extends DirectoryProvider { memberUids: [user.username], gid_number: gidNum, gidNumber: gidNum, - dn: `cn=${user.username},${process.env.LDAP_BASE_DN}`, + dn: `cn=${user.username},${this.ldapBaseDn}`, objectClass: ["posixGroup"], }]; } @@ -147,7 +140,7 @@ class SQLDirectoryProvider extends DirectoryProvider { async getAllUsers() { try { logger.debug('[SQLDirectoryProvider] Getting all users'); - const [users, _] = await this.sequelize.query(process.env.SQL_QUERY_ALL_USERS); + const [users, _] = await this.sequelize.query(this.sqlQueryAllUsers); logger.debug(`[SQLDirectoryProvider] Found ${users.length} users`); return users; @@ -163,7 +156,7 @@ class SQLDirectoryProvider extends DirectoryProvider { await this.initialize(); logger.debug('[SQLDirectoryProvider] Getting all groups'); - const [groups, _] = await this.sequelize.query(process.env.SQL_QUERY_ALL_GROUPS); + const [groups, _] = await this.sequelize.query(this.sqlQueryAllGroups); // Normalize member_uids from JSON strings to arrays const normalizedGroups = groups.map(normalizeMemberUids); diff --git a/server/backends/template.js b/server/backends/template.js index 2befe64..e5c9dcf 100644 --- a/server/backends/template.js +++ b/server/backends/template.js @@ -16,15 +16,11 @@ const { AuthProvider, DirectoryProvider } = require('@ldap-gateway/core'); class MyAuthBackend extends AuthProvider { constructor(options = {}) { - super(); + super(options); - // Initialize your backend with options - // Options may include: databaseService, ldapServerPool, or custom config - this.options = options; - - // Access environment variables for configuration - this.apiUrl = process.env.MY_API_URL; - this.apiKey = process.env.MY_API_KEY; + // Use options with env var fallback — enables multi-realm support + this.apiUrl = options.apiUrl ?? process.env.MY_API_URL; + this.apiKey = options.apiKey ?? process.env.MY_API_KEY; // Initialize any connections, clients, or state here } @@ -66,12 +62,10 @@ class MyAuthBackend extends AuthProvider { class MyDirectoryBackend extends DirectoryProvider { constructor(options = {}) { - super(); - - this.options = options; + super(options); - // Access environment variables - this.dataPath = process.env.MY_DATA_PATH; + // Use options with env var fallback — enables multi-realm support + this.dataPath = options.dataPath ?? process.env.MY_DATA_PATH; // Initialize your data source } diff --git a/server/config/configurationLoader.js b/server/config/configurationLoader.js index 22b50fe..67d9c45 100644 --- a/server/config/configurationLoader.js +++ b/server/config/configurationLoader.js @@ -48,6 +48,8 @@ class ConfigurationLoader { unencrypted: process.env.LDAP_UNENCRYPTED === 'true' || process.env.LDAP_UNENCRYPTED === '1', backendDir: process.env.BACKEND_DIR || null, requireAuthForSearch: process.env.REQUIRE_AUTH_FOR_SEARCH !== 'false', + // Load realm configuration (null if not configured) + realms: this._loadRealmConfig(), // Load certificates - this handles all certificate logic ...(await this._loadCertificates()), // Load TLS configuration @@ -67,6 +69,138 @@ class ConfigurationLoader { return commonName.split('.').map(part => `dc=${part}`).join(','); } + /** + * Load realm configuration from REALM_CONFIG env var. + * Supports both inline JSON strings and file paths. + * Returns null if REALM_CONFIG is not set (single-realm backward compat). + * @private + * @returns {Array|null} Array of realm config objects or null + */ + _loadRealmConfig() { + const realmConfigValue = process.env.REALM_CONFIG; + if (!realmConfigValue) { + return null; + } + + let realms; + const trimmed = realmConfigValue.trim(); + + // Try inline JSON first (starts with [ for an array) + if (trimmed.startsWith('[')) { + try { + realms = JSON.parse(trimmed); + } catch (err) { + logger.error(`Failed to parse REALM_CONFIG as JSON: ${err.message}`); + throw new Error(`Invalid REALM_CONFIG JSON: ${err.message}`); + } + } else { + // Treat as file path + const filePath = path.resolve(trimmed); + try { + const content = fs.readFileSync(filePath, 'utf8'); + realms = JSON.parse(content); + } catch (err) { + logger.error(`Failed to load REALM_CONFIG from file '${filePath}': ${err.message}`); + throw new Error(`Failed to load REALM_CONFIG from '${filePath}': ${err.message}`); + } + } + + // Validate realm config + this._validateRealmConfig(realms); + logger.info(`Loaded ${realms.length} realm(s) from REALM_CONFIG`); + return realms; + } + + /** + * Validate realm configuration array. + * @private + * @param {*} realms - Parsed realm configuration + * @throws {Error} If validation fails + */ + _validateRealmConfig(realms) { + if (!Array.isArray(realms)) { + throw new Error('REALM_CONFIG must be a JSON array of realm objects'); + } + + if (realms.length === 0) { + throw new Error('REALM_CONFIG must contain at least one realm'); + } + + const names = new Set(); + const baseDns = new Map(); // lowercased baseDn -> realm name + let defaultCount = 0; + for (let i = 0; i < realms.length; i++) { + const realm = realms[i]; + const prefix = `REALM_CONFIG[${i}]`; + + if (!realm || typeof realm !== 'object') { + throw new Error(`${prefix}: must be an object`); + } + + if (!realm.name || typeof realm.name !== 'string') { + throw new Error(`${prefix}: 'name' is required and must be a string`); + } + + if (names.has(realm.name)) { + throw new Error(`${prefix}: duplicate realm name '${realm.name}'`); + } + names.add(realm.name); + + if (!realm.baseDn || typeof realm.baseDn !== 'string') { + throw new Error(`${prefix} (${realm.name}): 'baseDn' is required and must be a string`); + } + + // Enforce 1:1 baseDN-to-realm mapping + const baseDnKey = realm.baseDn.toLowerCase(); + if (baseDns.has(baseDnKey)) { + throw new Error( + `${prefix} (${realm.name}): duplicate baseDn '${realm.baseDn}' ` + + `(already used by realm '${baseDns.get(baseDnKey)}'). ` + + `Each baseDN must map to exactly one realm.` + ); + } + baseDns.set(baseDnKey, realm.name); + + if (realm.default === true) { + defaultCount++; + } + + if (!realm.directory || typeof realm.directory !== 'object') { + throw new Error(`${prefix} (${realm.name}): 'directory' is required and must be an object`); + } + + if (!realm.directory.backend || typeof realm.directory.backend !== 'string') { + throw new Error(`${prefix} (${realm.name}): 'directory.backend' is required`); + } + + if (!realm.auth) { + throw new Error(`${prefix} (${realm.name}): 'auth' is required`); + } + + if (!Array.isArray(realm.auth.backends) || realm.auth.backends.length === 0) { + throw new Error(`${prefix} (${realm.name}): 'auth.backends' must be a non-empty array`); + } + + for (let j = 0; j < realm.auth.backends.length; j++) { + const backend = realm.auth.backends[j]; + if (!backend || typeof backend !== 'object') { + throw new Error(`${prefix} (${realm.name}): 'auth.backends[${j}]' must be an object`); + } + if (!backend.type || typeof backend.type !== 'string') { + throw new Error(`${prefix} (${realm.name}): 'auth.backends[${j}].type' is required`); + } + } + + logger.info(`Realm '${realm.name}' configured with baseDN '${realm.baseDn}', ` + + `directory: ${realm.directory.backend}, auth: [${realm.auth.backends.map(b => b.type).join(', ')}]` + + (realm.default ? ' (default)' : '')); + } + + if (defaultCount > 1) { + throw new Error('REALM_CONFIG: only one realm may be marked as "default": true'); + } + } + /** * Load SSL/TLS certificates (handles all certificate logic) * @private diff --git a/server/providers.js b/server/providers.js index b24f7c8..0df3f6f 100644 --- a/server/providers.js +++ b/server/providers.js @@ -13,12 +13,12 @@ class ProviderFactory { createAuthProvider(type, options = {}) { const AuthProvider = this.backendLoader.getAuthBackend(type); - return new AuthProvider(); + return new AuthProvider(options); } createDirectoryProvider(type, options = {}) { const DirectoryProvider = this.backendLoader.getDirectoryBackend(type); - return new DirectoryProvider(); + return new DirectoryProvider(options); } /** diff --git a/server/realms.example.json b/server/realms.example.json new file mode 100644 index 0000000..3844fe9 --- /dev/null +++ b/server/realms.example.json @@ -0,0 +1,59 @@ +[ + { + "name": "example-corp", + "baseDn": "dc=example,dc=com", + "default": true, + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://user:password@localhost:3306/ldap_db", + "ldapBaseDn": "dc=example,dc=com", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?", + "sqlQueryAllUsers": "SELECT * FROM users", + "sqlQueryAllGroups": "SELECT * FROM `groups`", + "sqlQueryGroupsByMember": "SELECT * FROM `groups` g WHERE JSON_CONTAINS(g.member_uids, JSON_QUOTE(?))" + } + }, + "auth": { + "backends": [ + { + "type": "sql", + "options": { + "sqlUri": "mysql://user:password@localhost:3306/ldap_db" + } + } + ] + } + }, + { + "name": "example-mfa", + "baseDn": "dc=secure,dc=example,dc=com", + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://user:password@localhost:3306/ldap_db", + "ldapBaseDn": "dc=secure,dc=example,dc=com", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?", + "sqlQueryAllUsers": "SELECT * FROM users", + "sqlQueryAllGroups": "SELECT * FROM `groups`", + "sqlQueryGroupsByMember": "SELECT * FROM `groups` g WHERE JSON_CONTAINS(g.member_uids, JSON_QUOTE(?))" + } + }, + "auth": { + "backends": [ + { + "type": "sql", + "options": { + "sqlUri": "mysql://user:password@localhost:3306/ldap_db" + } + }, + { + "type": "notification", + "options": { + "notificationUrl": "https://your-mfa-service.example.com/notify" + } + } + ] + } + } +] diff --git a/server/serverMain.js b/server/serverMain.js index 9fa3038..b3e2f0b 100644 --- a/server/serverMain.js +++ b/server/serverMain.js @@ -39,14 +39,8 @@ async function startServer(config) { logger.debug('Available auth backends:', availableBackends.auth); logger.debug('Available directory backends:', availableBackends.directory); - const selectedDirectory = providerFactory.createDirectoryProvider(config.directoryBackend); - const selectedBackends = config.authBackends.map((authBackend) => { - return providerFactory.createAuthProvider(authBackend); - }); - - // Create and configure LDAP engine - const ldapEngine = new LdapEngine({ - baseDn: config.ldapBaseDn, + // Build LdapEngine options + const engineOptions = { bindIp: config.bindIp, port: config.port, certificate: config.certContent, @@ -55,10 +49,89 @@ async function startServer(config) { tlsMaxVersion: config.tlsMaxVersion, tlsCiphers: config.tlsCiphers, logger: logger, - authProviders: selectedBackends, - directoryProvider: selectedDirectory, requireAuthForSearch: config.requireAuthForSearch - }); + }; + + if (config.realms) { + // Multi-realm mode: build realm objects from config + logger.info(`Initializing multi-realm mode with ${config.realms.length} realm(s)`); + let defaultRealmObj = null; + + engineOptions.realms = config.realms.map(realmCfg => { + // Ensure directory providers receive realm-scoped LDAP base DN so that + // any provider-side DN construction stays consistent with realmCfg.baseDn. + const directoryOptions = { + ldapBaseDn: realmCfg.baseDn, + ...(realmCfg.directory.options || {}) + }; + const directoryProvider = providerFactory.createDirectoryProvider( + realmCfg.directory.backend, + directoryOptions + ); + + // Build per-realm auth backend type map using explicit type names from config + const authBackendTypes = new Map(); + const authBackends = realmCfg.auth?.backends || []; + const authProviders = authBackends.map(backendCfg => { + const provider = providerFactory.createAuthProvider(backendCfg.type, backendCfg.options || {}); + const typeKey = backendCfg.type.toLowerCase(); + if (!authBackendTypes.has(typeKey)) { + authBackendTypes.set(typeKey, provider); + } + logger.debug(`Realm '${realmCfg.name}': registered auth backend '${typeKey}'`); + return provider; + }); + + if (authBackends.length === 0) { + logger.warn(`Realm '${realmCfg.name}': no auth backends configured — bind requests will be rejected`); + } + + logger.info(`Realm '${realmCfg.name}': baseDN=${realmCfg.baseDn}, ` + + `directory=${realmCfg.directory.backend}, auth=[${authBackends.map(b => b.type).join(', ')}]` + + (realmCfg.default ? ' (default)' : '')); + + const realmObj = { + name: realmCfg.name, + baseDn: realmCfg.baseDn, + directoryProvider, + authProviders, + authBackendTypes + }; + + if (realmCfg.default) { + defaultRealmObj = realmObj; + } + + return realmObj; + }); + + if (defaultRealmObj) { + engineOptions.defaultRealm = defaultRealmObj; + } + } else { + // Legacy single-realm mode + const selectedDirectory = providerFactory.createDirectoryProvider(config.directoryBackend); + const selectedBackends = config.authBackends.map((authBackend) => { + return providerFactory.createAuthProvider(authBackend); + }); + engineOptions.baseDn = config.ldapBaseDn; + engineOptions.authProviders = selectedBackends; + engineOptions.directoryProvider = selectedDirectory; + + // Build auth backend types map for legacy mode + const authBackendTypes = new Map(); + for (let idx = 0; idx < config.authBackends.length; idx++) { + const typeKey = config.authBackends[idx].toLowerCase(); + if (!authBackendTypes.has(typeKey)) { + authBackendTypes.set(typeKey, selectedBackends[idx]); + logger.debug(`Registered auth backend '${typeKey}' in provider type map`); + } + } + engineOptions.authBackendTypes = authBackendTypes; + } + + // Create and configure LDAP engine + const ldapEngine = new LdapEngine(engineOptions); // Set up event listeners for logging and monitoring ldapEngine.on('started', (info) => { diff --git a/server/services/notificationService.js b/server/services/notificationService.js index 8e726a7..3167847 100644 --- a/server/services/notificationService.js +++ b/server/services/notificationService.js @@ -1,10 +1,23 @@ const axios = require("axios"); class NotificationService { - static async sendAuthenticationNotification(username) { + /** + * Send an authentication push notification + * @param {string} username - The username requesting authentication + * @param {string} [notificationUrl] - Override URL (falls back to NOTIFICATION_URL env var) + * @returns {Promise} Response data with action field + */ + static async sendAuthenticationNotification(username, notificationUrl = null) { + const url = notificationUrl ?? process.env.NOTIFICATION_URL; + if (!url) { + throw new Error( + 'NOTIFICATION_URL must be configured (set NOTIFICATION_URL environment variable ' + + 'or provide notificationUrl option in realm config)' + ); + } try { const response = await axios.post( - process.env.NOTIFICATION_URL, + url, { username: username, title: "SSH Authentication Request", diff --git a/server/test/integration/auth/mysql.auth.test.js b/server/test/integration/auth/mysql.auth.test.js index be158eb..def090e 100644 --- a/server/test/integration/auth/mysql.auth.test.js +++ b/server/test/integration/auth/mysql.auth.test.js @@ -20,6 +20,12 @@ function configureEnv() { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; } +const directoryStub = { + initialize: async () => {}, + cleanup: async () => {}, + findUser: async (username) => ({ username }), +}; + maybeDescribe('MySQL Auth Backend (real DB) - Integration', () => { let engine; let conn; @@ -28,7 +34,7 @@ maybeDescribe('MySQL Auth Backend (real DB) - Integration', () => { async function startServer() { configureEnv(); const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); client = createClient(); return client; diff --git a/server/test/integration/auth/postgres.auth.test.js b/server/test/integration/auth/postgres.auth.test.js index 588f7ee..b5e4e23 100644 --- a/server/test/integration/auth/postgres.auth.test.js +++ b/server/test/integration/auth/postgres.auth.test.js @@ -14,6 +14,12 @@ const url = process.env.SQL_URI || 'postgres://testuser:testpass@127.0.0.1:25432 function createClient() { return ldap.createClient({ url: `ldap://127.0.0.1:${port}` }); } +const directoryStub = { + initialize: async () => {}, + cleanup: async () => {}, + findUser: async (username) => ({ username }), +}; + async function seedPostgres() { const client = new Client({ connectionString: url }); await client.connect(); @@ -33,7 +39,7 @@ maybeDescribe('PostgreSQL Auth Backend (real DB) - Integration', () => { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); const client = createClient(); @@ -46,7 +52,7 @@ maybeDescribe('PostgreSQL Auth Backend (real DB) - Integration', () => { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); const client = createClient(); diff --git a/server/test/integration/auth/sqlite.auth.test.js b/server/test/integration/auth/sqlite.auth.test.js index 4a9395f..02f769e 100644 --- a/server/test/integration/auth/sqlite.auth.test.js +++ b/server/test/integration/auth/sqlite.auth.test.js @@ -34,15 +34,25 @@ function createClient() { return ldap.createClient({ url: `ldap://127.0.0.1:${port}` }); } +// Minimal directory stub – only needs findUser so _authenticateInRealm +// can locate the user before delegating to the auth provider. +const directoryStub = { + initialize: async () => {}, + cleanup: async () => {}, + findUser: async (username) => ({ username }), +}; + describe('SQLite Auth Backend (real DB) - Integration', () => { let engine; let dbPath; + let client; beforeAll(() => { // nothing yet }); afterEach(async () => { + if (client) { try { client.unbind(); client.destroy(); } catch (_) {} client = null; } if (engine) { await engine.stop(); engine = null; } if (dbPath && fs.existsSync(dbPath)) { try { fs.unlinkSync(dbPath); } catch (_) {} @@ -60,15 +70,14 @@ describe('SQLite Auth Backend (real DB) - Integration', () => { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); - const client = createClient(); + client = createClient(); const userDN = `uid=testuser,${baseDn}`; await expect(new Promise((resolve, reject) => { client.bind(userDN, 'password123', (err) => err ? reject(err) : resolve()); })).resolves.not.toThrow(); - client.unbind(); }); test('2. Bind with invalid credentials should fail (SQLite)', async () => { @@ -79,14 +88,13 @@ describe('SQLite Auth Backend (real DB) - Integration', () => { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); - const client = createClient(); + client = createClient(); const userDN = `uid=testuser,${baseDn}`; await expect(new Promise((resolve, reject) => { client.bind(userDN, 'wrong', (err) => err ? reject(err) : resolve()); })).rejects.toThrow(); - client.unbind(); }); }); diff --git a/server/test/unit/configurationLoader.realms.test.js b/server/test/unit/configurationLoader.realms.test.js new file mode 100644 index 0000000..1f19fa1 --- /dev/null +++ b/server/test/unit/configurationLoader.realms.test.js @@ -0,0 +1,193 @@ +// Unit Tests for ConfigurationLoader Realm Config +// Tests _loadRealmConfig and _validateRealmConfig + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +// We need to require ConfigurationLoader after setting up env +let ConfigurationLoader; + +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() +}; + +// Mock logger module +jest.mock('../../utils/logger', () => mockLogger); + +// Mock dotenv to prevent loading actual .env files +jest.mock('dotenv', () => ({ config: jest.fn() })); + +beforeEach(() => { + jest.clearAllMocks(); + // Reset module cache so each test gets fresh ConfigurationLoader + jest.resetModules(); + ConfigurationLoader = require('../../config/configurationLoader'); +}); + +describe('ConfigurationLoader._loadRealmConfig', () => { + test('should return null when REALM_CONFIG is not set', () => { + delete process.env.REALM_CONFIG; + const loader = new ConfigurationLoader(); + const result = loader._loadRealmConfig(); + expect(result).toBeNull(); + }); + + test('should parse inline JSON array', () => { + const realms = [ + { + name: 'test-realm', + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + } + ]; + process.env.REALM_CONFIG = JSON.stringify(realms); + const loader = new ConfigurationLoader(); + const result = loader._loadRealmConfig(); + expect(result).toEqual(realms); + delete process.env.REALM_CONFIG; + }); + + test('should load from file path', () => { + const realms = [ + { + name: 'file-realm', + baseDn: 'dc=file,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + } + ]; + const tmpFile = path.join(os.tmpdir(), `realm-config-test-${Date.now()}.json`); + fs.writeFileSync(tmpFile, JSON.stringify(realms)); + + try { + process.env.REALM_CONFIG = tmpFile; + const loader = new ConfigurationLoader(); + const result = loader._loadRealmConfig(); + expect(result).toEqual(realms); + } finally { + fs.unlinkSync(tmpFile); + delete process.env.REALM_CONFIG; + } + }); + + test('should throw on invalid JSON string', () => { + process.env.REALM_CONFIG = '[{invalid json}]'; + const loader = new ConfigurationLoader(); + expect(() => loader._loadRealmConfig()).toThrow('Invalid REALM_CONFIG JSON'); + delete process.env.REALM_CONFIG; + }); + + test('should throw on non-existent file path', () => { + process.env.REALM_CONFIG = '/nonexistent/path/realms.json'; + const loader = new ConfigurationLoader(); + expect(() => loader._loadRealmConfig()).toThrow('Failed to load REALM_CONFIG'); + delete process.env.REALM_CONFIG; + }); +}); + +describe('ConfigurationLoader._validateRealmConfig', () => { + let loader; + + beforeEach(() => { + loader = new ConfigurationLoader(); + }); + + test('should accept valid realm config', () => { + const realms = [ + { + name: 'valid-realm', + baseDn: 'dc=valid,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + } + ]; + expect(() => loader._validateRealmConfig(realms)).not.toThrow(); + }); + + test('should reject non-array', () => { + expect(() => loader._validateRealmConfig({})).toThrow('must be a JSON array'); + }); + + test('should reject empty array', () => { + expect(() => loader._validateRealmConfig([])).toThrow('at least one realm'); + }); + + test('should reject missing name', () => { + expect(() => loader._validateRealmConfig([{ + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + }])).toThrow("'name' is required"); + }); + + test('should reject duplicate realm names', () => { + expect(() => loader._validateRealmConfig([ + { name: 'dup', baseDn: 'dc=a,dc=com', directory: { backend: 'sql' }, auth: { backends: [{ type: 'sql' }] } }, + { name: 'dup', baseDn: 'dc=b,dc=com', directory: { backend: 'sql' }, auth: { backends: [{ type: 'sql' }] } } + ])).toThrow("duplicate realm name 'dup'"); + }); + + test('should reject missing baseDn', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + }])).toThrow("'baseDn' is required"); + }); + + test('should reject missing directory', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + auth: { backends: [{ type: 'sql' }] } + }])).toThrow("'directory' is required"); + }); + + test('should reject missing directory.backend', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + directory: {}, + auth: { backends: [{ type: 'sql' }] } + }])).toThrow("'directory.backend' is required"); + }); + + test('should reject missing auth', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' } + }])).toThrow("'auth' is required"); + }); + + test('should reject empty auth.backends', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [] } + }])).toThrow("'auth.backends' must be a non-empty array"); + }); + + test('should reject auth backend without type', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{}] } + }])).toThrow("'auth.backends[0].type' is required"); + }); + + test('should reject multiple realms with same baseDN', () => { + const realms = [ + { name: 'realm-a', baseDn: 'dc=shared,dc=com', directory: { backend: 'sql' }, auth: { backends: [{ type: 'sql' }] } }, + { name: 'realm-b', baseDn: 'dc=shared,dc=com', directory: { backend: 'mongodb' }, auth: { backends: [{ type: 'mongodb' }] } } + ]; + expect(() => loader._validateRealmConfig(realms)).toThrow(/duplicate baseDn/); + }); +}); diff --git a/server/utils/sqlUtils.js b/server/utils/sqlUtils.js new file mode 100644 index 0000000..f9e6134 --- /dev/null +++ b/server/utils/sqlUtils.js @@ -0,0 +1,22 @@ +const logger = require('./logger'); + +/** + * Build Sequelize options with optional SSL configuration + * Shared between SQL auth and directory providers to avoid duplication. + * + * @param {Object} options - Provider options (sqlSsl overrides env var) + * @returns {Object} Sequelize constructor options + */ +function buildSequelizeOptions(options = {}) { + const seqOptions = { logging: msg => logger.debug(msg) }; + + const sqlSsl = options.sqlSsl ?? process.env.SQL_SSL; + // Handle both boolean false and string 'false'/'0' from JSON config or env vars + if (sqlSsl === false || sqlSsl === 'false' || sqlSsl === '0') { + seqOptions.dialectOptions = { ssl: false }; + } + + return seqOptions; +} + +module.exports = { buildSequelizeOptions };