Skip to content

Identity provider

ssoosshd has no user database of its own. Everyone who approves a certificate signs in through your OIDC provider first, and the provider stays authoritative for everything that follows – including who is an admin. This page covers registering the client and mapping its claims.

Any OIDC-compliant provider. ssoosshd uses standard discovery (/.well-known/openid-configuration), the authorization-code flow with PKCE, and reads its identity out of the ID token. There is no provider-specific code.

PKCE can be switched off with authentication.disable_pkce for a provider that refuses a code challenge – see the troubleshooting entry below before you reach for it.

pocket-id is the reference provider the project’s own configuration assumes, and the worked example below. Nothing about the setup is specific to it.

Item Value
Redirect URI <http.public_url>/auth/callback – exactly one, no others
Grant authorization code
Scopes openid, plus whatever authentication.scopes names (default profile email)
Claims a username claim, and a groups claim if any policy gates on a group

openid is always requested and does not need to be listed in scopes.

authentication:
provider_url: "https://idp.example.com" # base URL; discovery must resolve
client_id: "..."
client_secret: "..."

authentication.provider_url is the issuer’s base URL. A trailing /.well-known/openid-configuration is stripped automatically, so pasting the discovery URL works too.

The redirect URI is not configured. It is derived from http.public_url, because that is the one place the browser-visible identity of the deployment is written down. Get public_url right and the redirect URI follows.

  1. Run pocket-id and complete its first-run admin setup.
  2. In its admin UI, create an OIDC client for ssoossh. Set the callback URL to <http.public_url>/auth/callback – for a deployment at https://ssh.example.com, that is https://ssh.example.com/auth/callback. Note the generated client ID and secret.
  3. Create or import the people who will log in, and any groups the configuration gates on – an SSH Sudoers group for cert_options.pam.require.group, say, or the admin groups from Roles and containment.
  4. Put provider_url, client_id, and client_secret into ssoosshd.yaml. Leave authentication.fields.username at its default and set authentication.fields.groups if group-gated certificate types are in use.
  5. Restart ssoosshd, then confirm from the server itself that GET <provider_url>/.well-known/openid-configuration resolves. That is the first thing a typo’d provider_url breaks.

authentication.fields names which claim fills each ssoossh identity field. The defaults suit a provider that follows the usual claim names.

Key Default What it feeds
fields.subject sub the unique account identifier every login is keyed by. See Choosing the account identifier
fields.username preferred_username the primary principal, and {{.Username}} in key IDs. Required
fields.name name the person’s human-readable name, shown in the web UI and offered to email templates. Display only
fields.groups groups require gates, lifetime tiers, and the admin roles. A JSON array of names
fields.other_accounts empty alternate account names added to a certificate’s principal list
fields.service_accounts empty which service accounts this identity may enroll and manage
fields.email email the address notifications go to. Absent is not an error
fields.extra none operator-named claims, for key ID templates and claim conditions

Group membership is never written into an issued certificate. It feeds policy decisions and role checks only.

fields.extra maps a template field name to a claim name:

authentication:
fields:
extra:
dept: "https://idp.example.com/department"
loc: level_of_confidence

Each configured claim is captured at login and stored on the user’s row. Scalars become strings, JSON arrays keep their string elements as a list. A claim absent from the ID token stores empty and renders as MISSING; login never fails over one. Every claim a policy condition names must be declared here, and that is checked at startup, so a typo stops the process instead of quietly failing the condition on every evaluation. Details: Key ID templates and Certificate lifetime policy.

Directory attributes can fill the same fields when OIDC does not carry them – see LDAP enrichment.

authentication.fields.subject names the claim ssoossh keys a person by. It is the value stored on users.subject, and the only thing that ties someone to their certificate history, their enrollments and their audit trail across logins.

It defaults to sub, which is what OIDC guarantees to be stable per issuer and is the right answer for most providers. Two cases where it is not:

  • Entra ID issues a per-application sub and puts the tenant-stable identifier in oid. Registering a second application client for the same people would give them all new sub values, and so new accounts.
  • A provider fronting a directory may carry the directory’s own UUID in a private claim, which is worth keying on so that ssoossh and the directory agree on who someone is.

Username and email are not candidates and never will be. Both change – a rename, a marriage, a team move – and keying on either would silently fork a person’s history into a second account, leaving their certificates and enrollments attached to an identity nobody can log in as.

The Claims echo reports which claim this reads, and it is the first thing to check there: every other mapping can be wrong and be corrected later, while a subject claim that varies between logins creates a new account each time.

Writing authentication.fields is guesswork until you know what is in the token. The claims echo removes the guessing: it shows the person who signs in their own claims, every claim annotated by the configured field that consumes it, and the configuration that would fix or extend the mapping. It comes in two forms that show the same result.

The admin page below needs a sign-in that already works and an admin role that is already detected, which is exactly what is missing on day one. The host command needs neither:

Terminal window
sudo -u ssoossh ssoosshd identity echo

It prints a one-time link to /identity/echo on the server. Open it in a browser, press Sign in to see my claims, and sign in at your provider. No session is created, no account is written, and no role is needed. The link works once, for 15 minutes (--ttl changes that), and minting another replaces it. The code rides in the link’s fragment, so no access log or proxy sees it, and the database keeps only its hash. Whoever can run the command can already read the config and the database, which is the only authority there is before the mapping works.

The command waits and prints what happened: when and from which address the link was used, and either that the claims were shown or the error that came before any claims. That error (a wrong client secret, an unregistered redirect URI, a token from the wrong issuer) is also on the page; until now it was only in the server log. The command never prints claims. Ctrl-C retires an unused link.

