Skip to content

Repository files navigation

pam-device-auth

pam-device-auth adds browser-based sign-in as a second factor to SSH.

A user first proves possession of an SSH key. They then approve the login with your identity provider in a browser. Access is granted only when both steps succeed. Users, groups, SSH keys and sudo permissions stay in your central LDAP directory instead of being managed separately on every server.

It is built for teams that want to:

  • manage Linux SSH access centrally;
  • require an SSH key and company sign-in for every non-root login;
  • grant or revoke access through directory groups;
  • provision key-only service accounts for automation, in a dedicated directory group, without the OIDC step;
  • use the directory password for sudo, or grant passwordless sudo to selected groups;
  • optionally restrict users to approved source IPs, enforced for key-based service accounts too;
  • keep a local root SSH key as an emergency path if LDAP or the identity provider is unavailable.

The tested setup is Keycloak with LLDAP. Other providers can be used when they support the Device Authorization Grant, serve discovery/token/JWKS endpoints from the issuer host, and issue an access token with preferred_username, the configured role claim, and either azp or a matching aud. Templates for Auth0, Okta and Authentik are included.

What you need

  • Ubuntu 24.04+, Debian 13+, Rocky Linux 9 or 10, or a compatible amd64 Linux host with OpenSSH 9.6+.
  • An LDAP directory containing each user's Unix ID, group membership and SSH public key.
  • An OIDC provider with the Device Authorization Grant enabled, such as Keycloak, Auth0, Okta or Authentik.
  • A root SSH-key session during setup, so you can recover from configuration mistakes.

pam-device-auth configures SSSD and OpenSSH for this model. It does not replace LDAP or your identity provider.

How auth works

  1. The user runs ssh user@host.
  2. OpenSSH checks the user's public key from LDAP.
  3. pam-device-auth displays a URL, code and optional QR code.
  4. The user approves the login with the identity provider.
  5. pam-device-auth verifies the user, required role and optional IP restriction.
  6. The shell opens and the home directory is created if needed.

Later logins from a known IP can use a securely cached refresh token, so the user does not need to open the browser every time. The SSH key is still required on every login. sudo asks for the user's directory password. Root uses only its local SSH key and never depends on LDAP or OIDC.

Quick start

Before installing, prepare:

  • LDAP connectivity from the server. Use LDAPS where possible and install the directory CA on the server.
  • An access group such as ssh-access and an optional sudo group such as ssh-admin.
  • uidNumber, gidNumber and sshPublicKey for every user who should log in. On unprivileged LXC containers, keep IDs below 65536.
  • A public OIDC client with Device Authorization enabled and the access role included in its tokens.

LLDAP setup

The reference deployment is tested with LLDAP 0.6.3. LLDAP does not ship these POSIX/SSH attributes by default, so create them before configuring the first host:

Schema Attribute Type List Value
Group gidNumber Integer No unique Unix group ID
User uidNumber Integer No unique Unix user ID
User gidNumber Integer No primary group ID, usually the access-group ID
User sshPublicKey String No complete OpenSSH public key
User, optional clients String Yes allowed source IPs or CIDRs

Create the attributes in the LLDAP Admin UI under Attributes or through its GraphQL API. Set gidNumber on the access group (for example 6000) and the sudo group (for example 6001). Every group you reference from the config needs a gidNumber, including the optional service-account group (ssh-service, for example 6002) and passwordless-sudo group (ssh-admin-nopasswd, for example 6003). A group without a gidNumber does not resolve through SSSD, so the sshd Match Group block and the sudoers rules never match and its members are locked out. Set each user's primary gidNumber to the access-group ID, assign a unique uidNumber, and add the user to the access group. Add administrators to the sudo group as well.

Use LLDAP's built-in uid for the username and built-in member for group membership. Groups remain groupOfNames; do not convert them to posixGroup. The SSSD configuration generated by pam-device-auth uses the rfc2307bis membership model.

You do not need to add homeDirectory or loginShell: --setup-ldap sets the home to /home/<username> and the shell to /bin/bash. On unprivileged LXC containers, keep all UIDs and GIDs below 65536.

