Single Sign-On#
What Is It?#
Single Sign-On (SSO) means you log in once, and every app on your setup recognises you. No per-app account, no twenty-passwords-for-twenty-apps. You visit Jellyfin without being logged in, you’re redirected to a login page, you enter your password, you’re back on Jellyfin — and now Grafana , Forgejo , the operations dashboard , and everything else just let you in too.
Think of it like the wristband at a multi-venue festival. You queue once at the gate, they check your ID, and from then on every stage just scans the band.
The Three Pieces#
PSW’s SSO is three apps working together. You meet all three during bootstrap :
| App | Role | Where it lives |
|---|---|---|
| Traefik | The gate. Every request to *.<your-domain> hits Traefik first. It asks the next app whether you’re allowed in | psw-apps/traefik/ |
| Authelia | The bouncer. Checks session cookies, runs the login page, and speaks OIDC for apps that can use it natively | psw-apps/authelia/ |
| LLDAP | The guest list. A lightweight LDAP server that stores your users, passwords, and groups | psw-apps/lldap/ |
None of these are optional. All three are core apps deployed during bootstrap , wired together automatically, before a single user app is installed.
The Login You Actually Use#
PSW creates one admin user during bootstrap: akadmin. The password is auto-generated and stored encrypted at secrets/apps.yml → apps.lldap.lldap_default_password. When bootstrap finishes, the wizard’s Launch completion screen
shows both username and password — write them down, you’ll need them to log in to everything.
From then on, akadmin + that password is your login everywhere: Jellyfin, Forgejo, Grafana, the operations dashboard
, Authelia’s own portal at https://auth.<your-domain>. One wristband.
The reconciler that creates this user is in psw-apps/psw_apps/lldap/setup.py
. It’s idempotent — if akadmin already exists, it just makes sure they’re in the lldap_admin group and moves on.
How Apps Opt Into SSO#
Every app declares its SSO style in meta.yml
with the sso_type field. There are three values in active use:
sso_type | What it means | Example apps |
|---|---|---|
proxy (default) | The app doesn’t know about SSO. Traefik asks Authelia whether you’re logged in; if yes, Traefik quietly injects your username into the request headers (Remote-User, Remote-Groups, Remote-Email) and the app trusts them | Grafana , Prometheus , the operations dashboard , LLDAP ’s own UI |
oauth2 / oidc | The app speaks OIDC natively. Authelia acts as an OIDC provider; the app redirects you to Authelia for login and gets a signed token back | Forgejo , Jellyfin (via its SSO plugin), Home Assistant |
none | No SSO. The app is either public or handles its own auth. Only Authelia itself uses this (it’s the one checking everyone else, so it can’t check itself) | Authelia |
The sso_type: proxy default is deliberate: forward-auth works with any web app, zero integration required. OIDC is more work but gives the app real user identity (so e.g. Forgejo can show “commits by akadmin”).
PSW derives whether to inject Traefik’s forward-auth middleware from this field automatically — see effective_sso_forward_auth in psw-lib/src/psw_lib/models/app_metadata.py
. You never set it by hand.
What Happens When You Click a Link#
For a proxy-auth app (sso_type: proxy)#
- Browser:
GET https://grafana.<domain>/ - Traefik matches the host rule and fires its forward-auth middleware, which asks Authelia: “is this request allowed?” (internal HTTP call to
/api/authz/forward-auth) - Authelia checks the session cookie stored in Dragonfly
(the Redis-compatible cache). No cookie? → redirect to
https://auth.<domain> - You enter username + password at Authelia’s portal. Authelia checks them against LLDAP over LDAP
- Authelia sets a session cookie scoped to
*.<domain>, redirects you back to Grafana - Traefik re-runs forward-auth → Authelia now says yes + returns
Remote-User: akadmin,Remote-Groups: lldap_admin - Traefik adds those headers to the request, Grafana sees
akadminas the logged-in user
From now on, any other *.<domain> app you visit reuses the same session cookie — no login pages, you’re just in.
For an OIDC app (sso_type: oauth2)#
- Browser:
GET https://forgejo.<domain>/ - Forgejo has an “Authelia” auth source (PSW registered it during deploy). You click “Sign in with Authelia”
- Forgejo redirects you to
https://auth.<domain>/api/oidc/authorization?client_id=forgejo&... - Authelia shows the login portal (or reuses an existing session), authenticates you against LLDAP
- Authelia redirects you back to Forgejo’s callback URL with an authorization code
- Forgejo exchanges the code for an ID token + access token (server-to-server)
- Forgejo decodes the token, finds
sub: akadmin, links it to its own user record, logs you in
You see a standard “Login with Authelia” button, but the wristband is still one-and-done.
How the OIDC Client Gets Registered#
OIDC apps need a client_id and client_secret known to both themselves and Authelia. PSW handles this through the SSO convention with zero user configuration:
- The OIDC app declares an
sso:block in itsmeta.yml— redirect path, scope profile, whether to allow non-https redirects - At deploy time, PSW’s SSO convention renderer (
psw-lib/src/psw_lib/conventions/types/sso.py) generates a YAML fragment describing the Authelia client: id, PBKDF2-hashed secret, redirect URIs, scopes - That fragment lands in
services/<app>/sso/<app>-sso.yml - Authelia declares an aggregator
for the
ssoconvention. Its aggregator node in the execution plan rsyncs every*-sso.ymlintoservices/authelia/oidc-clients/and triggers a redeploy of Authelia so the new clients are live - Meanwhile, the app’s own setup reconciler
configures the other side — e.g.
forgejo.autheliacallsforgejo admin auth add-oauthinside the container with the matching client credentials;jellyfin.setupcalls Jellyfin’s REST API to install the SSO plugin and register Authelia
You added an app with sso_type: oauth2 and it just worked. No URIs pasted into a settings page, no client secrets written down twice. That’s the whole point.
Adding Users#
There’s no psw user add command. You create users through LLDAP’s web UI at https://lldap.<domain> (it’s itself protected by forward-auth, so you sign in there with akadmin first):
- Sign in as
akadminusing the password fromsecrets/apps.yml - Users → Create User → fill in username, email, display name, password
- (Optional) add them to the
lldap_admingroup if they should be admins on OIDC apps
New users can immediately log in to every proxy-auth app. For OIDC apps, most respect the lldap_admin group for admin privileges; non-admins are still regular logged-in users.
The sso: Block Details#
For OIDC apps, the sso: block in meta.yml (schema: SSOConvention in app_metadata.py
):
sso:
redirect_path: /user/oauth2/authelia/callback # where Authelia sends users back
allow_http_redirect: false # accept http:// callback too — only for localhost
scope_profile: standard # which OAuth2 scopes to request
token_endpoint_auth_method: client_secret_post # how the client sends its secret to Authelia's token endpoint
require_pkce: false # require PKCE — on for apps that send it (e.g. Vaultwarden)Two fields often need tuning per app because Authelia enforces strict per-client policies:
token_endpoint_auth_method— Authelia allows one method per client. Apps’ OIDC libraries vary: Forgejo, Grafana and Home Assistant sendclient_secret_post; Vaultwarden sendsclient_secret_basic(per Authelia’s Vaultwarden integration guide ). Mismatch → the token exchange fails withunsupported authentication method.require_pkce— turn on only for clients that actually send a PKCE challenge. Vaultwarden does (SSO_PKCE=true); Grafana’sgeneric_oauthdoes not.
Role mapping via LLDAP groups#
Some apps (currently Open WebUI , more to come) determine what role a user gets from their LLDAP group membership, refreshed on every login. PSW’s recipe for this — copy-pasteable across any future app with admin/user role splits:
- The app’s
meta.ymldeclaresextra_scopes: [groups]under itssso:block, so Authelia’s OIDC token includes thegroupsclaim alongsidesub/email/ etc. - The app’s env points an OIDC role-claim setting at
groups(Open WebUI’s variable isOAUTH_ROLES_CLAIM=groups; other apps may name itOIDC_GROUP_CLAIMor similar). - The app’s env names which LLDAP groups grant which role. Open WebUI uses
OAUTH_ADMIN_ROLES=lldap_adminto promote anyone inlldap_adminto admin; everyone else with an LLDAP account stays in the regularuserrole. - The app enables continuous role evaluation (Open WebUI:
ENABLE_OAUTH_ROLE_MANAGEMENT=true). On every login the app re-reads the live group claim, so removing someone fromlldap_adminin LLDAP demotes them on their next session — no first-user-wins, no group-membership drift.
No first-user-wins. Some apps (Open WebUI included) default to “the first OAuth login gets admin”. With group-based mapping that footgun goes away — admin status is determined by the LLDAP group claim from the very first login, and survives no longer than the LLDAP group membership.
Jellyfin’s gotchas#
Jellyfin ’s SSO plugin is the one OIDC integration that doesn’t fit the shared convention:
- The plugin’s underlying
Duende.IdentityModel.OidcClientusesclient_secret_basicfor pushed authorization requests (PAR ) butclient_secret_postfor the token exchange — a library-internal mismatch. Authelia only lets a client pick one method, so PSW disables PAR on the plugin side (DisablePushedAuthorization: true) and keeps the defaultclient_secret_post. The regular authorize-redirect flow + token POST then passes. - Jellyfin builds the OIDC redirect URI from the incoming request’s scheme. When it runs behind Traefik
, ASP.NET only trusts the forwarded
X-Forwarded-Protoheader from proxies inNetwork.KnownProxies— our default KnownProxies is just loopback, so without help the plugin emitshttp://jellyfin.<domain>/...and Authelia rejects it as non-localhosthttp. PSW setsSchemeOverride: "https"in the plugin config to force the scheme. - Jellyfin’s login page doesn’t render an SSO button on its own. PSW writes an HTML snippet into Jellyfin’s
LoginDisclaimer(onBrandingOptions, viaPOST /System/Configuration/branding) per the plugin’s README — this is what turns the “Sign in with Authelia” button on. Seejellyfin/setup.py.
All of this is handled by the Jellyfin setup reconciler + convention renderer — you don’t set any of it by hand.
Key Ideas#
- One password, all apps — log in once at Authelia, every
*.<domain>app recognises you - Three cooperating pieces — Traefik (gate), Authelia (bouncer), LLDAP (guest list)
- Two flavours per app — forward-auth (
proxy, works with anything) or native OIDC (oauth2/oidc, richer identity for apps that support it) akadminis your daily login — created automatically during bootstrap, shown on the wizard’s completion screen, password lives encrypted insecrets/apps.yml- Users live in LLDAP — add them through the LLDAP web UI at
https://lldap.<domain>; every app gets them instantly - OIDC clients wire themselves — the SSO convention + setup reconcilers register clients on both sides with no manual configuration