ADR-0006: OIDC login with a key-bound nonce, and short-lived X.509 and SSH certificates
- Status: Accepted (owner approved 2026-10-13)
- Date: 2026-10-13
Context
Section titled “Context”Engineers log in with Okta (Dex or Keycloak locally). Every downstream credential must be short-lived, and no long-lived user secret may exist. SAML and SCIM are out of scope but must stay possible to add.
Decision
Section titled “Decision”-
Flows: Authorization Code with PKCE through a loopback redirect (RFC 8252) by default, and Device Authorization Grant (RFC 8628) with
--device. The CLI is a public client that talks to the IdP directly, using theopenidconnectcrate. -
Key binding:
- The CLI generates its keys first.
- It sets
nonce = b64url(SHA-256(x509_pub ‖ ssh_pub ‖ 32 random bytes)). - The control plane accepts the ID token only if its
noncematches the presented keys.
A relaying proxy therefore can’t get certs issued for its own keys.
-
Issued credentials:
- an X.509 client cert (an OpenSSH user cert was planned here too; see the Phase 2 amendment)
- both with a 10-minute TTL by default, configurable per role, capped by a cluster
max_cert_ttl - Amended 2026-10-28: the default is now 1 hour, and a deployment sets its own with
--default-cert-ttl(LIMEN_DEFAULT_CERT_TTL), which may not exceedmax_cert_ttl. A role’scert_ttlstill overrides it (the shortest across a user’s roles wins). Without a stored refresh token, 10 minutes sent people back to the browser for nearly every new connection (eachkubectlcall, each database GUI reconnect), while revocation (logout, locks, kills) already ends access within one policy refresh and a stolen certificate is useless without its private key. An hour also bounds how long a role removed in the IdP survives, since roles are fixed at login. Bots are unchanged (ADR-0019: 15 minutes, at most an hour). - roles and the login session ID carried as URI SANs in the X.509 cert (
limen://user/…,limen://role/…,limen://login/…, with percent-encoded segments; seelimen_core::identity), and in the SSH cert’s key ID and extensions. Amended in Phase 1: URI SANs replace the custom extension planned here. They need no private OID arc, they’re understood by every X.509 parser, and they verify offline.
-
Groups → roles come from
role_mappingdocuments (ADR-0008). Groups are read from a configurable claim (defaultgroups). For Okta, the custom authorization server needs a groups claim configured; this goes in the Okta setup guide. -
No refresh token is stored by default. Users run
limen loginagain, which is silent if the IdP session is alive. -
OIDC login lives in the control plane (
oidc.rs) and the CLI. A placeholderlimen-authcrate with a one-variantIdentityProviderenum was removed unused (#86). If SAML is taken on, add the provider seam with it, in the same change.
Consequences
Section titled “Consequences”- Amended in Phase 2: login issues no SSH certificate. SSH certificates are per session (ADR-0007, amendment). Login returns the SSH host CA’s public key so the CLI can verify workers.
- + Stolen certs are worthless after their TTL. Private keys never leave the machine.
- − Each user who connects after the TTL expires has to log in again (a browser round trip, usually silent).
- Unverified against real service: Okta’s group claim limits (Okta caps the number of groups in a token; large memberships need a filter regex). This is documented in the Okta guide.