Install via the APT repository (recommended; pulls sssd-ldap, libnss-sss, libpam-sss, and sssd-dbus as recommended dependencies):

sudo install -d -m755 /etc/apt/keyrings
curl -fsSL https://apt.nk-it.cloud/gpg.key \
  | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/nk-it-cloud.gpg
echo "deb [signed-by=/etc/apt/keyrings/nk-it-cloud.gpg] https://apt.nk-it.cloud/apt stable main" \
  | sudo tee /etc/apt/sources.list.d/nk-it-cloud.list
sudo apt update
sudo apt install pam-device-auth

Or install a downloaded package manually: sudo apt install ./pam-device-auth_<version>_amd64.deb. On Rocky Linux 9 or 10, install the RPM release asset instead: sudo dnf install ./pam-device-auth-<version>.x86_64.rpm.

Configure (see configs/config.json):

sudo install -m600 /dev/stdin /etc/pam-device-auth/config.json <<'JSON'
{
  "issuer_url": "https://sso.example.com/realms/myrealm",
  "client_id": "ssh-server",
  "required_role": "ssh-access",
  "sudo_role": "ssh-admin",
  "allowed_algorithms": ["RS256"],
  "ldap": {
    "uri": "ldaps://ldap.example.com:636",
    "base_dn": "dc=example,dc=com",
    "bind_dn": "uid=nss-ro,ou=people,dc=example,dc=com",
    "bind_password": "<read-only bind password>",
    "access_group": "ssh-access",
    "admin_group": "ssh-admin",
    "service_account_groups": ["ssh-service"],
    "nopasswd_sudo_groups": ["ssh-admin-nopasswd"]
  }
}
JSON

The last two `ldap` fields are optional. `service_account_groups` lists groups
whose members log in with a directory key only, without the OIDC step, for
automation accounts. `nopasswd_sudo_groups` lists groups granted passwordless
sudo. Leave them out if you do not use those tiers.

sudo pam-device-auth --setup-ldap   # SSSD + nsswitch + mkhomedir + sudoers + sshd drop-in (+ auto-migrate)
sudo pam-device-auth --check        # verify OIDC, SSSD, directory keys and effective sshd policy
sudo pam-device-auth --enable       # activate the PAM/sshd config (restarts sshd)

Activation order matters: populate every user's sshPublicKey in LDAP before --enable, or non-root users cannot satisfy the key factor and are locked out (root is unaffected).

Existing local accounts: --setup-ldap automatically migrates same-name local accounts that shadow a directory identity. It prints the full scope, terminates those users' processes, removes their local passwd/shadow entries, and preserves/re-owns their home files. Run it from a persistent root session and review the warning before continuing with activation.

--check must report no [FAIL] items before activation. A [WARN] is non-blocking and may be intentional, for example an access-group member that is deliberately not provisioned with an SSH key. See the preflight example for the complete output format.

CLI: --check · --setup-ldap · --enable · --pam-acct (internal, used by the PAM account phase) · --debug · --version · --help

Configuration

/etc/pam-device-auth/config.json (root-only, 0600):

Field Req Description
issuer_url yes OIDC issuer URL (https)
client_id yes public OAuth2 client (device grant enabled)
required_role yes OIDC role required to log in (e.g. ssh-access)
sudo_role no admin-role label shown by --check; sudo enforcement uses ldap.admin_group
allowed_algorithms no pin accepted JWT alg (e.g. ["RS256"] / ["ES256"]); empty = any supported
role_claim no extra dotted/flat claim path for roles (supplements Keycloak realm/client roles)
auth_timeout no device-flow timeout, 30-240 s (default 180)
show_qr no true/false/omit (auto-detect client)
ldap setup/check directory settings for --setup-ldap and directory preflight: uri, base_dn, bind_dn, bind_password, access_group, admin_group; optional user_search_base, group_search_base, service_account_groups, nopasswd_sudo_groups

The ldap.bind_password is read by --setup-ldap and written into the root-only /etc/sssd/sssd.conf for SSSD. Environment overrides exist only for issuer_url, client_id, required_role, sudo_role, role_claim, and auth_timeout through PAM_DEVICE_AUTH_ISSUER, PAM_DEVICE_AUTH_CLIENT_ID, PAM_DEVICE_AUTH_REQUIRED_ROLE, PAM_DEVICE_AUTH_SUDO_ROLE, PAM_DEVICE_AUTH_ROLE_CLAIM, and PAM_DEVICE_AUTH_TIMEOUT.