The same command is useful after setup: an admin debugging one person’s claims can mint a link and send it to them, and they see only their own.

Claims echo (/admin/identity/echo, admin-only) does the same from the web UI. It re-authenticates rather than remembers. There is no stored copy of your claims to show – the server keeps only what the configuration maps – so starting an echo signs you in again with prompt=login, and the callback renders the token instead of establishing a session. Your existing session is untouched, no user record is written, and no login is recorded.

  • Would this sign in. The login’s own claim mapping, run on this token: yes, or the error a login would return (for example the username claim is missing). Whether the account is disabled, and what the directory adds, are decided at login and not shown.
  • Mapping. Each authentication.fields entry, the claim it reads, and whether that resolves, is missing, is empty or has the wrong shape, with the value it resolves to. The same code a login uses, so what the echo says is what a login concludes.
  • Suggested configuration. The mapping with each field that does not resolve replaced by the first candidate, and a picker for admin.require_group filled from your groups claim. Copy YAML and Download result are built in the browser; the server writes no file.
  • Your ID token. Every claim, however many and whatever their shape: a list one item per line, so a group named eng, ops stays one group; an object as indented JSON; an empty string and a null each labelled. A list or object longer than ten entries shows the first ten and a Show all button. Each claim names its source (the ID token, or UserInfo) and the field that reads it. Protocol claims nothing reads (iss, aud, nonce and the rest) are listed apart.
  • Candidates and suggestions. Claims that could hold the subject (sub, or Entra ID’s tenant-stable oid), the username (preferred_username, upn and the like), and groups (every list of strings), and the fields.extra block that would capture each claim nothing reads.
  • Not mappable. Claims no field can read: nested objects, since the mapper reads top-level claims only, and the claims of a JWT access token, decoded unverified. ssoossh reads only the ID token (and UserInfo), so a groups claim that appears only there means the provider has to be told to put it in the ID token.

Nothing about the result is stored. It reaches the page in the URL fragment, which browsers never send to a server, and the page clears the address bar as soon as it has read it. Reload and it is gone; download it to keep it. There is no shareable link, on purpose: a link would be the thing that turns a non-storing feature into a storing one.

Two things it deliberately does not do:

  • It only ever shows your own claims. That answers “what fields do I get from this provider”, which is the configuration question. It does not answer “why is this other person missing a group” – the user detail page and the directory probe are for that.
  • It shows the token and UserInfo, not the provider’s whole user record. A claim your provider only releases under a scope you have not requested will not appear; add the scope to authentication.scopes and echo again.

Each echo is a real authentication against your provider, so it appears in the provider’s own sign-in log like any other. The bootstrap form records auth.echo_code_minted, auth.echo_code_used and auth.echo_code_expired in the audit log, never with a claim value.

With authentication.userinfo on (the default), every login and every echo calls the provider’s UserInfo endpoint with the access token the code exchange returned, and fills in the claims the ID token lacks. Many providers keep the ID token lean and put profile, email or groups claims only there.

  • The ID token wins. A claim it carries is never replaced; UserInfo only adds the keys it lacks.
  • Same subject or no login. The UserInfo sub must equal the ID token’s exactly (OpenID Connect Core 5.3.2). A mismatch fails the login, and the echo page shows it.
  • No endpoint, no call. A provider whose discovery document names no userinfo_endpoint is simply not called.
  • A failed call is not a failed login. A network error, an error status or a body that is not JSON is logged, and the login carries on with the ID token’s claims. It fails only if the subject or username claim is then missing, as it would without UserInfo.
  • Pause and probe. After 10 failed calls in a row, counted across every instance through the database, the server pauses the calls: logins use the ID token alone, and one login every 15 minutes, one across the fleet, makes the call as a probe. A probe that succeeds resumes the calls and resets the count; one that fails keeps the pause and restarts the 15 minutes. Pausing and resuming are each audited (auth.userinfo_paused, auth.userinfo_resumed) and emailed to every admin, always; the diagnostics page shows the state.

Conditions in ssoosshd shape what an already-admitted identity receives. An identity that should not reach the server at all is expected never to be issued a token in the first place. Offboarding is the same statement: disable the person in the identity provider and they cannot get a new certificate, while the one they hold expires on its own.

The provider rejects the redirect URI. Almost always http.public_url. It must be the scheme and host browsers actually reach the deployment at, which behind a reverse proxy is the proxy’s public name and https, not the address and port ssoosshd binds. Compare the URI registered with the provider against <public_url>/auth/callback character for character; a trailing slash or an http where the browser uses https is enough to fail. public_url is origin-only, so a sub-path deployment is not supported and is rejected at startup rather than producing a redirect URI that silently does not work.

Discovery fails at startup. provider_url is wrong, or the server cannot reach the provider. Fetch <provider_url>/.well-known/openid-configuration from the server host.

Login succeeds but every group gate denies. The groups claim is not arriving. Check that the scope releasing it is in authentication.scopes, and that fields.groups names the claim your provider actually emits. Roles fail closed: no identity, no group, or no configured group all deny.

The provider rejects the authorization request over code_challenge. A few providers refuse PKCE parameters from a confidential client. Set authentication.disable_pkce to true and the login drops both the challenge and the code verifier; nothing else about the flow changes, and the state and ID token nonce checks are untouched. It is a last resort and the server warns at every startup while it is set: PKCE is what stops an authorization code observed in transit – in a proxy log, a browser history, a referrer header – from being redeemed by whoever found it. Prefer registering the client as one your provider will accept a challenge from.

Requests are rejected with 421 Misdirected Request. The request was addressed to a host name other than public_url’s. The health endpoints are exempt, so probes by IP still work.