Control SSH access and sudo privileges on your Linux servers through LemonLDAP::NG Web-SSO.
This PAM module integrates your servers with LemonLDAP::NG (LLNG) to centrally manage who can SSH into which servers and who can use sudo. Administrators define access rules in the LLNG portal, and the PAM module enforces them on each server.
The module supports two authentication methods:
- Token-based authentication: Users generate temporary access tokens from the LLNG portal to use as SSH passwords
- Key-based authorization: When users connect via SSH keys, the module checks if they're authorized to access this server
- Token introspection via OIDC introspection endpoint
- Server authorization via
/pam/authorizeendpoint - Server groups support for granular access control
- Token caching to reduce server load
- Secure communication with SSL/TLS support
- Easy server enrollment with
llng-pam-enrollscript - Security hardening:
- Structured JSON audit logging with correlation IDs
- Rate limiting with exponential backoff
- AES-256-GCM encrypted secret storage
- Webhook notifications for security events
- Token binding (IP, fingerprint)
Globally:
- A LemonLDAP::NG system >= 2.21.0 (LTS) with additional plugins installed and enabled
On each SSH servers to protect:
- libcurl
- json-c
- OpenSSL
- libkeyutils
- PAM development headers
- curl and jq (for enrollment script)
sudo apt-get install libcurl4-openssl-dev libjson-c-dev libpam0g-dev libssl-dev libkeyutils-dev cmake curl jqsudo dnf install libcurl-devel json-c-devel pam-devel openssl-devel keyutils-libs-devel cmake curl jqBefore deploying the PAM module on your servers, you need to configure LemonLDAP::NG.
Copy the plugins from the llng-plugin directory to your LemonLDAP::NG installation:
sudo cp -r llng-plugin/usr/share/* /usr/share/This installs:
- PamAccess - Main plugin: token generation interface and authorization endpoints
- OIDCDeviceAuthorization - Server enrollment via OAuth 2.0 Device Authorization Grant (RFC 8628)
- SSHCA (optional) - SSH Certificate Authority for key-based authentication
In the LLNG Manager, create a new OIDC Relying Party:
- Go to OpenID Connect Relying Parties → Add
- Configure:
- Client ID:
pam-access - Client secret: Generate a strong secret
- Allowed grant types: Enable
device_code(for server enrollment) - Allowed scopes:
openid,pam:server
- Client ID:
Use customPlugins inside lemonldap-ng.ini, section [portal]:
- without SSHCA:
[portal]
customPlugins = ::Plugin::OIDCDeviceAuthorization, ::Plugins::PamAccess- with SSHCA
[portal]
customPlugins = ::Plugin::OIDCDeviceAuthorization, ::Plugins::PamAccess, ::Plugins::SSHCAAdditional and optional parameters that can be inserted into lemonldap-ng.ini, section [portal]:
oidcServiceDeviceAuthorizationExpiration(default600== 10mn)oidcServiceDeviceAuthorizationPollingInterval(default5)oidcServiceDeviceAuthorizationUserCodeLength(default8)portalDisplayPamAccess(default0): set to 1 (or a rule) to display PAM tab into Lemonldap-NG module, useless if you're using SSHCApamAccessRp(defaultpam-access)pamAccessTokenDuration(default600== 10mn)pamAccessMaxDuration(default3600== 1h)pamAccessExportedVars(default{})pamAccessOfflineTtl(default86400== 1d)pamAccessSshRules(default{})pamAccessServerGroups(default{})pamAccessSudoRules(default{})pamAccessOfflineEnabled(default0)pamAccessHeartbeatInterval(default300== 5mn)portalDisplaySshCa(default0): set to 1 (or a rule) to display SSHCA tab into Lemonldap-NG module if you're using SSHCAsshCaCertMaxValidity(default365== 1y)sshCaSerialPath(default ""): set it to the path where the certificates serial will be stored (/var/lib/lemonldap-ng/sshfor example)sshCaPrincipalSources(default$uid)sshCaKrlPath(default ""): set it to the path where the Certificate Revocation List will be stored
If you're using the SSH CA plugin for key-based authentication, you need to generate a CA key pair and import it into LemonLDAP::NG.
# Generate Ed25519 CA key pair (recommended)
openssl genpkey -algorithm ed25519 -out ssh-ca.key
openssl pkey -in ssh-ca.key -pubout -out ssh-ca.pub
# Display keys for import into LLNG Manager
echo "=== Private Key (copy this) ==="
cat ssh-ca.key
echo "=== Public Key (copy this) ==="
cat ssh-ca.pubAlternatively, for compatibility with older systems, use RSA:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out ssh-ca.key
openssl pkey -in ssh-ca.key -pubout -out ssh-ca.pub- Go to General Parameters → Keys → Add a key
- Set a key name (e.g.,
ssh-ca) - Paste the private key content into Private key
- Paste the public key content into Public key
- Save the configuration
Then configure the SSH CA plugin to use this key inside lemonldap-ng.ini, section [portal]:
[portal]
sshCaKeyRef = ssh-caInsert this into lemonldap-ng.ini, section [portal]:
[portal]
keys = { ssh-ca => { keyPublic => "<public key value>", keyPrivate => "<private key value>" } }
sshCaKeyRef = ssh-casudo mkdir -p /var/lib/lemonldap-ng/ssh
sudo chown www-data:www-data /var/lib/lemonldap-ng/sshThese directories store the certificate serial number counter and the Key Revocation List (KRL).
sudo systemctl restart lemonldap-ng-fastcgi-server
# or
sudo systemctl restart apache2 # if using mod_perlmkdir build && cd build
cmake ..
make
sudo make installThis installs:
/usr/lib/security/pam_llng.so- The PAM module/usr/sbin/llng-pam-enroll- Server enrollment script/etc/security/pam_llng.conf.example- Example configuration
sudo cp /etc/security/pam_llng.conf.example /etc/security/pam_llng.conf
sudo chmod 600 /etc/security/pam_llng.conf
sudo nano /etc/security/pam_llng.confConfigure at minimum:
portal_url = https://auth.example.com
client_id = pam-access
client_secret = your-secret-here
server_group = defaultRun the enrollment script as root:
sudo llng-pam-enrollThe script will:
- Initiate a Device Authorization request
- Display a user code for administrator approval
- Wait for the administrator to approve the server
- Save the server token to
/etc/security/pam_llng.token
Administrator approval: An administrator must visit the LLNG portal, go to the device verification page, and enter the displayed code to approve this server.
Edit /etc/pam.d/sshd. The configuration depends on your authentication mode.
Important: The configurations below have different security implications regarding which authentication methods are accepted. Read the descriptions carefully.
Only LLNG tokens are accepted as passwords. Unix passwords are rejected.
This is the most secure mode: users must authenticate via LemonLDAP::NG.
# /etc/pam.d/sshd
#
# AUTHENTICATION: Only LLNG tokens accepted
# - Unix passwords: REJECTED
# - LLNG tokens: ACCEPTED
# - SSH keys: depends on sshd_config (PubkeyAuthentication)
auth sufficient pam_llng.so
auth required pam_deny.so
account required pam_llng.so
account required pam_unix.so
session required pam_unix.so
Both LLNG tokens AND traditional Unix passwords are accepted.
Useful for transition periods or when some users don't have LLNG accounts.
# /etc/pam.d/sshd
#
# AUTHENTICATION: LLNG token OR unix password
# - Unix passwords: ACCEPTED (fallback)
# - LLNG tokens: ACCEPTED (tried first)
# - SSH keys: depends on sshd_config
auth sufficient pam_llng.so
auth sufficient pam_unix.so nullok try_first_pass
auth required pam_deny.so
account required pam_llng.so
account required pam_unix.so
session required pam_unix.so
SSH key authentication only, but LLNG checks if user is authorized.
Users authenticate with SSH keys. PAM doesn't handle password authentication, but LLNG verifies the user has permission to access this server.
# /etc/pam.d/sshd
#
# AUTHENTICATION: Handled by SSH keys (not PAM)
# - Unix passwords: NOT USED (disable PasswordAuthentication in sshd_config)
# - LLNG tokens: NOT USED
# - SSH keys: REQUIRED
#
# AUTHORIZATION: LLNG checks if user can access this server
auth required pam_permit.so
account required pam_llng.so
account required pam_unix.so
session required pam_unix.so
For this mode, configure /etc/ssh/sshd_config:
PasswordAuthentication no
PubkeyAuthentication yes
SSH keys, LLNG tokens, AND Unix passwords all accepted. LLNG authorization required.
Maximum flexibility: any authentication method works, but users must be authorized in LLNG to access this server.
# /etc/pam.d/sshd
#
# AUTHENTICATION: Any method accepted
# - Unix passwords: ACCEPTED
# - LLNG tokens: ACCEPTED
# - SSH keys: ACCEPTED (if enabled in sshd_config)
#
# AUTHORIZATION: LLNG checks if user can access this server
auth sufficient pam_llng.so
auth sufficient pam_unix.so nullok try_first_pass
auth required pam_deny.so
account required pam_llng.so
account required pam_unix.so
session required pam_unix.so
| Mode | Unix Password | LLNG Token | SSH Key | LLNG Authorization |
|---|---|---|---|---|
| A - LLNG Only | ❌ Rejected | ✅ Required | Optional* | ✅ Required |
| B - LLNG + Unix | âś… Fallback | âś… Preferred | Optional* | âś… Required |
| C - SSH Key Only | ❌ Disabled | ❌ Not used | ✅ Required | ✅ Required |
| D - All Methods | âś… Accepted | âś… Accepted | Optional* | âś… Required |
* SSH key authentication depends on PubkeyAuthentication in sshd_config
Edit /etc/ssh/sshd_config according to your chosen mode:
UsePAM yes
PasswordAuthentication yes
KbdInteractiveAuthentication yes
PubkeyAuthentication yes # Optional: also allow SSH keys
PermitEmptyPasswords no
UsePAM yes
PasswordAuthentication no # Disable password authentication
KbdInteractiveAuthentication no
PubkeyAuthentication yes # SSH keys required
PermitEmptyPasswords no
UsePAM yes
PasswordAuthentication yes
KbdInteractiveAuthentication yes
PubkeyAuthentication yes
PermitEmptyPasswords no
Restart SSH:
sudo systemctl restart sshdImportant: Open a new terminal and keep your current session open as backup!
# Test with LLNG token (Modes A, B, D)
ssh user@server
Password: <paste LLNG token from portal>
# Test with Unix password (Modes B, D only)
ssh user@server
Password: <unix password>
# Test with SSH key (Modes C, D, or any mode with PubkeyAuthentication yes)
ssh -i ~/.ssh/id_rsa user@serverThe llng-pam-enroll script automates the Device Authorization Grant flow.
sudo llng-pam-enroll [OPTIONS]| Option | Description |
|---|---|
-p, --portal URL |
LemonLDAP::NG portal URL |
-c, --client-id ID |
OIDC client ID (default: pam-access) |
-s, --client-secret SECRET |
OIDC client secret |
-g, --server-group GROUP |
Server group name (default: default) |
-t, --token-file FILE |
Where to save the token (default: /etc/security/pam_llng.token) |
-C, --config FILE |
Configuration file (default: /etc/security/pam_llng.conf) |
-k, --insecure |
Skip SSL certificate verification |
-q, --quiet |
Quiet mode |
-h, --help |
Show help |
# Enroll using settings from config file
sudo llng-pam-enroll
# Enroll with explicit parameters
sudo llng-pam-enroll -p https://auth.example.com -s mysecret
# Enroll for a specific server group
sudo llng-pam-enroll -g production
# Enroll with custom token file location
sudo llng-pam-enroll -t /etc/pam_llng/server.tokenIf you prefer manual enrollment:
curl -X POST https://auth.example.com/oauth2/device \
-d "client_id=pam-access" \
-d "scope=pam:server"Response:
{
"device_code": "...",
"user_code": "ABCD-EFGH",
"verification_uri": "https://auth.example.com/device",
"expires_in": 1800
}An administrator visits https://auth.example.com/device, logs in, and enters the user code.
curl -X POST https://auth.example.com/oauth2/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=<device_code_from_step_1>" \
-d "client_id=pam-access" \
-d "client_secret=your-secret"echo "<access_token>" | sudo tee /etc/security/pam_llng.token
sudo chmod 600 /etc/security/pam_llng.token# Required: LemonLDAP::NG portal URL
portal_url = https://auth.example.com
# Required: OIDC client credentials
client_id = pam-access
client_secret = your-secret
# Server token file (created by enrollment)
server_token_file = /etc/security/pam_llng.token
# Server group for authorization rules
server_group = default
# HTTP settings
timeout = 10
verify_ssl = true
# ca_cert = /etc/ssl/certs/custom-ca.pem
# Cache settings
cache_enabled = true
cache_dir = /var/cache/pam_llng
cache_ttl = 300
cache_ttl_high_risk = 60
high_risk_services = sudo,su
# Logging: error, warn, info, debug
log_level = warn
# Audit logging
audit_enabled = true
audit_log_file = /var/log/pam_llng/audit.json
audit_to_syslog = true
audit_level = 1 # 0=critical, 1=auth events, 2=all
# Rate limiting
rate_limit_enabled = true
rate_limit_max_attempts = 5
rate_limit_initial_lockout = 30
rate_limit_max_lockout = 3600
# Webhook notifications (optional)
# notify_enabled = true
# notify_url = https://alerts.example.com/webhook
# notify_secret = your-hmac-secretArguments can be passed directly in PAM configuration:
auth required pam_llng.so portal_url=https://auth.example.com debug
| Argument | Description |
|---|---|
conf=/path/to/file |
Use alternate config file |
portal_url=URL |
Override portal URL |
server_group=GROUP |
Override server group |
debug |
Enable debug logging |
authorize_only |
Skip password check (for SSH key mode) |
no_cache |
Disable token caching |
insecure |
Skip SSL verification |
no_audit |
Disable audit logging |
no_rate_limit |
Disable rate limiting |
no_bind_ip |
Disable IP binding for tokens |
Server groups allow different authorization rules for different server categories.
General Parameters > Plugins > PAM Access > Server Groups
production => $hGroup->{ops}
staging => $hGroup->{ops} or $hGroup->{dev}
dev => $hGroup->{dev}
default => 1
In /etc/security/pam_llng.conf:
server_group = productionOr during enrollment:
sudo llng-pam-enroll -g production- User visits the LLNG portal
- Navigates to "PAM Access" tab
- Generates a temporary token (valid 5-60 minutes)
- SSH to server, paste token as password:
ssh user@server.example.com
Password: <paste token>- User has SSH key configured normally
- SSH to server:
ssh user@server.example.com- PAM module checks authorization via LLNG
- Access granted or denied based on rules
# System auth log
sudo tail -f /var/log/auth.log
# Or journald
sudo journalctl -u sshd -fIn /etc/security/pam_llng.conf:
log_level = debugcurl -X POST https://auth.example.com/oauth2/introspect \
-u "pam-access:secret" \
-d "token=<user_token>"curl -X POST https://auth.example.com/pam/authorize \
-H "Authorization: Bearer $(sudo cat /etc/security/pam_llng.token)" \
-H "Content-Type: application/json" \
-d '{"user": "testuser", "host": "'$(hostname)'", "server_group": "default"}'| Issue | Cause | Solution |
|---|---|---|
PAM unable to load module |
Module not in path | Check /lib/security/ or /lib64/security/ |
Token introspection failed |
Wrong credentials | Verify client_id and client_secret |
Server not enrolled |
Missing/invalid token | Run llng-pam-enroll |
User not authorized |
Server group rules | Check LLNG Manager configuration |
Connection refused |
Portal unreachable | Check network and portal_url |
If the server token expires or is compromised:
sudo rm /etc/security/pam_llng.token
sudo llng-pam-enroll- Protect configuration files:
/etc/security/pam_llng.confand.tokenshould be readable only by root - Use TLS: Always use HTTPS for portal_url
- Server tokens: Server tokens are automatically rotated via refresh token mechanism (
token_rotate_refresh = trueby default). If you suspect compromise, re-enroll the server withllng-pam-enroll - Backup access: Keep a root password or console access as fallback
AGPL-3.0
Xavier Guimard xguimard@linagora.com
Copyright (C) 2025 Linagora