Per-app OIDC
How an application gets connected over OIDC. Applications without OIDC use a reverse-proxy forward_auth
call into the portal's session gate, or a service-local password for native clients.
The shape
One issuer, fixed once any relying party is wired (DEPLOY.md § Before the first enrolment), and authorization code for
every interactive application. One manifest
per application, read at runtime. A manifest carries up to three
namespaces: presentation: (tile metadata and the access binding), oidc:
(forwarded to Hydra), and consent: (role and claim shape, read by the portal
and never sent to Hydra). That shape produces one login, shown end to end:
Entry and role are different bindings
Two groups exist per service with different jobs. <svc>-access is entry: hold it and you get in, and that is
true of everyone, admins included. <svc>-admin is role only: it elevates
someone already admitted and admits nobody on its own. So "can this person get
in?" is answered by looking at exactly one group.
presentation:
access:
- audiobookshelf-access # ENTRY. Only the access group, ever.
consent:
group_role_map:
audiobookshelf-admin: admin # ROLE. Both groups appear here.
audiobookshelf-access: user
Group names are convention; ARCHITECTURE.md § The access model is the rule.
Adding an app
Drop <stem>.yaml, and optionally <stem>.md (guide) and <stem>.svg (logo, at most 32 KiB, shown only to
people who can see the tile), into apps/ under /etc/othala and SIGHUP the portal.
Wiring a new relying party
Generate a client secret, store it, and write the manifest with
presentation.access naming only the access group and consent.group_role_map
naming both. The app's boot reconcile (and SIGHUP) registers the client through
the Hydra admin port. It creates and updates but never deletes: retiring a
service means deleting the Hydra client through the admin API as well as the
manifest. Drop offline_access from the scopes where the app needs no refresh
tokens.
The secret must be the same plaintext on both sides; nothing links them, and a
mismatch surfaces as invalid_client at the token exchange. Last, grant the
access group, which is admin-API-only with no self-service path. An admin who
will also administer the application gets <svc>-admin in addition, never instead.
Two worked examples
Both live in e2e/content/apps/, exercised by CI, so neither can drift silently.
audiobookshelf.yaml, the minimal relying party. A groups
claim and a two-entry group_role_map; no JMESPath, no nested claim shapes.
Every field carries its constraint and enforcer as a comment. Start here.
grafana.yaml, role mapping under an application-specific claim contract.
A JMESPath role attribute and three declared roles, one reachable only by
per-client override. Grafana also shows a common quirk: its role expression is
evaluated application-side, and a separate flag must be enabled or the
server-admin role silently resolves to org-admin only.
When it is not OIDC
Forward-auth. Applications with no usable OIDC client sit behind a reverse proxy calling
portal/src/pages/api/forward-auth.ts with a required group as a query parameter. Only 401 redirects to
login; ARCHITECTURE.md § The status contract has the rest.
Service-local passwords. Some native clients cannot run the OAuth browser redirect, so they use a credential in the application's own user database, set by an admin there and never shared with the Kratos password. This is the supported pattern for those clients.
Troubleshooting
Hydra's log explains generic OAuth failures. Two are specific to this model:
- Denied when they should be admitted: check the entry group, not
the admin group. Entry comes from
<svc>-accessand nothing else. - The role is wrong but login works: that is
group_role_mapand claim shape, not the gate. Check what the application reads, and how often.
Browser clients on another origin are unsupported: Hydra's CORS allowlist is OTHALA_PUBLIC_URL only.
The upgrade steps are in DEPLOY.md.