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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,61 @@ ENABLE_NOTIFICATION=true
NOTIFICATION_URL=https://your-notification-service.com
```

### 🔑 SSH Public Key Support (openssh-lpk)

The LDAP gateway supports SSH public key storage and retrieval, compatible with the openssh-lpk schema used by SSSD and other LDAP-aware SSH implementations.

#### Database Schema

Add an `sshpublickey` column to your users table:

```sql
-- MySQL/MariaDB/PostgreSQL
ALTER TABLE users ADD COLUMN sshpublickey TEXT;

-- Store SSH public keys (supports multiple keys)
UPDATE users SET sshpublickey = 'ssh-rsa AAAAB3NzaC1yc2EAAA... user@host' WHERE username = 'alice';
```

#### SQL Query Configuration

Update your `SQL_QUERY_ONE_USER` and `SQL_QUERY_ALL_USERS` to include the `sshpublickey` column:

```ini
SQL_QUERY_ONE_USER='SELECT username, uid_number, gid_number, full_name, mail, home_directory, login_shell, sshpublickey FROM users WHERE username = ?'
SQL_QUERY_ALL_USERS='SELECT username, uid_number, gid_number, full_name, mail, home_directory, login_shell, sshpublickey FROM users'
```

#### LDAP Attributes

When SSH public keys are present, the LDAP gateway automatically:
- Adds the `ldapPublicKey` objectClass to user entries
- Includes the `sshPublicKey` attribute with the key(s)
- Supports multiple SSH keys per user (stored as multi-value LDAP attribute)

#### SSSD Configuration

Configure SSSD to use SSH public keys from LDAP:

```ini
# /etc/sssd/sssd.conf
[domain/ldap]
ldap_user_ssh_public_key = sshPublicKey