Group model

Two group axes are independent of each other:

  • SSH auth: ldap.access_group gets the normal key + OIDC login; any group listed in ldap.service_account_groups logs in with the SSH key only, no OIDC step. This is for automation and other service accounts. A user needs membership in one of these two kinds of groups to log in at all.
  • sudo: ldap.admin_group gets password sudo through pam_sss; any group listed in ldap.nopasswd_sudo_groups gets passwordless sudo (NOPASSWD:ALL), written to /etc/sudoers.d/pam-device-auth by --setup-ldap.

Any new LLDAP group used in these lists needs a POSIX gidNumber, or SSSD will not resolve it.

Per-user IP allowlist

The optional clients LDAP attribute holds a per-user list of source IPs/CIDRs. It is enforced on every login, including key-only service accounts, by the PAM account phase (--pam-acct), which reads clients for the logging-in user from the SSSD InfoPipe over busctl. Root and other local (non-directory) users are exempt, since break-glass access must not depend on SSSD.

This check fails closed: if the InfoPipe lookup for a directory user errors out, that login is denied. An SSSD or InfoPipe outage therefore denies all directory users, not just the ones with a clients value set. --check reports both the IP-pin posture and InfoPipe health so this can be caught before it happens in production.

On Debian and Ubuntu, the InfoPipe (ifp) responder that --setup-ldap configures needs the sssd-dbus package, which --setup-ldap installs. On RHEL-family systems it ships inside sssd-common.

Migration of existing hosts

If a local account has the same name but a different UID from a directory user, --setup-ldap prints the complete migration scope, terminates that user's processes, removes the local passwd/shadow entry with userdel, and re-owns the existing home, mail spool and crontab to the directory UID/GID. It does not delete the home directory. Genuine local-only accounts and same-UID entries are left unchanged.

Security

  • 2FA: directory SSH key and OIDC, re-validated every login (signature, issuer, azp/aud, exp/nbf/iat, required role). Algorithm/key-type binding (RS/ES 256/384/512), kid required, no none/HMAC.
  • Source-IP allowlist: the per-user clients attribute is enforced in the PAM account phase for every login, including key-only service accounts. Only root is exempt. See docs/ip-allowlist.md.
  • Directory revocation: remove a user from the access group and its mapped OIDC access role. New logins are denied after SSSD and identity-provider federation/cache updates. Existing SSH sessions are not terminated.
  • sudo: members of ldap.admin_group authenticate with the directory password through pam_sss; members of ldap.nopasswd_sudo_groups get NOPASSWD:ALL instead. No local passwords or /etc/shadow to drift.
  • C PAM shim: fork/exec (no shell), clearenv + whitelisted env, pipe2(O_CLOEXEC), strtok_r, SIGPIPE handling, per-call timeout, log sanitization.
  • Cache: refresh token in tmpfs /run/pam-device-auth/ (0700 root:root, cleared on reboot); known-IP list capped (FIFO). The silent cached-refresh path still requires the SSH key factor.
  • Root break-glass: key-only, independent of OIDC/SSSD/LDAP availability.
  • Uninstall: removing the package restores the original /etc/pam.d/sshd, removes the sshd drop-in, and deletes the managed /etc/sudoers.d/pam-device-auth file, so no NOPASSWD grant is left behind. This applies to both the .deb (dpkg) and the .rpm (dnf/rpm) package.

See SECURITY.md for the threat model and reporting.

Building from source

# Go 1.26+, GCC, libpam0g-dev
sudo apt install build-essential libpam0g-dev
make build-all   # Go helper (CGO-free) + C PAM module
make test        # tests with race detector
make deb         # Debian package, built with nfpm from nfpm.yaml
make rpm         # RPM package, from the same nfpm.yaml

See CONTRIBUTING.md for the development workflow.

License

MIT

About

SSH authentication via OIDC Device Authorization Grant

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages