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 :

AppRoleWhere it lives
TraefikThe gate. Every request to *.<your-domain> hits Traefik first. It asks the next app whether you’re allowed inpsw-apps/traefik/
AutheliaThe bouncer. Checks session cookies, runs the login page, and speaks OIDC for apps that can use it nativelypsw-apps/authelia/
LLDAPThe guest list. A lightweight LDAP server that stores your users, passwords, and groupspsw-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_typeWhat it meansExample 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 themGrafana , Prometheus , the operations dashboard , LLDAP ’s own UI
oauth2 / oidcThe app speaks OIDC natively. Authelia acts as an OIDC provider; the app redirects you to Authelia for login and gets a signed token backForgejo , Jellyfin (via its SSO plugin), Home Assistant
noneNo 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.

For a proxy-auth app (sso_type: proxy)#

  1. Browser: GET https://grafana.<domain>/
  2. 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)
  3. Authelia checks the session cookie stored in Dragonfly (the Redis-compatible cache). No cookie? → redirect to https://auth.<domain>
  4. You enter username + password at Authelia’s portal. Authelia checks them against LLDAP over LDAP
  5. Authelia sets a session cookie scoped to *.<domain>, redirects you back to Grafana
  6. Traefik re-runs forward-auth → Authelia now says yes + returns Remote-User: akadmin, Remote-Groups: lldap_admin
  7. Traefik adds those headers to the request, Grafana sees akadmin as 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)#

  1. Browser: GET https://forgejo.<domain>/
  2. Forgejo has an “Authelia” auth source (PSW registered it during deploy). You click “Sign in with Authelia”
  3. Forgejo redirects you to https://auth.<domain>/api/oidc/authorization?client_id=forgejo&...
  4. Authelia shows the login portal (or reuses an existing session), authenticates you against LLDAP
  5. Authelia redirects you back to Forgejo’s callback URL with an authorization code
  6. Forgejo exchanges the code for an ID token + access token (server-to-server)
  7. 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:

  1. The OIDC app declares an sso: block in its meta.yml — redirect path, scope profile, whether to allow non-https redirects
  2. 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
  3. That fragment lands in services/<app>/sso/<app>-sso.yml
  4. Authelia declares an aggregator for the sso convention. Its aggregator node in the execution plan rsyncs every *-sso.yml into services/authelia/oidc-clients/ and triggers a redeploy of Authelia so the new clients are live
  5. Meanwhile, the app’s own setup reconciler configures the other side — e.g. forgejo.authelia calls forgejo admin auth add-oauth inside the container with the matching client credentials; jellyfin.setup calls 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):

  1. Sign in as akadmin using the password from secrets/apps.yml
  2. Users → Create User → fill in username, email, display name, password
  3. (Optional) add them to the lldap_admin group 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 send client_secret_post; Vaultwarden sends client_secret_basic (per Authelia’s Vaultwarden integration guide ). Mismatch → the token exchange fails with unsupported authentication method.
  • require_pkce — turn on only for clients that actually send a PKCE challenge. Vaultwarden does (SSO_PKCE=true); Grafana’s generic_oauth does 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:

  1. The app’s meta.yml declares extra_scopes: [groups] under its sso: block, so Authelia’s OIDC token includes the groups claim alongside sub / email / etc.
  2. The app’s env points an OIDC role-claim setting at groups (Open WebUI’s variable is OAUTH_ROLES_CLAIM=groups; other apps may name it OIDC_GROUP_CLAIM or similar).
  3. The app’s env names which LLDAP groups grant which role. Open WebUI uses OAUTH_ADMIN_ROLES=lldap_admin to promote anyone in lldap_admin to admin; everyone else with an LLDAP account stays in the regular user role.
  4. 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 from lldap_admin in 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.OidcClient uses client_secret_basic for pushed authorization requests (PAR ) but client_secret_post for 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 default client_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-Proto header from proxies in Network.KnownProxies — our default KnownProxies is just loopback, so without help the plugin emits http://jellyfin.<domain>/... and Authelia rejects it as non-localhost http. PSW sets SchemeOverride: "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 (on BrandingOptions , via POST /System/Configuration/branding) per the plugin’s README — this is what turns the “Sign in with Authelia” button on. See jellyfin/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)
  • akadmin is your daily login — created automatically during bootstrap, shown on the wizard’s completion screen, password lives encrypted in secrets/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