[ssh]
ssh_key_cache_timeout = 300
```

Update SSHD configuration:

```bash
# /etc/ssh/sshd_config
AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys
AuthorizedKeysCommandUser nobody
```

Now SSH authentication will retrieve public keys from LDAP automatically!

### 🔌 Custom Backends (Dynamic Loading)

**NEW:** Create your own backends without rebuilding! Place JavaScript files in `server/backends/` to add custom authentication or directory providers.
Expand Down Expand Up @@ -523,6 +578,7 @@ Direct integration with Proxmox virtualization environments:
- **Container Authentication** → Centralized LDAP for all containers/VMs
- **Configuration Syncing** → Reads directly from Proxmox user/shadow files
- **MFA Support** → Optional push notifications via [MIE Authenticator](https://github.com/mieweb/mieweb_auth_app)
- **SSH Public Key Support** → Compatible with openssh-lpk schema for SSH key authentication via SSSD
- **Automated Setup** → Use [pown.sh](https://github.com/mieweb/pown.sh) for container LDAP client configuration

### Deployment
Expand Down
15 changes: 15 additions & 0 deletions npm/src/LdapEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,13 @@ class LdapEngine extends EventEmitter {
let entryCount = 0;
const startTime = Date.now();

// ldapjs compares lowercased entry attribute names against res.attributes,
// but does not lowercase res.attributes itself. Normalize to lowercase so
// that case-insensitive attribute requests (e.g. sshPublicKey) match.
if (req.attributes && req.attributes.length > 0 && !req.attributes.includes('*')) {
res.attributes = req.attributes.map(a => a.toLowerCase());
}

try {
this.emit('searchRequest', {
filter: filterStr,
Expand Down Expand Up @@ -346,7 +353,15 @@ class LdapEngine extends EventEmitter {
const users = await this.directoryProvider.getAllUsers();
this.logger.debug(`Found ${users.length} users`);

// Check if filtering by ldapPublicKey objectClass
const filterBySSHKeys = /objectClass=ldapPublicKey/i.test(filterStr);

for (const user of users) {
// Skip users without SSH keys if filtering by ldapPublicKey
if (filterBySSHKeys && !user.sshpublickey) {
continue;
}

const entry = createLdapEntry(user, this.config.baseDn);
this.emit('entryFound', { type: 'user', entry: entry.dn });
res.send(entry);
Expand Down
2 changes: 1 addition & 1 deletion npm/src/utils/filterUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function isAllUsersRequest(filterStr, attributes) {
}

// User objectClass searches
if (/(objectClass=posixAccount)|(objectClass=inetOrgPerson)|(objectClass=person)/i.test(filterStr)) {
if (/(objectClass=posixAccount)|(objectClass=inetOrgPerson)|(objectClass=person)|(objectClass=ldapPublicKey)/i.test(filterStr)) {
return true;
}

Expand Down
7 changes: 7 additions & 0 deletions npm/src/utils/ldapUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* @property {string} [mail] - User's email address
* @property {string} [home_directory] - User's home directory
* @property {string} [login_shell] - User's login shell (e.g. "/bin/bash")
* @property {string|string[]} [sshpublickey] - SSH public key(s) for openssh-lpk support
*/

/**
Expand Down Expand Up @@ -81,6 +82,12 @@ function createLdapEntry(user, baseDn) {
if (fullName)
entry.attributes.gecos = fullName;

// SSH public key support (openssh-lpk schema)
if (user.sshpublickey) {
entry.attributes.sshPublicKey = user.sshpublickey;
entry.attributes.objectClass.push("ldapPublicKey");
}

return entry;
}

Expand Down
5 changes: 5 additions & 0 deletions npm/test/unit/utils/filterUtils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ describe('filterUtils', () => {
expect(result).toBe(true);
});

test('should detect ldapPublicKey objectClass filter: (objectClass=ldapPublicKey)', () => {
const result = isAllUsersRequest('(objectClass=ldapPublicKey)', []);
expect(result).toBe(true);
});

test('should detect wildcard uid filter: (uid=*)', () => {
const result = isAllUsersRequest('(uid=*)', []);
expect(result).toBe(true);
Expand Down
49 changes: 49 additions & 0 deletions npm/test/unit/utils/ldapUtils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,55 @@ describe('ldapUtils', () => {
expect(entry.attributes.sn).toBe('Smith');
expect(entry.attributes.gecos).toBe('Smith');
});

test('should include sshPublicKey attribute when sshpublickey field is provided', () => {
const user = {
username: 'sshuser',
uid_number: 3000,
gid_number: 3000,
sshpublickey: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKey... user@host'
};

const entry = createLdapEntry(user, baseDN);

expect(entry.attributes.sshPublicKey).toBe('ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKey... user@host');
expect(entry.attributes.objectClass).toContain('ldapPublicKey');
expect(entry.attributes.objectClass).toEqual(['top', 'posixAccount', 'inetOrgPerson', 'ldapPublicKey']);
});

test('should handle multiple SSH public keys as array', () => {
const user = {
username: 'multikey',
uid_number: 3001,
gid_number: 3001,
sshpublickey: [
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKey1... user@host1',
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKey2... user@host2'
]
};

const entry = createLdapEntry(user, baseDN);

expect(entry.attributes.sshPublicKey).toEqual([
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKey1... user@host1',
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKey2... user@host2'
]);
expect(entry.attributes.objectClass).toContain('ldapPublicKey');
});

test('should not include sshPublicKey or ldapPublicKey when sshpublickey is not provided', () => {
const user = {
username: 'nossh',
uid_number: 3002,
gid_number: 3002
};

const entry = createLdapEntry(user, baseDN);

expect(entry.attributes.sshPublicKey).toBeUndefined();
expect(entry.attributes.objectClass).not.toContain('ldapPublicKey');
expect(entry.attributes.objectClass).toEqual(['top', 'posixAccount', 'inetOrgPerson']);
});
});

describe('createLdapGroupEntry', () => {
Expand Down
14 changes: 14 additions & 0 deletions server/test/data/common.users.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,19 @@
"home_directory": "/home/disabled",
"login_shell": "/bin/bash",
"enabled": false
},
{
"username": "sshuser",
"password": "sshpass123",
"uid_number": 1004,
"gid_number": 1001,
"full_name": "SSH Test User",
"surname": "User",
"given_name": "SSH",
"mail": "sshuser@example.com",
"home_directory": "/home/sshuser",
"login_shell": "/bin/bash",
"enabled": true,
"sshpublickey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC8... sshuser@testhost"
}
]
10 changes: 6 additions & 4 deletions server/test/data/e2e.sssd.sql
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ CREATE TABLE IF NOT EXISTS users (
uidNumber INT NOT NULL,
gidNumber INT NOT NULL,
homeDirectory VARCHAR(256) NOT NULL,
loginShell VARCHAR(64) NOT NULL DEFAULT '/bin/bash'
loginShell VARCHAR(64) NOT NULL DEFAULT '/bin/bash',
sshpublickey TEXT
);

CREATE TABLE IF NOT EXISTS `groups` (
Expand All @@ -29,12 +30,13 @@ CREATE TABLE IF NOT EXISTS `groups` (
-- Password: 'password123' (bcrypt hashed with 10 rounds)
-- Pre-hashed because this SQL is loaded directly by MySQL, not processed by Node.js
-- Hash generated with: bcrypt.hash('password123', 10)
INSERT INTO users (uid, cn, sn, mail, userPassword, uidNumber, gidNumber, homeDirectory)
INSERT INTO users (uid, cn, sn, mail, userPassword, uidNumber, gidNumber, homeDirectory, sshpublickey)
VALUES
('testuser', 'Test User', 'User', 'testuser@example.com', '$2b$10$DJylnYTJZBhXqzYDV62nTOCW3/6ytjmXITpGo.tSqR5eCppmERflS', 10100, 20100, '/home/testuser');
('testuser', 'Test User', 'User', 'testuser@example.com', '$2b$10$DJylnYTJZBhXqzYDV62nTOCW3/6ytjmXITpGo.tSqR5eCppmERflS', 10100, 20100, '/home/testuser', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKogUL8oT4Sn4+V2zBa4Jtis4CIryh+igq2PTCoYXSw4 testuser@e2e-test'),
('nokeyuser', 'NoKey User', 'NoKey', 'nokeyuser@example.com', '$2b$10$DJylnYTJZBhXqzYDV62nTOCW3/6ytjmXITpGo.tSqR5eCppmERflS', 10101, 20100, '/home/nokeyuser', NULL);

-- Test groups with testuser as member
INSERT INTO `groups` (cn, gidNumber, member_uids)
VALUES
('developers', 20100, JSON_ARRAY('testuser')),
('developers', 20100, JSON_ARRAY('testuser', 'nokeyuser')),
('devops', 20101, JSON_ARRAY('testuser'));
5 changes: 5 additions & 0 deletions server/test/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ It validates full authentication flow:
- `getent passwd testuser` returns correct home directory and shell
- SSH authentication works end-to-end

It validates SSH public key retrieval via `sss_ssh_authorizedkeys`:
- `sss_ssh_authorizedkeys testuser` returns the correct SSH public key from LDAP
- `sss_ssh_authorizedkeys nokeyuser` returns empty for users without SSH keys
- SSH key-based authentication works using the key stored in LDAP

## Test Classification

- **Unit tests** (`test/unit/`): Fast, isolated tests with mocked dependencies
Expand Down
10 changes: 9 additions & 1 deletion server/test/e2e/client/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
openssh-server \
sssd \
sssd-ldap \
sssd-tools \
ldap-utils \
sshpass \
procps \
Expand All @@ -16,9 +17,12 @@ RUN mkdir -p /var/run/sshd && \
echo "Port 2222" >> /etc/ssh/sshd_config && \
echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config && \
echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config && \
echo 'PubkeyAuthentication yes' >> /etc/ssh/sshd_config && \
echo 'UsePAM yes' >> /etc/ssh/sshd_config && \
echo 'LoginGraceTime 30' >> /etc/ssh/sshd_config && \
echo 'MaxAuthTries 3' >> /etc/ssh/sshd_config
echo 'MaxAuthTries 3' >> /etc/ssh/sshd_config && \
echo 'AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u' >> /etc/ssh/sshd_config && \
echo 'AuthorizedKeysCommandUser nobody' >> /etc/ssh/sshd_config

# PAM: create home dirs for LDAP users
RUN echo "session required pam_mkhomedir.so skel=/etc/skel umask=0077" >> /etc/pam.d/sshd
Expand All @@ -38,6 +42,10 @@ RUN useradd -m -s /bin/bash localadmin && echo 'localadmin:localpass' | chpasswd
# Pre-create home directory for testuser to avoid warnings
RUN mkdir -p /home/testuser && chmod 755 /home/testuser

# Copy test SSH private key for key-based auth testing
COPY fixtures/testuser_ed25519 /tmp/testuser_ed25519
RUN chmod 600 /tmp/testuser_ed25519

EXPOSE 2222

CMD service ssh start && /usr/sbin/sssd -i -d 9
7 changes: 7 additions & 0 deletions server/test/e2e/client/fixtures/testuser_ed25519
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCqIFC/KE+Ep+PldswWuCbYrOAiK8ofooKtj0wqGF0sOAAAAJgtlX/ILZV/
yAAAAAtzc2gtZWQyNTUxOQAAACCqIFC/KE+Ep+PldswWuCbYrOAiK8ofooKtj0wqGF0sOA
AAAECyoNqVL1PFfHNetG/g7gV8CDpsiyofSVF9PxauoO78j6ogUL8oT4Sn4+V2zBa4Jtis
4CIryh+igq2PTCoYXSw4AAAAEXRlc3R1c2VyQGUyZS10ZXN0AQIDBA==
-----END OPENSSH PRIVATE KEY-----
1 change: 1 addition & 0 deletions server/test/e2e/client/fixtures/testuser_ed25519.pub
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKogUL8oT4Sn4+V2zBa4Jtis4CIryh+igq2PTCoYXSw4 testuser@e2e-test
5 changes: 4 additions & 1 deletion server/test/e2e/client/sssd.conf
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[sssd]
services = nss, pam
services = nss, pam, ssh
config_file_version = 2
domains = LDAP

Expand Down Expand Up @@ -31,3 +31,6 @@ ldap_group_member = memberUid
# NSS/PAM tuning
entry_cache_timeout = 600
ldap_network_timeout = 3

# SSH public key retrieval
ldap_user_ssh_public_key = sshPublicKey
4 changes: 2 additions & 2 deletions server/test/e2e/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ services:
- TLS_ENABLE=true
- TLS_CERT_PATH=/cert/server.crt
- TLS_KEY_PATH=/cert/server.key
- "SQL_QUERY_ALL_USERS=SELECT uid AS username, cn AS full_name, sn AS surname, mail, homeDirectory AS home_directory, loginShell AS login_shell, uidNumber AS uid_number, gidNumber AS gid_number FROM users"
- "SQL_QUERY_ONE_USER=SELECT uid AS username, cn AS full_name, sn AS surname, mail, homeDirectory AS home_directory, loginShell AS login_shell, uidNumber AS uid_number, gidNumber AS gid_number, userPassword AS password FROM users WHERE uid = ?"
- "SQL_QUERY_ALL_USERS=SELECT uid AS username, cn AS full_name, sn AS surname, mail, homeDirectory AS home_directory, loginShell AS login_shell, uidNumber AS uid_number, gidNumber AS gid_number, sshpublickey FROM users"
- "SQL_QUERY_ONE_USER=SELECT uid AS username, cn AS full_name, sn AS surname, mail, homeDirectory AS home_directory, loginShell AS login_shell, uidNumber AS uid_number, gidNumber AS gid_number, sshpublickey, userPassword AS password FROM users WHERE uid = ?"
- "SQL_QUERY_ALL_GROUPS=SELECT cn AS name, gidNumber AS gid_number, member_uids FROM `groups`"
- "SQL_QUERY_GROUPS_BY_MEMBER=SELECT cn AS name, gidNumber AS gid_number, member_uids FROM `groups` WHERE JSON_CONTAINS(member_uids, JSON_QUOTE(?), '$')"
ports:
Expand Down
28 changes: 28 additions & 0 deletions server/test/e2e/run-sssd-integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,35 @@ else
exit 1
fi

# Test 6: sss_ssh_authorizedkeys returns SSH public key for testuser
echo "Test 6: sss_ssh_authorizedkeys returns SSH key for testuser..."
SSH_KEY_OUTPUT=$(container_exec "sss_ssh_authorizedkeys testuser" || echo "FAIL")
echo " Result: $SSH_KEY_OUTPUT"
echo "$SSH_KEY_OUTPUT" | grep -q "ssh-ed25519" || { echo "FAIL: sss_ssh_authorizedkeys did not return SSH key"; exit 1; }
echo "$SSH_KEY_OUTPUT" | grep -q "AAAAC3NzaC1lZDI1NTE5AAAAIKogUL8oT4Sn4+V2zBa4Jtis4CIryh+igq2PTCoYXSw4" || { echo "FAIL: SSH key content mismatch"; exit 1; }
echo " ✓ SSH public key correctly retrieved via sss_ssh_authorizedkeys"

# Test 7: sss_ssh_authorizedkeys returns empty for user without SSH key
echo "Test 7: sss_ssh_authorizedkeys returns empty for user without SSH key..."
NO_KEY_OUTPUT=$(container_exec "sss_ssh_authorizedkeys nokeyuser" || echo "")
echo " Result: '${NO_KEY_OUTPUT}'"
if [ -z "$NO_KEY_OUTPUT" ] || ! echo "$NO_KEY_OUTPUT" | grep -q "ssh-"; then
echo " ✓ No SSH key returned for user without key"
else
echo "FAIL: Unexpected SSH key returned for nokeyuser: $NO_KEY_OUTPUT"
exit 1
fi

# Test 8: SSH key-based authentication
echo "Test 8: SSH public key authentication..."
KEY_AUTH_OUTPUT=$(docker compose exec -T sssd-client bash -c "ssh -i /tmp/testuser_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -p 2222 testuser@localhost 'whoami'" 2>/dev/null || echo "FAIL")
echo " Result: $KEY_AUTH_OUTPUT"
[[ "$KEY_AUTH_OUTPUT" == "testuser" ]] || { echo "FAIL: SSH key-based auth failed or whoami=$KEY_AUTH_OUTPUT"; exit 1; }
echo " ✓ SSH public key authentication successful"

echo ""
echo "=========================================="
echo "All SSH integration tests passed! ✓"
echo " Tests 1-5: Password auth, UID/GID, groups"
echo " Tests 6-8: SSH key via sss_ssh_authorizedkeys"
echo "=========================================="
10 changes: 6 additions & 4 deletions server/test/e2e/sql/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS users (
uidNumber INT NOT NULL,
gidNumber INT NOT NULL,
homeDirectory VARCHAR(256) NOT NULL,
loginShell VARCHAR(64) NOT NULL DEFAULT '/bin/bash'
loginShell VARCHAR(64) NOT NULL DEFAULT '/bin/bash',
sshpublickey TEXT
);

CREATE TABLE IF NOT EXISTS `groups` (
Expand All @@ -24,11 +25,12 @@ CREATE TABLE IF NOT EXISTS `groups` (
member_uids JSON NOT NULL
);

INSERT INTO users (uid, cn, sn, mail, userPassword, uidNumber, gidNumber, homeDirectory)
INSERT INTO users (uid, cn, sn, mail, userPassword, uidNumber, gidNumber, homeDirectory, sshpublickey)
VALUES
('testuser', 'Test User', 'User', 'testuser@example.com', '$2b$10$HV4N7iwiJsiyERmTieP69.wm./j0esYrr3XdJ1Q2QFqFC0qmhy65q', 10100, 20100, '/home/testuser');
('testuser', 'Test User', 'User', 'testuser@example.com', '$2b$10$HV4N7iwiJsiyERmTieP69.wm./j0esYrr3XdJ1Q2QFqFC0qmhy65q', 10100, 20100, '/home/testuser', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKogUL8oT4Sn4+V2zBa4Jtis4CIryh+igq2PTCoYXSw4 testuser@e2e-test'),
('nokeyuser', 'NoKey User', 'NoKey', 'nokeyuser@example.com', '$2b$10$HV4N7iwiJsiyERmTieP69.wm./j0esYrr3XdJ1Q2QFqFC0qmhy65q', 10101, 20100, '/home/nokeyuser', NULL);

INSERT INTO `groups` (cn, gidNumber, member_uids)
VALUES
('developers', 20100, JSON_ARRAY('testuser')),
('developers', 20100, JSON_ARRAY('testuser', 'nokeyuser')),
('devops', 20101, JSON_ARRAY('testuser'));
Loading