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.
Which providers work
Section titled “Which providers work”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.
What the provider needs from you
Section titled “What the provider needs from you”| 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.
What you need from the provider
Section titled “What you need from the provider”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.
Worked example: pocket-id
Section titled “Worked example: pocket-id”- Run pocket-id and complete its first-run admin setup.
- In its admin UI, create an OIDC client for ssoossh. Set the callback URL to
<http.public_url>/auth/callback– for a deployment athttps://ssh.example.com, that ishttps://ssh.example.com/auth/callback. Note the generated client ID and secret. - Create or import the people who will log in, and any groups the
configuration gates on – an
SSH Sudoersgroup forcert_options.pam.require.group, say, or the admin groups from Roles and containment. - Put
provider_url,client_id, andclient_secretintossoosshd.yaml. Leaveauthentication.fields.usernameat its default and setauthentication.fields.groupsif group-gated certificate types are in use. - Restart
ssoosshd, then confirm from the server itself thatGET <provider_url>/.well-known/openid-configurationresolves. That is the first thing a typo’dprovider_urlbreaks.
Mapping claims to fields
Section titled “Mapping claims to fields”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_confidenceEach 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.
Choosing the account identifier
Section titled “Choosing the account identifier”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
suband puts the tenant-stable identifier inoid. Registering a second application client for the same people would give them all newsubvalues, 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.
Seeing what your provider actually sends
Section titled “Seeing what your provider actually sends”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.
At first setup: ssoosshd identity echo
Section titled “At first setup: ssoosshd identity echo”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:
sudo -u ssoossh ssoosshd identity echoIt 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.
Once admin works: Admin > Claims echo
Section titled “Once admin works: Admin > Claims echo”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.
What the result shows
Section titled “What the result shows”- 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.fieldsentry, 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_groupfilled 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, opsstays 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,nonceand the rest) are listed apart. - Candidates and suggestions. Claims that could hold the subject (
sub, or Entra ID’s tenant-stableoid), the username (preferred_username,upnand the like), and groups (every list of strings), and thefields.extrablock 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
groupsclaim 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.scopesand 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.
Claims from UserInfo
Section titled “Claims from UserInfo”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
submust 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_endpointis 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.
Total denial belongs to the provider
Section titled “Total denial belongs to the provider”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.
Troubleshooting
Section titled “Troubleshooting”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.