P4-05: OIDC login (generic, JIT-provisioned) #16

Merged
steve merged 19 commits from p4-05-oidc into main 2026-05-05 14:46:23 +01:00
Owner

Summary

  • New OIDC login flow (Authorization Code + PKCE/S256) against any IdP advertising standard discovery; YAML+env config (oidc.issuer, client_id, client_secret[_file], role_claim default groups, role_mapping, display_name, redirect_url). Empty issuer → routes not mounted.
  • Migration 0019: users.auth_source/oidc_subject (partial unique index), sessions.id_token, oidc_state (state+verifier round-trip, 5-min TTL, swept on alert tick).
  • JIT-provision on first OIDC sign-in (auth_source='oidc', blank password); returning users matched by stable sub, role + email refreshed every login. No role match → deny banner, no row created, audit user.oidc_login_blocked. Username collision with a local user → same deny path.
  • Local password login refused for OIDC users. Logout posts to /logout and, when the IdP advertised end_session_endpoint, follows up with RP-initiated logout (id_token_hint + post_logout_redirect_uri); when not advertised it gracefully degrades to clearing the local session.
  • UI: SSO button + error banner on /login; oidc chip in the users list; edit page disables username/email/role for OIDC users with a server-side guard backing the UI.
  • OIDC client also fetches /userinfo and folds claims in (Authelia and many other IdPs only put sub on the ID token); id_token claims remain authoritative on conflict.

Test plan

  • go vet ./... + go test ./... green (oidc + http suites cover the four callback branches via oidctest stub IdP)
  • Live Authelia sweep against https://auth.dcglab.co.uk (screenshots in _diag/p4-05-sweep/):
    • rm-admin → admin role JIT-provisioned, oidc chip on users list, edit fields disabled
    • rm-operator → operator JIT, 403 on /settings/users
    • rm-viewer → viewer JIT, 403 on /hosts/new
    • rm-other (group not in role_mapping) → no_role_match banner, no row created, audit logged
    • Returning rm-admin sign-in resolves to the same row by sub (no duplicate)
  • Logout clears local session; falls back cleanly when IdP doesn't advertise end_session
## Summary - New OIDC login flow (Authorization Code + PKCE/S256) against any IdP advertising standard discovery; YAML+env config (`oidc.issuer`, `client_id`, `client_secret[_file]`, `role_claim` default `groups`, `role_mapping`, `display_name`, `redirect_url`). Empty issuer → routes not mounted. - Migration 0019: `users.auth_source`/`oidc_subject` (partial unique index), `sessions.id_token`, `oidc_state` (state+verifier round-trip, 5-min TTL, swept on alert tick). - JIT-provision on first OIDC sign-in (`auth_source='oidc'`, blank password); returning users matched by stable `sub`, role + email refreshed every login. No role match → deny banner, no row created, audit `user.oidc_login_blocked`. Username collision with a local user → same deny path. - Local password login refused for OIDC users. Logout posts to `/logout` and, when the IdP advertised `end_session_endpoint`, follows up with RP-initiated logout (`id_token_hint` + `post_logout_redirect_uri`); when not advertised it gracefully degrades to clearing the local session. - UI: SSO button + error banner on `/login`; **oidc** chip in the users list; edit page disables username/email/role for OIDC users with a server-side guard backing the UI. - OIDC client also fetches `/userinfo` and folds claims in (Authelia and many other IdPs only put `sub` on the ID token); id_token claims remain authoritative on conflict. ## Test plan - [x] `go vet ./...` + `go test ./...` green (oidc + http suites cover the four callback branches via `oidctest` stub IdP) - [x] Live Authelia sweep against `https://auth.dcglab.co.uk` (screenshots in `_diag/p4-05-sweep/`): - rm-admin → admin role JIT-provisioned, **oidc** chip on users list, edit fields disabled - rm-operator → operator JIT, 403 on `/settings/users` - rm-viewer → viewer JIT, 403 on `/hosts/new` - rm-other (group not in role_mapping) → no_role_match banner, no row created, audit logged - Returning rm-admin sign-in resolves to the same row by `sub` (no duplicate) - [x] Logout clears local session; falls back cleanly when IdP doesn't advertise end_session
steve added 19 commits 2026-05-05 14:44:21 +01:00
Brainstormed shape locked: JIT-provision local rows on first OIDC
sign-in (auth_source='oidc'), YAML-only config (no UI), 'roles'
claim with deny-on-no-match default, preferred_username with email
fallback, refuse on local-user collision, single provider, login
page shows SSO above password (break-glass), front-channel logout
only, role re-evaluation at login only.

Migration 0019: users.auth_source + users.oidc_subject (partial
unique index), sessions.id_token (for end_session id_token_hint),
oidc_state table for the OAuth round-trip state, swept on the
existing alert-engine tick.

Composes with the user-management work from P4-03/04: admin can
disable OIDC users like local; last-admin guard catches IdP role-
mapping mistakes; audit trail covers JIT-provision via
user.created with auth_source payload + new user.oidc_login /
user.oidc_login_blocked actions.

Out of scope (deferred): back-channel logout, multi-provider,
UI-driven role mapping, refresh tokens / mid-session re-eval.
Confirmed claim name from the lab IdP is 'groups' (not 'roles' as
the original spec assumed). Default the role_claim config field to
'groups' which also matches Keycloak and Authentik out of the box.
Add a 'display_name' field so the SSO button can read 'Sign in with
Authelia' rather than the generic 'SSO'.

Two new gotchas captured:
  - Authelia 4.39+ 'sub' is an opaque UUID, not username — the
    locked design already keys on sub + reads preferred_username
    for display, so this is just documentation.
  - end_session_endpoint isn't always published (Authelia config-
    dependent); the locked logout flow already degrades cleanly.
Bite-sized TDD tasks across 7 slices (A schema, B config, C OIDC
client core + stub IdP, D login + callback, E logout + local-login
rejection, F UI, G wiring + Authelia sweep). Each task is one
commit with concrete code blocks and test cases — no placeholders.

Refs spec at docs/superpowers/specs/2026-05-05-p4-05-oidc-design.md.
Authelia bundle for the sweep stashed at /tmp/rm-smoke/oidc.env.
oidc: merge userinfo claims; tick P4-05 in tasks.md
CI / Test (rest) (pull_request) Successful in 40s
CI / Test (store) (pull_request) Successful in 37s
CI / Build (windows/amd64) (pull_request) Successful in 23s
CI / Test (server-http) (pull_request) Successful in 1m10s
CI / Build (linux/amd64) (pull_request) Successful in 24s
CI / Build (linux/arm64) (pull_request) Successful in 22s
CI / Lint (pull_request) Successful in 58s
4d90f72575
Authelia (and many other IdPs) only put `sub` in the ID token by
default, surfacing `preferred_username`/`email`/`groups` from the
userinfo endpoint. Fetch userinfo after id_token verification and
fold its claims into the parsed claim map; the id_token claims
remain authoritative on conflict so the signed assertion still
wins.

Live sweep against https://auth.dcglab.co.uk verified all four
flows: rm-admin → admin JIT, rm-operator → operator JIT (RBAC
denies admin pages), rm-viewer → viewer JIT (RBAC denies operator
pages), rm-other → no_role_match banner with no row created.
Returning rm-admin sign-in resolves to the same row by sub.
Screenshots in _diag/p4-05-sweep/.
steve merged commit 5ee58979fa into main 2026-05-05 14:46:23 +01:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/restic-manager#16