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
32 changed files with 4326 additions and 44 deletions
+13
View File
@@ -19,6 +19,7 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
rmhttp "gitea.dcglab.co.uk/steve/restic-manager/internal/server/http" rmhttp "gitea.dcglab.co.uk/steve/restic-manager/internal/server/http"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/maintenance" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/maintenance"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
@@ -92,6 +93,17 @@ func run() error {
return fmt.Errorf("ui: %w", err) return fmt.Errorf("ui: %w", err)
} }
var oidcClient *oidc.Client
if cfg.OIDC != nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
oidcClient, err = oidc.New(ctx, cfg.OIDC, cfg.BaseURL)
if err != nil {
return fmt.Errorf("oidc: %w", err)
}
slog.Info("oidc enabled", "issuer", cfg.OIDC.Issuer, "display", cfg.OIDC.DisplayName)
}
deps := rmhttp.Deps{ deps := rmhttp.Deps{
Cfg: cfg, Cfg: cfg,
Store: st, Store: st,
@@ -102,6 +114,7 @@ func run() error {
NotificationHub: notifHub, NotificationHub: notifHub,
UI: renderer, UI: renderer,
Version: version, Version: version,
OIDC: oidcClient,
} }
// First-run bootstrap: if the users table is empty, mint a one-time // First-run bootstrap: if the users table is empty, mint a one-time
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
# P4-05 — OIDC Login Design
> **Date:** 2026-05-05
> **Status:** brainstorm complete; ready for plan
> **Closes:** P4-05 (OIDC login)
## Goal
Wire OpenID Connect authentication as a sign-in path alongside the existing local-user system, so a deployment that already has an IdP (Authelia, Authentik, Keycloak, Okta, Auth0, etc.) can use it for restic-manager logins.
## Architecture
OIDC sits on top of the local-user system rather than replacing it. The first time a user signs in via OIDC the server **just-in-time provisions** a local user row marked `auth_source='oidc'`, with role derived from the IdP's `roles` claim. Subsequent sign-ins look up the same row by stable `oidc_subject` and refresh role + email from the latest claims. Once the row exists it behaves like any other local user — admin can disable it, force-logout, see it in audit logs, etc. — except password-login is rejected because there's no password.
The Authorization Code flow (with PKCE) is implemented against the discovered well-known config of a single configured issuer. Front-channel logout: clicking Sign out drops the local session + redirects the browser to the IdP's `end_session_endpoint` (when advertised). Back-channel logout deferred.
## Locked decisions
| Decision | Pick |
|---|---|
| User lifecycle | **B** — JIT-provision local rows on first OIDC login (`auth_source='oidc'`, `oidc_subject`) |
| Role mapping config | **A** — YAML/env, claim name configurable (default `groups`, matching Authelia / Keycloak / Authentik), default = deny on no-match |
| Username source | `preferred_username`, fallback to `email` |
| Username collision with existing local user | **Refuse** with clear remediation message |
| Provider config | **Single provider**`providers:` array can come later |
| Login page layout | SSO button **above** password form; password form labelled "or sign in with a local account" |
| OIDC users + password login | **Disabled**`auth_source='oidc'` rows have empty `password_hash`; password form rejects them |
| Logout shape | **Front-channel only** — drop session + redirect to `end_session_endpoint` when advertised |
| Role re-evaluation | **At login only** — claims read at the OIDC callback; admin can disable mid-session locally |
## Schema changes
Migration 0019 — `users` extensions for OIDC bookkeeping:
```sql
ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'local'
CHECK (auth_source IN ('local', 'oidc'));
ALTER TABLE users ADD COLUMN oidc_subject TEXT;
CREATE UNIQUE INDEX users_oidc_subject ON users(oidc_subject)
WHERE oidc_subject IS NOT NULL;
```
Both column-level ALTERs (CLAUDE.md preference). The unique partial index defends the JIT-lookup invariant (one row per IdP subject) without blocking multiple rows with NULL oidc_subject (the local users).
## Configuration
```yaml
# server config — extend existing config struct
oidc:
issuer: https://auth.example.com # well-known config discovered from this
client_id: restic-manager
client_secret: ${RM_OIDC_CLIENT_SECRET} # or via _FILE
display_name: Authelia # button label "Sign in with <display_name>"; default "SSO"
scopes: [openid, profile, email, groups]
role_claim: groups # default if absent (matches Authelia / Keycloak / Authentik)
role_mapping:
rm-admins: admin
rm-operators: operator
rm-viewers: viewer
# Optional — auto-derived from BaseURL if absent.
redirect_url: https://rm.example.com/auth/oidc/callback
```
Env-var overrides: `RM_OIDC_ISSUER`, `RM_OIDC_CLIENT_ID`, `RM_OIDC_CLIENT_SECRET`, `RM_OIDC_CLIENT_SECRET_FILE`. Mapping is YAML-only (env doesn't fit a multi-key string→string map cleanly).
When `oidc.issuer` is empty or missing, OIDC is disabled (current behaviour). No restart-toggle UI; this is a deploy-time setting.
## Auth flow
### Login start
`GET /auth/oidc/login` — only mounted when OIDC is configured.
1. Generate `state` (32 random bytes, base64) and `code_verifier` (64 random bytes, base64); compute `code_challenge = base64(sha256(code_verifier))`.
2. Store `(state, code_verifier, created_at)` in a new ephemeral table (or in memory with a 5-minute TTL — see "trade-off" below).
3. Redirect to `<authorization_endpoint>?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256`.
### Callback
`GET /auth/oidc/callback?code=...&state=...` — also OIDC-only mount.
1. Validate `state` against the stored value (one-shot — delete row on read). Reject if missing/expired/already used.
2. Exchange `code` + `code_verifier` for tokens at `token_endpoint`.
3. Validate the `id_token` JWT: signature against the JWKS endpoint, `iss`, `aud`, `exp`, `iat`, `nonce` (if used).
4. Extract `sub`, `preferred_username`, `email`, and the configured `role_claim` (default `roles`).
5. Pick username: `preferred_username` if non-empty, else `email`. Lowercase / trim per the existing local-user rules.
6. Pick role: first match in `role_mapping` against the array of role-claim values. **No match → deny with a clear error page**, no row created.
7. Look up user by `oidc_subject`. Three cases:
- **Found** — refresh `email`, `role`, `last_login_at`. Don't touch `username` (changing it would break audit trails; if the IdP changes the username, that's an operator concern). Log `user.oidc_login`.
- **Not found, username free** — INSERT row with `auth_source='oidc'`, `oidc_subject=<sub>`, `password_hash=''`, `must_change_password=0`. Log `user.created` with payload `{"auth_source":"oidc"}` + `user.oidc_login`.
- **Not found, username taken by a local user** — render an error page: "This OIDC user (`<sub>`) wants to sign in as `alice`, but a local user with that name already exists. Ask your administrator to either rename / remove the local user, or exclude this user from the OIDC mapping." 403, no row created. Log `user.oidc_login_blocked`.
8. Drop a session cookie + `MarkUserLogin` (the existing helper).
9. Redirect to `/`.
### Logout
`POST /logout` (existing handler) — augmented:
1. Look up the session before deletion (we need the user row to know if they're an OIDC user).
2. Delete the session as today.
3. If the user is `auth_source='oidc'` AND the discovered `end_session_endpoint` is non-empty → 303 to `<end_session_endpoint>?id_token_hint=<id_token>&post_logout_redirect_uri=<base>/login`. Otherwise → existing 303 to `/login`.
We need to keep the latest `id_token` per session to drive `id_token_hint`. Stash it in a new `sessions.id_token TEXT` column (one column-level ALTER on migration 0019 alongside the user columns), populated only for OIDC sessions.
## State table
Two reasonable shapes for the short-lived state used during the OAuth round-trip:
- **In-memory map** with a 5-minute TTL sweeper. Simpler, but multi-process deployments lose it (no multi-process today, but Phase 5 OSS readiness might add).
- **`oidc_state` table** — `(state_hash PK, code_verifier, created_at)`, swept on the same 60s alert-engine tick that already handles setup-token cleanup.
I'll go with the **table**. Costs ~3 lines in the existing cleanup tick, behaves correctly under restarts, and survives a future scale-out. Migration 0019 includes:
```sql
CREATE TABLE oidc_state (
state_hash TEXT PRIMARY KEY, -- sha256(state) hex; raw state never persisted
code_verifier TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX oidc_state_created ON oidc_state(created_at);
```
## Login-page UI
`/login` template branches based on `view.OIDCEnabled`:
- **OIDC off** → current layout (just the password form).
- **OIDC on** → an `Sign in with <provider name>` button at the top, then a faint divider line, then the existing password form labelled "Or sign in with a local account". Provider name comes from a new optional config `oidc.display_name` (defaults to "SSO").
Failed-OIDC redirects (no role match, username collision, IdP error) land on `/login?oidc_error=<reason>` with a small banner above the buttons.
## Audit actions
New entries in the action vocabulary:
- `user.oidc_login` (target_kind=user, target_id=user_id, payload `{"sub":"…"}`)
- `user.oidc_login_blocked` (target_kind=user, target_id=oidc_subject when no row was created, payload `{"username":"…", "reason":"username_taken|no_role_match|other"}`)
- `user.created` already exists; OIDC's first-time provisioning fires this with payload `{"auth_source":"oidc"}` so the audit log distinguishes admin-created from JIT-provisioned rows.
## User-management UI changes
Small additions, not new screens:
- **Users list** — Status column adds a small `oidc` chip when `auth_source='oidc'` so admin can see at a glance which rows came from JIT-provisioning. Sortable by auth_source via the same sortable-headers pattern (lands as a small follow-up if anyone asks; out of scope for v1).
- **Add user form** — disabled when OIDC is the only auth path, with a hint: "User provisioning is handled by your OIDC provider; users appear here on first sign-in." Configurable later via a `oidc.disable_local_users` flag if that becomes a real ask. Out of scope for v1; both paths stay open.
- **Edit user form** — when `auth_source='oidc'`:
- Username field disabled (changing it would just be undone on next OIDC login)
- Role dropdown disabled, with a hint: "Role is managed by your OIDC provider's `roles` claim mapping. Edit the mapping in server config to change."
- Email field disabled (refreshed from IdP on each login)
- **Disable / Enable / Force logout** still work — disabling an OIDC user kicks their session and rejects future OIDC logins ("user disabled by administrator")
- **Regenerate setup link** hidden — there's no setup token for OIDC users
- **Login UI** — password form rejects users with `auth_source='oidc'` ("This account uses single sign-on. Click the SSO button above.")
## Middleware / handler changes
- **Routes**: new public-band entries `GET /auth/oidc/login`, `GET /auth/oidc/callback`. Skipped entirely when OIDC isn't configured (`s.deps.OIDC == nil`).
- **Logout handler** augmented to fetch the user row + decide between local logout (303 → `/login`) and OIDC logout (303 → `end_session_endpoint`).
- **Login handler** rejects `auth_source='oidc'` users with the SSO-prompt error.
- **Last-admin guard** — already covers OIDC users naturally because they live in the `users` table. The role-from-claims path could create a "every admin gets demoted to operator" situation if the IdP's claim mapping is wrong; the guard rejects that demotion at the moment it'd be applied (returns the user to the login page with `oidc_error=role_change_blocked` and audit entry; admin must fix the mapping or promote a local admin first).
## Implementation outline
1. **Schema** — migration 0019 (users.auth_source + oidc_subject, sessions.id_token, oidc_state table)
2. **Config** — extend `internal/server/config` with the OIDC block + env-var overrides; load JWKS lazily
3. **Discovery + JWKS** — small helper that fetches `<issuer>/.well-known/openid-configuration` once at startup, caches `authorization_endpoint`, `token_endpoint`, `end_session_endpoint`, `jwks_uri`. JWKS refreshed on first failed verification.
4. **Login start handler**`/auth/oidc/login`
5. **Callback handler**`/auth/oidc/callback`, with the four claim-resolution branches
6. **Logout handler augmentation** — branch on `auth_source`
7. **Login form rejection** — local-user password form rejects OIDC accounts
8. **State cleanup** — extend the alert engine's existing cleanup tick
9. **UI**`oidc` chip on users list, disabled fields on edit-form for OIDC users, login page SSO button + error banner
10. **Tests** — config parse tests; happy-path callback test using a fake IdP (httptest server with a hand-rolled discovery doc + JWKS); username-collision test; no-role-match test; logout test
11. **Sweep** — full Playwright walk against an actual IdP (Authelia in a Docker container) — admin gets in via OIDC, role mapping works, logout redirects through IdP, OIDC user can't password-login
## Test strategy
The IdP is the hard part to test cleanly. Two layers:
- **Unit / integration tests** use a stub OIDC provider built into the test harness — `httptest.Server` exposing `.well-known/openid-configuration`, a token endpoint that signs minted JWTs with a test ECDSA key, and a JWKS endpoint serving the public key. This covers every code path without a real IdP. Pattern: each test mints its own claims and runs the callback against the stub.
- **Smoke env** runs against a real Authelia container (existing `compose.smoke.yaml`-style file or one-liner `docker run`) for the final sweep — confirms the discovery doc isn't being misread, real JWT verification works, real `end_session_endpoint` redirect works.
## Out of scope (deferred)
- **Multi-provider** support (`providers:` array)
- **Back-channel logout** (RFC 8138) — schema isn't blocked from adding it later
- **UI-driven role mapping** (config-only in v1)
- **Refresh tokens / mid-session role re-evaluation** — login-only refresh in v1
- **`oidc.disable_local_users`** flag — both paths stay open in v1
- **OIDC user dashboard chip / badges** beyond the small `oidc` indicator on the users list
- **Per-user "auth source" filter on the users list** — sortable headers cover most of the use case
## Risks / gotchas
- **JWKS key rotation** — refresh on first failed verification is the standard fix; document the cache TTL (1h) in the config block.
- **Clock skew** — accept `iat`/`exp` with a 60s leeway; matches what most OIDC libraries do.
- **End-session 404 / not advertised** — degrade gracefully; just drop the session and 303 to `/login`. Don't 500 the logout because the IdP doesn't implement RP-initiated logout.
- **Username changes at the IdP** — silently keep the local username (matches our locked decision: subject is the stable key, username is display-only). Document.
- **Role claim is sometimes a string, sometimes an array, sometimes a comma-separated string** depending on IdP — normalise into `[]string` before mapping. Authelia/Keycloak emit arrays; some custom setups emit strings; handle both.
- **Authelia `sub` is an opaque UUID, not the username** (Authelia 4.39+ default for new clients). Don't assume `sub` is human-readable; it's stable but display value is `preferred_username` or `email`. The locked design already keys lookups on `sub` and uses `preferred_username` for the display username, so this is just a correctness note.
- **`end_session_endpoint` may not be published** (Authelia doesn't advertise it for many configs). The locked logout flow already degrades to "drop session + redirect to /login" when the discovery doc lacks it; no extra config needed.
- **Password-form bypass for OIDC users via /api/auth/login (JSON)** — same rejection rule applies, not just the HTML form.
## Acceptance
- [ ] An OIDC user with `roles: ["rm-admins"]` can sign in, becomes an admin, is visible in `/settings/users` with an `oidc` chip
- [ ] Same user signing in again resolves to the same row (no duplicate)
- [ ] Same user with `roles: ["something-else"]` is denied, lands on `/login?oidc_error=no_role_match` with a banner, no row created
- [ ] OIDC user can't password-login through `/login` or `/api/auth/login`
- [ ] Admin disables an OIDC user → next OIDC login is rejected, existing session bounced (existing disable-mid-session)
- [ ] Sign out as an OIDC user → 303 to IdP's end-session URL (when advertised); no end-session URL → 303 to `/login`
- [ ] OIDC config absent → password login works exactly as today (zero behavioural change)
- [ ] Username collision: a local `alice` exists, OIDC user with `preferred_username=alice` and a different `sub` → blocked at sign-in with the clear error page
- [ ] Last-admin guard refuses to demote the only enabled admin even if the IdP's role mapping says otherwise
- [ ] All existing tests pass; new test suite covers the four claim-resolution branches and logout
+7 -3
View File
@@ -3,22 +3,26 @@ module gitea.dcglab.co.uk/steve/restic-manager
go 1.25.0 go 1.25.0
require ( require (
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.18.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.43.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.0 modernc.org/sqlite v1.50.0
) )
require ( require (
github.com/coder/websocket v1.8.14 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
golang.org/x/sys v0.43.0 // indirect
modernc.org/libc v1.72.0 // indirect modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+8
View File
@@ -1,9 +1,15 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -25,6 +31,8 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+3
View File
@@ -193,6 +193,9 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
if _, err := e.store.CleanupExpiredSetupTokens(ctx, now); err != nil { if _, err := e.store.CleanupExpiredSetupTokens(ctx, now); err != nil {
slog.Warn("alert: cleanup expired setup tokens", "err", err) slog.Warn("alert: cleanup expired setup tokens", "err", err)
} }
if _, err := e.store.CleanupExpiredOIDCState(ctx, now.Add(-5*time.Minute)); err != nil {
slog.Warn("alert: cleanup expired oidc state", "err", err)
}
hosts, err := e.store.ListHosts(ctx) hosts, err := e.store.ListHosts(ctx)
if err != nil { if err != nil {
+13 -1
View File
@@ -30,7 +30,9 @@ type Config struct {
// Defaults to true. Set RM_COOKIE_SECURE=false only for local HTTP // Defaults to true. Set RM_COOKIE_SECURE=false only for local HTTP
// testing — production deployments are always behind a TLS proxy // testing — production deployments are always behind a TLS proxy
// and the cookie must be Secure. // and the cookie must be Secure.
CookieSecure bool `yaml:"cookie_secure"` CookieSecure bool `yaml:"cookie_secure"`
OIDCRaw *OIDCConfig `yaml:"oidc"`
OIDC *OIDCConfig `yaml:"-"`
} }
// Load resolves config in this order: // Load resolves config in this order:
@@ -91,6 +93,16 @@ func Load(yamlPath string) (Config, error) {
} }
} }
var rawOIDC OIDCConfig
if c.OIDCRaw != nil {
rawOIDC = *c.OIDCRaw
}
oidc, err := loadOIDC(envSnapshot(), rawOIDC)
if err != nil {
return c, err
}
c.OIDC = oidc
return c, c.validate() return c, c.validate()
} }
+103
View File
@@ -0,0 +1,103 @@
// internal/server/config/oidc.go — OIDC subsection of the server
// config. Disabled when oidc.issuer is empty or absent.
package config
import (
"errors"
"fmt"
"os"
)
// OIDCConfig is the OIDC sub-block. The struct doubles as YAML schema;
// loadOIDC applies env overlays on top and fills defaults.
type OIDCConfig struct {
Issuer string `yaml:"issuer"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
DisplayName string `yaml:"display_name"`
Scopes []string `yaml:"scopes"`
RoleClaim string `yaml:"role_claim"`
RoleMapping map[string]string `yaml:"role_mapping"`
RedirectURL string `yaml:"redirect_url"`
}
// loadOIDC merges YAML + env, applies defaults, validates. Returns
// nil + nil when OIDC is disabled (issuer empty after merge); a
// non-nil OIDCConfig means the caller should wire OIDC.
//
// Env vars (override YAML when set):
//
// RM_OIDC_ISSUER, RM_OIDC_CLIENT_ID, RM_OIDC_CLIENT_SECRET,
// RM_OIDC_CLIENT_SECRET_FILE, RM_OIDC_DISPLAY_NAME,
// RM_OIDC_REDIRECT_URL.
//
// envs is passed in (rather than read with os.LookupEnv) so unit
// tests can supply a fake env map.
func loadOIDC(envs map[string]string, yaml OIDCConfig) (*OIDCConfig, error) {
c := yaml
if v, ok := envs["RM_OIDC_ISSUER"]; ok {
c.Issuer = v
}
if v, ok := envs["RM_OIDC_CLIENT_ID"]; ok {
c.ClientID = v
}
if v, ok := envs["RM_OIDC_CLIENT_SECRET"]; ok {
c.ClientSecret = v
}
if v, ok := envs["RM_OIDC_CLIENT_SECRET_FILE"]; ok && v != "" {
body, err := os.ReadFile(v)
if err != nil {
return nil, fmt.Errorf("config: oidc client_secret_file: %w", err)
}
c.ClientSecret = string(body)
}
if v, ok := envs["RM_OIDC_DISPLAY_NAME"]; ok {
c.DisplayName = v
}
if v, ok := envs["RM_OIDC_REDIRECT_URL"]; ok {
c.RedirectURL = v
}
if c.Issuer == "" {
return nil, nil
}
if c.ClientID == "" {
return nil, errors.New("config: oidc.client_id required when issuer is set")
}
if c.ClientSecret == "" {
return nil, errors.New("config: oidc.client_secret required when issuer is set")
}
if len(c.RoleMapping) == 0 {
return nil, errors.New("config: oidc.role_mapping must have at least one entry")
}
if c.DisplayName == "" {
c.DisplayName = "SSO"
}
if c.RoleClaim == "" {
c.RoleClaim = "groups"
}
if len(c.Scopes) == 0 {
c.Scopes = []string{"openid", "profile", "email", "groups"}
}
return &c, nil
}
// envSnapshot reads the OIDC env vars into a map. Lets the production
// loadOIDC call site stay env-driven while tests pass an explicit
// map.
func envSnapshot() map[string]string {
keys := []string{
"RM_OIDC_ISSUER", "RM_OIDC_CLIENT_ID", "RM_OIDC_CLIENT_SECRET",
"RM_OIDC_CLIENT_SECRET_FILE", "RM_OIDC_DISPLAY_NAME",
"RM_OIDC_REDIRECT_URL",
}
out := make(map[string]string, len(keys))
for _, k := range keys {
if v, ok := os.LookupEnv(k); ok {
out[k] = v
}
}
return out
}
+72
View File
@@ -0,0 +1,72 @@
package config
import "testing"
func TestOIDCParseDisabledWhenIssuerEmpty(t *testing.T) {
t.Parallel()
c, err := loadOIDC(map[string]string{}, OIDCConfig{})
if err != nil {
t.Fatalf("load: %v", err)
}
if c != nil {
t.Errorf("expected nil OIDC config when issuer empty; got %+v", c)
}
}
func TestOIDCRejectMissingClientID(t *testing.T) {
t.Parallel()
yaml := OIDCConfig{Issuer: "https://x", ClientSecret: "s"}
if _, err := loadOIDC(map[string]string{}, yaml); err == nil {
t.Error("expected error for missing client_id")
}
}
func TestOIDCRejectMissingClientSecret(t *testing.T) {
t.Parallel()
yaml := OIDCConfig{Issuer: "https://x", ClientID: "rm"}
if _, err := loadOIDC(map[string]string{}, yaml); err == nil {
t.Error("expected error for missing client_secret")
}
}
func TestOIDCDefaultsApplied(t *testing.T) {
t.Parallel()
yaml := OIDCConfig{
Issuer: "https://x", ClientID: "rm", ClientSecret: "s",
RoleMapping: map[string]string{"a": "admin"},
}
c, err := loadOIDC(map[string]string{}, yaml)
if err != nil {
t.Fatalf("load: %v", err)
}
if c.RoleClaim != "groups" {
t.Errorf("role_claim default: got %q want groups", c.RoleClaim)
}
if c.DisplayName != "SSO" {
t.Errorf("display_name default: got %q want SSO", c.DisplayName)
}
wantScopes := []string{"openid", "profile", "email", "groups"}
if len(c.Scopes) != len(wantScopes) {
t.Errorf("scopes default: got %v want %v", c.Scopes, wantScopes)
}
}
func TestOIDCEnvOverrides(t *testing.T) {
t.Parallel()
yaml := OIDCConfig{
Issuer: "https://from-yaml", ClientID: "yaml-id", ClientSecret: "yaml-secret",
RoleMapping: map[string]string{"x": "admin"},
}
envs := map[string]string{
"RM_OIDC_ISSUER": "https://from-env",
"RM_OIDC_CLIENT_ID": "env-id",
"RM_OIDC_CLIENT_SECRET": "env-secret",
}
c, err := loadOIDC(envs, yaml)
if err != nil {
t.Fatalf("load: %v", err)
}
if c.Issuer != "https://from-env" || c.ClientID != "env-id" || c.ClientSecret != "env-secret" {
t.Errorf("env override: got %+v", c)
}
}
+3
View File
@@ -56,6 +56,9 @@ func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Req
// existence to a probing attacker. // existence to a probing attacker.
return nil, errInvalidCredentials return nil, errInvalidCredentials
} }
if u.AuthSource == "oidc" {
return nil, errInvalidCredentials
}
if err := auth.VerifyPassword(u.PasswordHash, password); err != nil { if err := auth.VerifyPassword(u.PasswordHash, password); err != nil {
return nil, errInvalidCredentials return nil, errInvalidCredentials
} }
+205
View File
@@ -0,0 +1,205 @@
// oidc_handlers.go — OIDC sign-in handlers. Public routes when oidc
// is configured (s.deps.OIDC != nil), otherwise not mounted.
package http
import (
"encoding/json"
"errors"
"log/slog"
stdhttp "net/http"
"strings"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// handleOIDCLogin generates state + PKCE pair, persists them, and
// redirects to the IdP authorization endpoint.
func (s *Server) handleOIDCLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) {
state, err := oidc.RandomState()
if err != nil {
slog.Error("oidc login: state", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
verifier, challenge, err := oidc.PKCEPair()
if err != nil {
slog.Error("oidc login: pkce", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := s.deps.Store.PutOIDCState(r.Context(),
oidc.HashState(state), verifier, time.Now().UTC()); err != nil {
slog.Error("oidc login: persist state", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
stdhttp.Redirect(w, r, s.deps.OIDC.AuthURL(state, challenge), stdhttp.StatusSeeOther)
}
func (s *Server) handleOIDCCallback(w stdhttp.ResponseWriter, r *stdhttp.Request) {
q := r.URL.Query()
code := q.Get("code")
state := q.Get("state")
if code == "" || state == "" {
s.oidcRedirectError(w, r, "missing_params")
return
}
verifier, err := s.deps.Store.ConsumeOIDCState(r.Context(), oidc.HashState(state))
if err != nil {
s.oidcRedirectError(w, r, "bad_state")
return
}
claims, rawIDToken, err := s.deps.OIDC.Exchange(r.Context(), code, verifier)
if err != nil {
slog.Warn("oidc callback: exchange", "err", err)
s.oidcRedirectError(w, r, "exchange_failed")
return
}
uname := strings.ToLower(strings.TrimSpace(claims.PreferredUsername))
if uname == "" {
uname = strings.ToLower(strings.TrimSpace(claims.Email))
}
if uname == "" || claims.Subject == "" {
s.oidcRedirectError(w, r, "missing_claims")
return
}
role := s.deps.OIDC.MapRole(claims.Roles)
if role == "" {
_ = s.auditOIDCBlocked(r, claims, "no_role_match")
s.oidcRedirectError(w, r, "no_role_match")
return
}
now := time.Now().UTC()
// Returning OIDC user — refresh role + email + last_login.
existing, err := s.deps.Store.GetUserByOIDCSubject(r.Context(), claims.Subject)
if err == nil {
if existing.DisabledAt != nil {
s.oidcRedirectError(w, r, "user_disabled")
return
}
_ = s.deps.Store.SetUserRole(r.Context(), existing.ID, store.Role(role))
_ = s.deps.Store.SetUserEmail(r.Context(), existing.ID, claims.Email)
_ = s.deps.Store.MarkUserLogin(r.Context(), existing.ID, now)
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &existing.ID, Actor: "user",
Action: "user.oidc_login", TargetKind: ptr("user"),
TargetID: &existing.ID, TS: now,
})
s.oidcDropSessionAndRedirect(w, r, existing.ID, rawIDToken, now)
return
} else if !errors.Is(err, store.ErrNotFound) {
slog.Error("oidc callback: lookup by sub", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
// New OIDC user — first check the username doesn't collide with
// a local user.
if _, err := s.deps.Store.GetUserByUsername(r.Context(), uname); err == nil {
_ = s.auditOIDCBlocked(r, claims, "username_taken")
s.oidcRedirectError(w, r, "username_taken")
return
} else if !errors.Is(err, store.ErrNotFound) {
slog.Error("oidc callback: lookup by username", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
// JIT-provision.
id := ulid.Make().String()
var emailPtr *string
if claims.Email != "" {
em := strings.ToLower(claims.Email)
emailPtr = &em
}
sub := claims.Subject
if err := s.deps.Store.CreateUser(r.Context(), store.User{
ID: id, Username: uname, PasswordHash: "",
Role: store.Role(role), Email: emailPtr,
AuthSource: "oidc", OIDCSubject: &sub,
CreatedAt: now,
}); err != nil {
slog.Error("oidc callback: provision", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.MarkUserLogin(r.Context(), id, now)
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &id, Actor: "user",
Action: "user.created", TargetKind: ptr("user"), TargetID: &id,
TS: now,
Payload: jsonMust(map[string]any{"auth_source": "oidc"}),
})
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &id, Actor: "user",
Action: "user.oidc_login", TargetKind: ptr("user"), TargetID: &id,
TS: now,
})
s.oidcDropSessionAndRedirect(w, r, id, rawIDToken, now)
}
func (s *Server) oidcDropSessionAndRedirect(w stdhttp.ResponseWriter, r *stdhttp.Request, userID, idToken string, now time.Time) {
rawSession, err := auth.NewToken()
if err != nil {
slog.Error("oidc: session token", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
hashed := auth.HashToken(rawSession)
if err := s.deps.Store.CreateSession(r.Context(), store.Session{
ID: hashed, UserID: userID, CreatedAt: now,
ExpiresAt: now.Add(8 * time.Hour),
IDToken: idToken,
}, hashed); err != nil {
slog.Error("oidc: create session", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
stdhttp.SetCookie(w, &stdhttp.Cookie{
Name: sessionCookieName, Value: rawSession,
Path: "/", HttpOnly: true,
SameSite: stdhttp.SameSiteLaxMode,
Secure: s.deps.Cfg.CookieSecure,
Expires: now.Add(8 * time.Hour),
})
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
}
func (s *Server) oidcRedirectError(w stdhttp.ResponseWriter, r *stdhttp.Request, code string) {
stdhttp.Redirect(w, r, "/login?oidc_error="+code, stdhttp.StatusSeeOther)
}
// auditOIDCBlocked records a failed sign-in. user_id is nil because
// no row was created; the IdP subject + reason go in the payload so
// admin can correlate.
func (s *Server) auditOIDCBlocked(r *stdhttp.Request, claims *oidc.Claims, reason string) error {
return s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: nil, Actor: "system",
Action: "user.oidc_login_blocked", TargetKind: ptr("user"),
TargetID: nil, TS: time.Now().UTC(),
Payload: jsonMust(map[string]any{
"sub": claims.Subject,
"username": claims.PreferredUsername,
"reason": reason,
}),
})
}
// jsonMust marshals to json.RawMessage; on error returns nil so the
// audit row still lands without the payload (best-effort).
func jsonMust(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return nil
}
return json.RawMessage(b)
}
+293
View File
@@ -0,0 +1,293 @@
package http
import (
"bytes"
"context"
"encoding/json"
stdhttp "net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc/oidctest"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// newTestServerWithOIDC returns a Server wired to a stub IdP.
// Returned ts is the httptest.Server fronting the actual server;
// stub is the IdP for minting codes / configuring claims.
func newTestServerWithOIDC(t *testing.T) (*Server, *httptest.Server, *oidctest.StubIdP) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
keyPath := filepath.Join(dir, "secret.key")
if err := crypto.GenerateKeyFile(keyPath); err != nil {
t.Fatalf("genkey: %v", err)
}
key, _ := crypto.LoadKeyFromFile(keyPath)
aead, _ := crypto.NewAEAD(key)
stub := oidctest.New(t)
cfg := &config.OIDCConfig{
Issuer: stub.URL(), ClientID: "test-client", ClientSecret: "x",
Scopes: []string{"openid"}, RoleClaim: "groups",
RoleMapping: map[string]string{
"rm-admins": "admin",
"rm-operators": "operator",
"rm-viewers": "viewer",
},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
oidcClient, err := oidc.New(ctx, cfg, "http://test")
if err != nil {
t.Fatalf("oidc client: %v", err)
}
deps := Deps{
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath, BaseURL: "http://test"},
Store: st,
AEAD: aead,
OIDC: oidcClient,
}
s := New(deps)
ts := httptest.NewServer(s.srv.Handler)
t.Cleanup(ts.Close)
return s, ts, stub
}
func TestOIDCLoginRedirectsToIdP(t *testing.T) {
t.Parallel()
srv, ts, _ := newTestServerWithOIDC(t)
c := &stdhttp.Client{CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error {
return stdhttp.ErrUseLastResponse
}}
res, err := c.Get(ts.URL + "/auth/oidc/login")
if err != nil {
t.Fatalf("get: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusSeeOther {
t.Errorf("status: got %d want 303", res.StatusCode)
}
loc := res.Header.Get("Location")
if !strings.Contains(loc, "code_challenge=") || !strings.Contains(loc, "state=") {
t.Errorf("location: %q", loc)
}
_ = srv
}
// runCallback drives the auth code flow against the stub: kicks off
// /auth/oidc/login (capturing the state), mints a code at the stub
// with the given claims, then GETs /auth/oidc/callback. Returns the
// final response.
func runCallback(t *testing.T, ts *httptest.Server, stub *oidctest.StubIdP, claims map[string]any) *stdhttp.Response {
t.Helper()
jar, _ := cookiejar.New(nil)
c := &stdhttp.Client{Jar: jar, CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error {
return stdhttp.ErrUseLastResponse
}}
res, err := c.Get(ts.URL + "/auth/oidc/login")
if err != nil {
t.Fatalf("login: %v", err)
}
res.Body.Close()
authURL, _ := url.Parse(res.Header.Get("Location"))
state := authURL.Query().Get("state")
code := stub.MintCode(claims)
res, err = c.Get(ts.URL + "/auth/oidc/callback?code=" + code + "&state=" + state)
if err != nil {
t.Fatalf("callback: %v", err)
}
return res
}
func TestOIDCCallbackHappyPathAdmin(t *testing.T) {
t.Parallel()
srv, ts, stub := newTestServerWithOIDC(t)
res := runCallback(t, ts, stub, map[string]any{
"sub": "admin-sub",
"preferred_username": "alice",
"email": "alice@example.com",
"groups": []string{"rm-admins"},
"aud": "test-client",
})
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusSeeOther || res.Header.Get("Location") != "/" {
t.Errorf("status: %d Location: %q", res.StatusCode, res.Header.Get("Location"))
}
u, err := srv.deps.Store.GetUserByOIDCSubject(t.Context(), "admin-sub")
if err != nil || u.AuthSource != "oidc" || u.Role != "admin" || u.Username != "alice" {
t.Errorf("user: %+v err: %v", u, err)
}
}
func TestOIDCCallbackNoRoleMatchDeny(t *testing.T) {
t.Parallel()
_, ts, stub := newTestServerWithOIDC(t)
res := runCallback(t, ts, stub, map[string]any{
"sub": "other-sub",
"preferred_username": "bob",
"groups": []string{"something-else"},
"aud": "test-client",
})
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusSeeOther {
t.Errorf("status: got %d want 303", res.StatusCode)
}
loc := res.Header.Get("Location")
if !strings.Contains(loc, "oidc_error=no_role_match") {
t.Errorf("location: %q", loc)
}
}
func TestOIDCCallbackUsernameCollision(t *testing.T) {
t.Parallel()
srv, ts, stub := newTestServerWithOIDC(t)
if err := srv.deps.Store.CreateUser(t.Context(), store.User{
ID: "local-alice", Username: "alice", PasswordHash: "x",
Role: store.RoleViewer, CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("seed: %v", err)
}
res := runCallback(t, ts, stub, map[string]any{
"sub": "remote-sub",
"preferred_username": "alice",
"groups": []string{"rm-admins"},
"aud": "test-client",
})
defer res.Body.Close()
loc := res.Header.Get("Location")
if !strings.Contains(loc, "oidc_error=username_taken") {
t.Errorf("location: %q", loc)
}
if _, err := srv.deps.Store.GetUserByOIDCSubject(t.Context(), "remote-sub"); err == nil {
t.Error("collision should not have provisioned a user")
}
}
func TestOIDCCallbackReturningUserRefreshesRole(t *testing.T) {
t.Parallel()
srv, ts, stub := newTestServerWithOIDC(t)
res := runCallback(t, ts, stub, map[string]any{
"sub": "carol-sub",
"preferred_username": "carol",
"groups": []string{"rm-operators"},
"aud": "test-client",
})
res.Body.Close()
res = runCallback(t, ts, stub, map[string]any{
"sub": "carol-sub",
"preferred_username": "carol",
"groups": []string{"rm-admins"},
"aud": "test-client",
})
res.Body.Close()
u, _ := srv.deps.Store.GetUserByOIDCSubject(t.Context(), "carol-sub")
if u.Role != "admin" {
t.Errorf("role refresh: got %q want admin", u.Role)
}
}
func TestOIDCLogoutRedirectsToEndSession(t *testing.T) {
t.Parallel()
srv, ts, stub := newTestServerWithOIDC(t)
endSessionURL := stub.URL() + "/logout-end"
stub.SetEndSessionEndpoint(endSessionURL)
// Rebuild the OIDC client because end_session_endpoint is read at
// New() time from the discovery doc.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cfg := &config.OIDCConfig{
Issuer: stub.URL(), ClientID: "test-client", ClientSecret: "x",
Scopes: []string{"openid"}, RoleClaim: "groups",
RoleMapping: map[string]string{"rm-admins": "admin"},
}
newClient, err := oidc.New(ctx, cfg, "http://test")
if err != nil {
t.Fatalf("rebuild client: %v", err)
}
srv.deps.OIDC = newClient
// Sign in via the OIDC flow.
res := runCallback(t, ts, stub, map[string]any{
"sub": "logout-sub",
"preferred_username": "lo",
"groups": []string{"rm-admins"},
"aud": "test-client",
})
res.Body.Close()
cookies := res.Cookies()
if len(cookies) == 0 {
t.Fatal("expected session cookie after sign-in")
}
sessionCookie := cookies[0]
// POST /logout — should 303 to the end_session endpoint with
// id_token_hint + post_logout_redirect_uri.
c := &stdhttp.Client{CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error {
return stdhttp.ErrUseLastResponse
}}
req, _ := stdhttp.NewRequest("POST", ts.URL+"/logout", nil)
req.AddCookie(sessionCookie)
res, err = c.Do(req)
if err != nil {
t.Fatalf("logout: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusSeeOther {
t.Errorf("status: got %d want 303", res.StatusCode)
}
loc := res.Header.Get("Location")
if !strings.Contains(loc, "/logout-end") {
t.Errorf("location not at end_session: %q", loc)
}
if !strings.Contains(loc, "id_token_hint=") {
t.Errorf("location missing id_token_hint: %q", loc)
}
if !strings.Contains(loc, "post_logout_redirect_uri=") {
t.Errorf("location missing post_logout_redirect_uri: %q", loc)
}
}
func TestLocalLoginRejectsOIDCUser(t *testing.T) {
t.Parallel()
srv, urlBase := newTestServer(t, false)
uid := "u-oidc"
sub := "sub-x"
if err := srv.deps.Store.CreateUser(t.Context(), store.User{
ID: uid, Username: "ouser", PasswordHash: "",
Role: store.RoleOperator, CreatedAt: time.Now().UTC(),
AuthSource: "oidc", OIDCSubject: &sub,
}); err != nil {
t.Fatalf("create: %v", err)
}
body, _ := json.Marshal(map[string]string{
"username": "ouser", "password": "anything",
})
res, err := stdhttp.Post(urlBase+"/api/auth/login",
"application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusUnauthorized {
t.Errorf("status: got %d want 401", res.StatusCode)
}
}
+11 -1
View File
@@ -17,6 +17,7 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification" "gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
@@ -45,6 +46,9 @@ type Deps struct {
// admin-bootstrap token printed in the server logs. While set, the // admin-bootstrap token printed in the server logs. While set, the
// /bootstrap endpoint accepts it to create the first admin user. // /bootstrap endpoint accepts it to create the first admin user.
BootstrapToken string BootstrapToken string
// OIDC (optional). Non-nil when the operator has configured an
// IdP — handlers under /auth/oidc/* are mounted only when set.
OIDC *oidc.Client
} }
// Server is the running HTTP server. // Server is the running HTTP server.
@@ -133,13 +137,19 @@ func (s *Server) routes(r chi.Router) {
r.Get("/ws/agent/pending", s.handlePendingWS) r.Get("/ws/agent/pending", s.handlePendingWS)
r.Mount("/static/", staticHandler()) r.Mount("/static/", staticHandler())
// POST /logout is always mounted — it handles both local and OIDC
// sessions and doesn't require the UI renderer.
r.Post("/logout", s.handleUILogoutPost)
if s.deps.UI != nil { if s.deps.UI != nil {
r.Get("/login", s.handleUILoginGet) r.Get("/login", s.handleUILoginGet)
r.Post("/login", s.handleUILoginPost) r.Post("/login", s.handleUILoginPost)
r.Post("/logout", s.handleUILogoutPost)
r.Get("/setup", s.handleUISetupGet) r.Get("/setup", s.handleUISetupGet)
r.Post("/setup", s.handleUISetupPost) r.Post("/setup", s.handleUISetupPost)
} }
if s.deps.OIDC != nil {
r.Get("/auth/oidc/login", s.handleOIDCLogin)
r.Get("/auth/oidc/callback", s.handleOIDCCallback)
}
// Viewer band — anyone authenticated can read. // Viewer band — anyone authenticated can read.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
+43 -6
View File
@@ -8,6 +8,7 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
stdhttp "net/http" stdhttp "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -921,7 +922,14 @@ func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request)
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
return return
} }
view := ui.ViewData{Version: s.version()} view := ui.ViewData{
Version: s.version(),
OIDCError: r.URL.Query().Get("oidc_error"),
}
if s.deps.OIDC != nil {
view.OIDCEnabled = true
view.OIDCDisplayName = s.deps.OIDC.DisplayName()
}
if err := s.deps.UI.Render(w, "login", view); err != nil { if err := s.deps.UI.Render(w, "login", view); err != nil {
slog.Error("ui: render login", "err", err) slog.Error("ui: render login", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
@@ -947,6 +955,10 @@ func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request)
Username: username, Username: username,
Error: "Invalid username or password.", Error: "Invalid username or password.",
} }
if s.deps.OIDC != nil {
view.OIDCEnabled = true
view.OIDCDisplayName = s.deps.OIDC.DisplayName()
}
w.WriteHeader(stdhttp.StatusUnauthorized) w.WriteHeader(stdhttp.StatusUnauthorized)
if err := s.deps.UI.Render(w, "login", view); err != nil { if err := s.deps.UI.Render(w, "login", view); err != nil {
slog.Error("ui: render login (post-fail)", "err", err) slog.Error("ui: render login (post-fail)", "err", err)
@@ -956,12 +968,37 @@ func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request)
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
} }
// handleUILogoutPost is the form-submit twin of /api/auth/logout. It // handleUILogoutPost is the form-submit twin of /api/auth/logout. For
// drops the session cookie and redirects to /login. // local sessions it drops the cookie and redirects to /login. For OIDC
// sessions, if the IdP advertised an end_session_endpoint it performs
// RP-initiated logout by redirecting there with id_token_hint and
// post_logout_redirect_uri.
func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil { c, err := r.Cookie(sessionCookieName)
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value)) if err != nil {
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
return
} }
hash := auth.HashToken(c.Value)
sess, _ := s.deps.Store.LookupSession(r.Context(), hash)
_ = s.deps.Store.DeleteSession(r.Context(), hash)
// Default: drop session, go to /login.
dest := "/login"
// OIDC session with a discovered end_session_endpoint? Compose
// the IdP logout URL with id_token_hint + post_logout_redirect_uri.
if sess != nil && sess.IDToken != "" && s.deps.OIDC != nil &&
s.deps.OIDC.EndSessionEndpoint() != "" {
v := url.Values{}
v.Set("id_token_hint", sess.IDToken)
if base := strings.TrimRight(s.deps.Cfg.BaseURL, "/"); base != "" {
v.Set("post_logout_redirect_uri", base+"/login")
}
dest = s.deps.OIDC.EndSessionEndpoint() + "?" + v.Encode()
}
// Clear the cookie.
stdhttp.SetCookie(w, &stdhttp.Cookie{ stdhttp.SetCookie(w, &stdhttp.Cookie{
Name: sessionCookieName, Name: sessionCookieName,
Value: "", Value: "",
@@ -971,5 +1008,5 @@ func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request
Secure: s.deps.Cfg.CookieSecure, Secure: s.deps.Cfg.CookieSecure,
SameSite: stdhttp.SameSiteLaxMode, SameSite: stdhttp.SameSiteLaxMode,
}) })
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) stdhttp.Redirect(w, r, dest, stdhttp.StatusSeeOther)
} }
+11 -3
View File
@@ -51,6 +51,7 @@ type userRow struct {
LastLoginAt string // pre-formatted "2006-01-02 15:04:05" or "never" LastLoginAt string // pre-formatted "2006-01-02 15:04:05" or "never"
Disabled bool Disabled bool
MustChangePassword bool MustChangePassword bool
AuthSource string
} }
func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
@@ -104,6 +105,7 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request)
Role: string(ux.Role), LastLoginAt: ll, Role: string(ux.Role), LastLoginAt: ll,
Disabled: ux.DisabledAt != nil, Disabled: ux.DisabledAt != nil,
MustChangePassword: ux.MustChangePassword, MustChangePassword: ux.MustChangePassword,
AuthSource: ux.AuthSource,
}) })
} }
@@ -157,7 +159,8 @@ type userFormPage struct {
// to add a username that already exists (disabled). Triggers a // to add a username that already exists (disabled). Triggers a
// banner on the edit page explaining why and steering them at // banner on the edit page explaining why and steering them at
// the Re-enable button. See handleUIUserNewPost's collision branch. // the Re-enable button. See handleUIUserNewPost's collision branch.
Reenable bool Reenable bool
AuthSource string
} }
func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
@@ -294,8 +297,9 @@ func (s *Server) handleUIUserEditGet(w stdhttp.ResponseWriter, r *stdhttp.Reques
view.Page = userFormPage{ view.Page = userFormPage{
Mode: "edit", ID: target.ID, Username: target.Username, Mode: "edit", ID: target.ID, Username: target.Username,
Email: em, Role: string(target.Role), Email: em, Role: string(target.Role),
Disabled: target.DisabledAt != nil, Disabled: target.DisabledAt != nil,
Reenable: r.URL.Query().Get("reenable") == "1", Reenable: r.URL.Query().Get("reenable") == "1",
AuthSource: target.AuthSource,
} }
_ = s.deps.UI.Render(w, "user_edit", view) _ = s.deps.UI.Render(w, "user_edit", view)
} }
@@ -315,6 +319,10 @@ func (s *Server) handleUIUserEditPost(w stdhttp.ResponseWriter, r *stdhttp.Reque
stdhttp.NotFound(w, r) stdhttp.NotFound(w, r)
return return
} }
if target.AuthSource == "oidc" {
stdhttp.Error(w, "OIDC users cannot have role/email edited locally", stdhttp.StatusForbidden)
return
}
role, ok := validRole(r.PostForm.Get("role")) role, ok := validRole(r.PostForm.Get("role"))
if !ok { if !ok {
stdhttp.Error(w, "bad role", stdhttp.StatusBadRequest) stdhttp.Error(w, "bad role", stdhttp.StatusBadRequest)
+208
View File
@@ -0,0 +1,208 @@
// Package oidc wraps go-oidc + oauth2 in the small surface the
// HTTP handlers need: discovery, code-exchange config, ID-token
// verification, and role-claim resolution.
package oidc
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strings"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
)
// Client bundles the discovered provider + a pre-built oauth2.Config.
// Constructed once at server start; safe for concurrent use.
type Client struct {
cfg *config.OIDCConfig
provider *gooidc.Provider
verifier *gooidc.IDTokenVerifier
oauth *oauth2.Config
endSession string // discovered end_session_endpoint, "" if none
}
// New discovers the provider's well-known config and builds a Client.
// Network call — should be invoked once at startup with a context
// carrying a sane timeout. Returns an error on a 4xx/5xx from
// discovery so the operator finds out at startup, not on first login.
func New(ctx context.Context, cfg *config.OIDCConfig, baseURL string) (*Client, error) {
if cfg == nil {
return nil, errors.New("oidc: config nil")
}
prov, err := gooidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("oidc: discovery: %w", err)
}
redir := cfg.RedirectURL
if redir == "" {
redir = strings.TrimRight(baseURL, "/") + "/auth/oidc/callback"
}
oa := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: prov.Endpoint(),
RedirectURL: redir,
Scopes: cfg.Scopes,
}
verifier := prov.Verifier(&gooidc.Config{ClientID: cfg.ClientID})
// Pull end_session_endpoint out of the discovery doc — go-oidc
// doesn't expose it as a typed field, but the underlying claims
// blob does.
var doc struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
_ = prov.Claims(&doc)
return &Client{
cfg: cfg,
provider: prov,
verifier: verifier,
oauth: oa,
endSession: doc.EndSessionEndpoint,
}, nil
}
// AuthURL returns the URL to redirect the browser to for the
// Authorization Code + PKCE flow. State + verifier are caller-
// supplied so the caller can persist them in the oidc_state table.
func (c *Client) AuthURL(state, codeChallenge string) string {
return c.oauth.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
}
// Exchange swaps a code+verifier for a token set and verifies the
// id_token. Returns the parsed Claims and the raw id_token (the
// caller stashes the raw on the session for RP-initiated logout).
func (c *Client) Exchange(ctx context.Context, code, verifier string) (*Claims, string, error) {
tok, err := c.oauth.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", verifier))
if err != nil {
return nil, "", fmt.Errorf("oidc: token exchange: %w", err)
}
rawID, ok := tok.Extra("id_token").(string)
if !ok || rawID == "" {
return nil, "", errors.New("oidc: id_token missing from token response")
}
idTok, err := c.verifier.Verify(ctx, rawID)
if err != nil {
return nil, "", fmt.Errorf("oidc: verify id_token: %w", err)
}
var raw map[string]any
if err := idTok.Claims(&raw); err != nil {
return nil, "", fmt.Errorf("oidc: claims: %w", err)
}
// Many IdPs (Authelia among them) only return minimal claims in
// the ID token and put profile/email/groups on /userinfo. Fetch
// userinfo and merge — id_token claims win on conflict so the
// signed assertion remains authoritative.
if ui, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(tok)); err == nil {
var uiClaims map[string]any
if err := ui.Claims(&uiClaims); err == nil {
for k, v := range uiClaims {
if _, present := raw[k]; !present {
raw[k] = v
}
}
}
}
return parseClaims(raw, c.cfg.RoleClaim), rawID, nil
}
// EndSessionEndpoint exposes the discovered end_session URL ("" if
// the IdP doesn't advertise one).
func (c *Client) EndSessionEndpoint() string { return c.endSession }
// DisplayName for the SSO button on the login page.
func (c *Client) DisplayName() string { return c.cfg.DisplayName }
// MapRole returns the role for the first matching claim value; "" if
// none match. Caller treats "" as deny.
func (c *Client) MapRole(roles []string) string {
for _, r := range roles {
if mapped, ok := c.cfg.RoleMapping[r]; ok {
return mapped
}
}
return ""
}
// Claims is the minimal projection the callback handler cares about.
type Claims struct {
Subject string
PreferredUsername string
Email string
Roles []string // normalised from string|[]string|csv
}
// parseClaims pulls the four fields we need from the raw id_token
// claims. The 'roles' field is normalised from the three shapes
// IdPs emit (string, []string, comma-separated string).
func parseClaims(raw map[string]any, roleClaim string) *Claims {
c := &Claims{}
if v, ok := raw["sub"].(string); ok {
c.Subject = v
}
if v, ok := raw["preferred_username"].(string); ok {
c.PreferredUsername = v
}
if v, ok := raw["email"].(string); ok {
c.Email = v
}
switch v := raw[roleClaim].(type) {
case string:
for _, p := range strings.Split(v, ",") {
p = strings.TrimSpace(p)
if p != "" {
c.Roles = append(c.Roles, p)
}
}
case []any:
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
c.Roles = append(c.Roles, s)
}
}
}
return c
}
// RandomState generates 32 random bytes URL-safe base64-encoded —
// used as the 'state' parameter on the authorization request.
// Caller is expected to compute sha256(state) for storage.
func RandomState() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// PKCEPair generates a code_verifier (base64-url 64 chars) and the
// corresponding S256 code_challenge.
func PKCEPair() (verifier, challenge string, err error) {
var b [48]byte
if _, err := rand.Read(b[:]); err != nil {
return "", "", err
}
verifier = base64.RawURLEncoding.EncodeToString(b[:])
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return verifier, challenge, nil
}
// HashState returns sha256(state) hex — used as the primary key in
// the oidc_state table (so a DB leak doesn't leak active states).
func HashState(state string) string {
sum := sha256.Sum256([]byte(state))
return fmt.Sprintf("%x", sum)
}
+49
View File
@@ -0,0 +1,49 @@
package oidc
import (
"context"
"testing"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc/oidctest"
)
func TestClientExchangeAgainstStub(t *testing.T) {
t.Parallel()
stub := oidctest.New(t)
cfg := &config.OIDCConfig{
Issuer: stub.URL(), ClientID: "test-client", ClientSecret: "x",
Scopes: []string{"openid"}, RoleClaim: "groups",
RoleMapping: map[string]string{"rm-admins": "admin"},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, err := New(ctx, cfg, "http://rm.example")
if err != nil {
t.Fatalf("new client: %v", err)
}
code := stub.MintCode(map[string]any{
"sub": "abc",
"preferred_username": "alice",
"email": "alice@example.com",
"groups": []string{"rm-admins"},
})
verifier, _, err := PKCEPair()
if err != nil {
t.Fatalf("pkce: %v", err)
}
claims, raw, err := c.Exchange(ctx, code, verifier)
if err != nil {
t.Fatalf("exchange: %v", err)
}
if claims.Subject != "abc" || claims.PreferredUsername != "alice" {
t.Errorf("claims: %+v", claims)
}
if c.MapRole(claims.Roles) != "admin" {
t.Errorf("role: got %q", c.MapRole(claims.Roles))
}
if raw == "" {
t.Error("raw id_token must be non-empty")
}
}
+181
View File
@@ -0,0 +1,181 @@
// Package oidctest provides a minimal OIDC provider for tests —
// discovery doc, JWKS, and a token endpoint. Each test mints its
// own claims; the stub signs them with an ECDSA P-256 key and the
// production verifier accepts them because the JWKS is fetched live
// from the stub.
//
// Usage:
//
// stub := oidctest.New(t)
// code := stub.MintCode(map[string]any{
// "sub": "abc",
// "preferred_username": "alice",
// "groups": []string{"rm-admins"},
// })
// // stub.URL() is the issuer URL; pass to oidc.New as Issuer
package oidctest
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
stdhttp "net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// StubIdP is an httptest-backed OIDC provider. Each test creates a
// fresh one via New(t); cleanup is registered on t.
type StubIdP struct {
t *testing.T
srv *httptest.Server
mu sync.Mutex
priv *ecdsa.PrivateKey
kid string
claims map[string]map[string]any // code → claims
endSession string // optional, set by SetEndSessionEndpoint
}
// New constructs a stub IdP listening on a random port. Cleanup is
// registered on t.
func New(t *testing.T) *StubIdP {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("oidctest: genkey: %v", err)
}
s := &StubIdP{
t: t,
priv: priv,
kid: "stub-key",
claims: map[string]map[string]any{},
}
mux := stdhttp.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", s.discovery)
mux.HandleFunc("/jwks.json", s.jwks)
mux.HandleFunc("/token", s.token)
s.srv = httptest.NewServer(mux)
t.Cleanup(s.srv.Close)
return s
}
// URL returns the base URL of the stub — pass as Issuer to
// oidc.New().
func (s *StubIdP) URL() string { return s.srv.URL }
// MintCode produces an authorization code that the stub will exchange
// for an id_token containing the supplied claims.
func (s *StubIdP) MintCode(claims map[string]any) string {
s.mu.Lock()
defer s.mu.Unlock()
code := fmt.Sprintf("code-%d", time.Now().UnixNano())
s.claims[code] = claims
return code
}
// SetEndSessionEndpoint configures the stub to advertise an
// end_session_endpoint in its discovery doc. Used by the logout
// test in E1.
func (s *StubIdP) SetEndSessionEndpoint(url string) {
s.mu.Lock()
defer s.mu.Unlock()
s.endSession = url
}
func (s *StubIdP) discovery(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
s.mu.Lock()
endSession := s.endSession
s.mu.Unlock()
doc := map[string]any{
"issuer": s.srv.URL,
"authorization_endpoint": s.srv.URL + "/authorize",
"token_endpoint": s.srv.URL + "/token",
"jwks_uri": s.srv.URL + "/jwks.json",
"id_token_signing_alg_values_supported": []string{"ES256"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
}
if endSession != "" {
doc["end_session_endpoint"] = endSession
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
}
func (s *StubIdP) jwks(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
pub := s.priv.Public().(*ecdsa.PublicKey)
x := base64.RawURLEncoding.EncodeToString(padTo32(pub.X.Bytes()))
y := base64.RawURLEncoding.EncodeToString(padTo32(pub.Y.Bytes()))
keys := map[string]any{
"keys": []map[string]any{{
"kty": "EC", "crv": "P-256", "alg": "ES256",
"use": "sig", "kid": s.kid,
"x": x, "y": y,
}},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(keys)
}
func (s *StubIdP) token(w stdhttp.ResponseWriter, r *stdhttp.Request) {
_ = r.ParseForm()
code := r.PostForm.Get("code")
s.mu.Lock()
claims, ok := s.claims[code]
if ok {
delete(s.claims, code)
}
s.mu.Unlock()
if !ok {
stdhttp.Error(w, "bad code", stdhttp.StatusBadRequest)
return
}
if _, ok := claims["iss"]; !ok {
claims["iss"] = s.srv.URL
}
if _, ok := claims["aud"]; !ok {
claims["aud"] = "test-client"
}
now := time.Now().Unix()
claims["iat"] = now
claims["exp"] = now + 600
jc := jwt.MapClaims{}
for k, v := range claims {
jc[k] = v
}
tk := jwt.NewWithClaims(jwt.SigningMethodES256, jc)
tk.Header["kid"] = s.kid
signed, err := tk.SignedString(s.priv)
if err != nil {
stdhttp.Error(w, err.Error(), 500)
return
}
resp := map[string]any{
"access_token": "stub-access",
"token_type": "Bearer",
"id_token": signed,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// padTo32 left-pads an integer big-endian byte slice to 32 bytes,
// the size required by P-256 JWK x/y components.
func padTo32(b []byte) []byte {
if len(b) >= 32 {
return b
}
out := make([]byte, 32)
copy(out[32-len(b):], b)
return out
}
+13
View File
@@ -56,6 +56,19 @@ type ViewData struct {
// today; other pages can adopt the same field. // today; other pages can adopt the same field.
Error string Error string
// OIDCEnabled is true when the server has an OIDC provider
// configured. The login page uses it to show the SSO button.
OIDCEnabled bool
// OIDCDisplayName is the human-readable label for the OIDC
// provider (e.g. "Authelia"). Shown on the SSO button.
OIDCDisplayName string
// OIDCError holds an error code returned via ?oidc_error=… after
// a failed OIDC callback. The login page maps it to a user-facing
// message.
OIDCError string
// Page carries page-specific data. Concrete type is the page's // Page carries page-specific data. Concrete type is the page's
// own struct. // own struct.
Page any Page any
+35
View File
@@ -0,0 +1,35 @@
-- 0019_oidc.sql
--
-- OIDC bookkeeping. Three independent additions land in one
-- migration to keep the related changes together:
--
-- 1. users.auth_source — 'local' | 'oidc'. Local users get
-- the default; first OIDC sign-in JITs
-- a row with auth_source='oidc'.
-- 2. users.oidc_subject — IdP's stable 'sub' claim. Indexed
-- uniquely (partial; NULLs allowed).
-- 3. sessions.id_token — last id_token for OIDC sessions, used
-- as id_token_hint on RP-initiated
-- logout. NULL for local sessions.
-- 4. oidc_state — short-lived state for the OAuth round-
-- trip (state + PKCE code_verifier).
-- Swept on the alert engine tick.
--
-- All column-level ALTERs (CLAUDE.md preference; safe under
-- foreign_keys=ON).
ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'local'
CHECK (auth_source IN ('local', 'oidc'));
ALTER TABLE users ADD COLUMN oidc_subject TEXT;
CREATE UNIQUE INDEX users_oidc_subject ON users(oidc_subject)
WHERE oidc_subject IS NOT NULL;
ALTER TABLE sessions ADD COLUMN id_token TEXT;
CREATE TABLE oidc_state (
state_hash TEXT PRIMARY KEY, -- sha256(state) hex; raw never persisted
code_verifier TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX oidc_state_created ON oidc_state(created_at);
+65
View File
@@ -0,0 +1,65 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// PutOIDCState stores the (state_hash, code_verifier) pair created
// at /auth/oidc/login start. Called once per login attempt.
func (s *Store) PutOIDCState(ctx context.Context, stateHash, verifier string, createdAt time.Time) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO oidc_state (state_hash, code_verifier, created_at)
VALUES (?, ?, ?)`,
stateHash, verifier,
createdAt.UTC().Format(time.RFC3339Nano))
if err != nil {
return fmt.Errorf("store: put oidc state: %w", err)
}
return nil
}
// ConsumeOIDCState atomically reads + deletes the row in one go,
// returning the code_verifier. Single-use — a re-play returns
// ErrNotFound. Used by the OIDC callback handler.
func (s *Store) ConsumeOIDCState(ctx context.Context, stateHash string) (string, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return "", fmt.Errorf("store: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
var verifier string
err = tx.QueryRowContext(ctx,
`SELECT code_verifier FROM oidc_state WHERE state_hash = ?`,
stateHash).Scan(&verifier)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", fmt.Errorf("store: consume oidc state: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM oidc_state WHERE state_hash = ?`, stateHash); err != nil {
return "", fmt.Errorf("store: delete oidc state: %w", err)
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("store: commit: %w", err)
}
return verifier, nil
}
// CleanupExpiredOIDCState removes entries created before cutoff.
// Called on the alert engine's 60s tick alongside setup-token sweep.
func (s *Store) CleanupExpiredOIDCState(ctx context.Context, cutoff time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM oidc_state WHERE created_at < ?`,
cutoff.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, fmt.Errorf("store: cleanup oidc state: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
+64
View File
@@ -0,0 +1,64 @@
package store
import (
"context"
"path/filepath"
"testing"
"time"
)
func newOIDCStateTestStore(t *testing.T) *Store {
t.Helper()
st, err := Open(context.Background(), filepath.Join(t.TempDir(), "rm.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func TestOIDCStatePutAndConsume(t *testing.T) {
t.Parallel()
st := newOIDCStateTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
if err := st.PutOIDCState(ctx, "hash1", "verifier-1", now); err != nil {
t.Fatalf("put: %v", err)
}
v, err := st.ConsumeOIDCState(ctx, "hash1")
if err != nil {
t.Fatalf("consume: %v", err)
}
if v != "verifier-1" {
t.Errorf("verifier: got %q want %q", v, "verifier-1")
}
if _, err := st.ConsumeOIDCState(ctx, "hash1"); err == nil {
t.Error("re-consume should fail")
}
}
func TestOIDCStateCleanup(t *testing.T) {
t.Parallel()
st := newOIDCStateTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
_ = st.PutOIDCState(ctx, "stale", "v-stale", now.Add(-10*time.Minute))
_ = st.PutOIDCState(ctx, "fresh", "v-fresh", now)
cutoff := now.Add(-5 * time.Minute)
n, err := st.CleanupExpiredOIDCState(ctx, cutoff)
if err != nil {
t.Fatalf("cleanup: %v", err)
}
if n != 1 {
t.Errorf("cleanup count: got %d want 1", n)
}
if _, err := st.ConsumeOIDCState(ctx, "stale"); err == nil {
t.Error("stale entry should have been deleted")
}
if _, err := st.ConsumeOIDCState(ctx, "fresh"); err != nil {
t.Errorf("fresh entry should still be readable: %v", err)
}
}
+10 -6
View File
@@ -12,13 +12,14 @@ import (
// insert; the raw token is what the caller hands to the user (cookie). // insert; the raw token is what the caller hands to the user (cookie).
func (s *Store) CreateSession(ctx context.Context, sess Session, tokenHash string) error { func (s *Store) CreateSession(ctx context.Context, sess Session, tokenHash string) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
`INSERT INTO sessions (id, user_id, created_at, expires_at, ip, ua) `INSERT INTO sessions (id, user_id, created_at, expires_at, ip, ua, id_token)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
tokenHash, tokenHash,
sess.UserID, sess.UserID,
sess.CreatedAt.UTC().Format(time.RFC3339Nano), sess.CreatedAt.UTC().Format(time.RFC3339Nano),
sess.ExpiresAt.UTC().Format(time.RFC3339Nano), sess.ExpiresAt.UTC().Format(time.RFC3339Nano),
sess.IP, sess.UA) nullableStr(sess.IP), nullableStr(sess.UA),
nullableStr(sess.IDToken))
if err != nil { if err != nil {
return fmt.Errorf("store: create session: %w", err) return fmt.Errorf("store: create session: %w", err)
} }
@@ -32,15 +33,15 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, tokenHash strin
// of valid token hashes. // of valid token hashes.
func (s *Store) LookupSession(ctx context.Context, tokenHash string) (*Session, error) { func (s *Store) LookupSession(ctx context.Context, tokenHash string) (*Session, error) {
row := s.db.QueryRowContext(ctx, row := s.db.QueryRowContext(ctx,
`SELECT id, user_id, created_at, expires_at, ip, ua `SELECT id, user_id, created_at, expires_at, ip, ua, id_token
FROM sessions FROM sessions
WHERE id = ? AND expires_at > ?`, WHERE id = ? AND expires_at > ?`,
tokenHash, time.Now().UTC().Format(time.RFC3339Nano)) tokenHash, time.Now().UTC().Format(time.RFC3339Nano))
var sess Session var sess Session
var created, expires string var created, expires string
var ip, ua sql.NullString var ip, ua, idTok sql.NullString
if err := row.Scan(&sess.ID, &sess.UserID, &created, &expires, &ip, &ua); err != nil { if err := row.Scan(&sess.ID, &sess.UserID, &created, &expires, &ip, &ua, &idTok); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -62,6 +63,9 @@ func (s *Store) LookupSession(ctx context.Context, tokenHash string) (*Session,
if ua.Valid { if ua.Valid {
sess.UA = ua.String sess.UA = ua.String
} }
if idTok.Valid {
sess.IDToken = idTok.String
}
return &sess, nil return &sess, nil
} }
+31
View File
@@ -43,3 +43,34 @@ func TestDeleteSessionsByUserID(t *testing.T) {
t.Error("hash1 should be gone") t.Error("hash1 should be gone")
} }
} }
func TestSessionRoundTripsIDToken(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
uid := "u-oidc"
if err := s.CreateUser(ctx, User{
ID: uid, Username: "ouser", PasswordHash: "",
Role: RoleOperator, CreatedAt: now,
AuthSource: "oidc",
}); err != nil {
t.Fatalf("create user: %v", err)
}
if err := s.CreateSession(ctx, Session{
ID: "h1", UserID: uid, CreatedAt: now,
ExpiresAt: now.Add(time.Hour),
IDToken: "eyJ.fake.jwt",
}, "h1"); err != nil {
t.Fatalf("create session: %v", err)
}
got, err := s.LookupSession(ctx, "h1")
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.IDToken != "eyJ.fake.jwt" {
t.Errorf("id_token round trip: got %q", got.IDToken)
}
}
+16 -2
View File
@@ -16,8 +16,18 @@ type User struct {
Email *string // optional; nil = not set Email *string // optional; nil = not set
DisabledAt *time.Time // nil = enabled DisabledAt *time.Time // nil = enabled
MustChangePassword bool MustChangePassword bool
CreatedAt time.Time // AuthSource is "local" (created by admin or bootstrap) or
LastLoginAt *time.Time // "oidc" (JIT-provisioned on first OIDC sign-in). Local users
// authenticate via password; OIDC users via the IdP and have an
// empty PasswordHash.
AuthSource string
// OIDCSubject is the stable 'sub' claim from the IdP. Set only
// when AuthSource == "oidc". Used for fast lookup on subsequent
// sign-ins; the username/email may change at the IdP but sub
// stays stable.
OIDCSubject *string
CreatedAt time.Time
LastLoginAt *time.Time
} }
// Role enumerates the access tiers from spec.md §7.2. // Role enumerates the access tiers from spec.md §7.2.
@@ -40,6 +50,10 @@ type Session struct {
ExpiresAt time.Time ExpiresAt time.Time
IP string IP string
UA string UA string
// IDToken is the OIDC id_token captured at sign-in for OIDC
// sessions; empty for local-user sessions. Used as
// id_token_hint on RP-initiated logout.
IDToken string
} }
// Host mirrors the hosts table. The P2 redesign moved repo-related // Host mirrors the hosts table. The P2 redesign moved repo-related
+51 -14
View File
@@ -18,12 +18,18 @@ func (s *Store) CreateUser(ctx context.Context, u User) error {
if u.MustChangePassword { if u.MustChangePassword {
must = 1 must = 1
} }
authSource := u.AuthSource
if authSource == "" {
authSource = "local"
}
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, username, password_hash, role, email, `INSERT INTO users (id, username, password_hash, role, email,
must_change_password, created_at) must_change_password, auth_source,
VALUES (?, ?, ?, ?, ?, ?, ?)`, oidc_subject, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.PasswordHash, string(u.Role), u.ID, u.Username, u.PasswordHash, string(u.Role),
nullable(u.Email), must, nullable(u.Email), must, authSource,
nullable(u.OIDCSubject),
u.CreatedAt.UTC().Format(time.RFC3339Nano)) u.CreatedAt.UTC().Format(time.RFC3339Nano))
if err != nil { if err != nil {
return fmt.Errorf("store: create user: %w", err) return fmt.Errorf("store: create user: %w", err)
@@ -31,24 +37,49 @@ func (s *Store) CreateUser(ctx context.Context, u User) error {
return nil return nil
} }
// userSelectCols centralises the column list every read path uses so
// scanUser stays in lockstep.
const userSelectCols = `id, username, password_hash, role, email,
disabled_at, must_change_password,
auth_source, oidc_subject,
created_at, last_login_at`
// GetUserByUsername resolves a user case-insensitively. // GetUserByUsername resolves a user case-insensitively.
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
row := s.db.QueryRowContext(ctx, row := s.db.QueryRowContext(ctx,
`SELECT id, username, password_hash, role, email, disabled_at, `SELECT `+userSelectCols+` FROM users WHERE LOWER(username) = LOWER(?)`,
must_change_password, created_at, last_login_at username)
FROM users WHERE LOWER(username) = LOWER(?)`, username)
return scanUser(row.Scan) return scanUser(row.Scan)
} }
// GetUserByID looks up a user by id. Returns ErrNotFound on miss. // GetUserByID looks up a user by id. Returns ErrNotFound on miss.
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
row := s.db.QueryRowContext(ctx, row := s.db.QueryRowContext(ctx,
`SELECT id, username, password_hash, role, email, disabled_at, `SELECT `+userSelectCols+` FROM users WHERE id = ?`, id)
must_change_password, created_at, last_login_at
FROM users WHERE id = ?`, id)
return scanUser(row.Scan) return scanUser(row.Scan)
} }
// GetUserByOIDCSubject finds the user JIT-provisioned on a previous
// OIDC sign-in. ErrNotFound on miss.
func (s *Store) GetUserByOIDCSubject(ctx context.Context, sub string) (*User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+userSelectCols+` FROM users WHERE oidc_subject = ?`, sub)
return scanUser(row.Scan)
}
// SetUserOIDCSubject pins an existing user row to an IdP subject.
// Used by tests today; reserved for a future "link a local user to
// OIDC" flow.
func (s *Store) SetUserOIDCSubject(ctx context.Context, id, authSource, sub string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET auth_source = ?, oidc_subject = ? WHERE id = ?`,
authSource, sub, id)
if err != nil {
return fmt.Errorf("store: set oidc subject: %w", err)
}
return nil
}
// UserSort selects the column ListUsers orders by. OrderBy is // UserSort selects the column ListUsers orders by. OrderBy is
// allowlisted in usersOrderColumn so callers can't inject SQL via // allowlisted in usersOrderColumn so callers can't inject SQL via
// this field. Empty / unknown OrderBy falls back to "username". // this field. Empty / unknown OrderBy falls back to "username".
@@ -88,9 +119,8 @@ func (s *Store) ListUsers(ctx context.Context, sort UserSort) ([]User, error) {
// Default: username ASC (alphabetical), matching pre-sort behaviour. // Default: username ASC (alphabetical), matching pre-sort behaviour.
asc = true asc = true
} }
q := `SELECT id, username, password_hash, role, email, disabled_at, q := `SELECT ` + userSelectCols + ` FROM users ORDER BY ` +
must_change_password, created_at, last_login_at usersOrderColumn(sort.OrderBy, asc)
FROM users ORDER BY ` + usersOrderColumn(sort.OrderBy, asc)
rows, err := s.db.QueryContext(ctx, q) rows, err := s.db.QueryContext(ctx, q)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list users: %w", err) return nil, fmt.Errorf("store: list users: %w", err)
@@ -220,11 +250,13 @@ func (s *Store) SetPasswordHash(ctx context.Context, id, hash string) error {
func scanUser(scan func(...any) error) (*User, error) { func scanUser(scan func(...any) error) (*User, error) {
var u User var u User
var role string var role string
var email, disabledAt, lastLogin sql.NullString var email, disabledAt, oidcSub, lastLogin sql.NullString
var must int var must int
var authSource string
var created string var created string
if err := scan(&u.ID, &u.Username, &u.PasswordHash, &role, if err := scan(&u.ID, &u.Username, &u.PasswordHash, &role,
&email, &disabledAt, &must, &created, &lastLogin); err != nil { &email, &disabledAt, &must, &authSource, &oidcSub,
&created, &lastLogin); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -240,6 +272,11 @@ func scanUser(scan func(...any) error) (*User, error) {
u.DisabledAt = &t u.DisabledAt = &t
} }
u.MustChangePassword = must == 1 u.MustChangePassword = must == 1
u.AuthSource = authSource
if oidcSub.Valid {
v := oidcSub.String
u.OIDCSubject = &v
}
t, _ := time.Parse(time.RFC3339Nano, created) t, _ := time.Parse(time.RFC3339Nano, created)
u.CreatedAt = t u.CreatedAt = t
if lastLogin.Valid { if lastLogin.Valid {
+48
View File
@@ -165,6 +165,54 @@ func TestCreateUserLowercasesUsername(t *testing.T) {
} }
} }
func TestGetUserByOIDCSubject(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
sub := "sub-abc-123"
if err := s.CreateUser(ctx, User{
ID: "u1", Username: "alice", PasswordHash: "",
Role: RoleAdmin, CreatedAt: now,
AuthSource: "oidc", OIDCSubject: &sub,
}); err != nil {
t.Fatalf("create: %v", err)
}
got, err := s.GetUserByOIDCSubject(ctx, sub)
if err != nil {
t.Fatalf("get by sub: %v", err)
}
if got.ID != "u1" || got.AuthSource != "oidc" {
t.Errorf("unexpected: %+v", got)
}
if _, err := s.GetUserByOIDCSubject(ctx, "nope"); !errors.Is(err, ErrNotFound) {
t.Errorf("missing sub: want ErrNotFound, got %v", err)
}
}
func TestSetUserOIDCSubject(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
if err := s.CreateUser(ctx, User{
ID: "u1", Username: "alice", PasswordHash: "x",
Role: RoleAdmin, CreatedAt: now,
}); err != nil {
t.Fatalf("create: %v", err)
}
sub := "sub-456"
if err := s.SetUserOIDCSubject(ctx, "u1", "oidc", sub); err != nil {
t.Fatalf("set: %v", err)
}
got, _ := s.GetUserByID(ctx, "u1")
if got.AuthSource != "oidc" || got.OIDCSubject == nil || *got.OIDCSubject != sub {
t.Errorf("after set: %+v", got)
}
}
func TestEnrollmentTokenSingleUse(t *testing.T) { func TestEnrollmentTokenSingleUse(t *testing.T) {
t.Parallel() t.Parallel()
s := openTestStore(t) s := openTestStore(t)
+4 -1
View File
@@ -308,7 +308,10 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 days.
> **Schema:** migration 0017 adds `email`, `disabled_at`, `must_change_password` plus a UNIQUE INDEX on LOWER(username) (lowercase normalisation in Go on every CreateUser); 0018 adds `user_setup_tokens`. Both column-level ALTERs per CLAUDE.md preference. Email is metadata only in v1 (no SMTP-the-link); the SMTP channel infrastructure from P3-06 makes that a one-page follow-up. > **Schema:** migration 0017 adds `email`, `disabled_at`, `must_change_password` plus a UNIQUE INDEX on LOWER(username) (lowercase normalisation in Go on every CreateUser); 0018 adds `user_setup_tokens`. Both column-level ALTERs per CLAUDE.md preference. Email is metadata only in v1 (no SMTP-the-link); the SMTP channel infrastructure from P3-06 makes that a one-page follow-up.
> >
> **Sweep verified (smoke env):** admin adds operator → setup link generated → curl-as-new-user fetches /setup (200, page shows username) → POSTs password → 303 to / + Set-Cookie → operator authenticated → 200 on /, 200 on /settings/account, **403 on /settings/users** (admin-only) → admin disables user → operator's next request is **401** + session row count drops to 0 → audit log shows `user.created` + `user.setup_completed` for the cycle. All 26 implementation tasks landed; full `go test ./...` green. > **Sweep verified (smoke env):** admin adds operator → setup link generated → curl-as-new-user fetches /setup (200, page shows username) → POSTs password → 303 to / + Set-Cookie → operator authenticated → 200 on /, 200 on /settings/account, **403 on /settings/users** (admin-only) → admin disables user → operator's next request is **401** + session row count drops to 0 → audit log shows `user.created` + `user.setup_completed` for the cycle. All 26 implementation tasks landed; full `go test ./...` green.
- [ ] **P4-05** (L) OIDC login (generic provider config, group → role mapping) - [x] **P4-05** (L) OIDC login (generic provider config, group → role mapping)
> **As shipped (2026-05-05):** Authorization Code + PKCE (S256) against any OIDC IdP advertising standard discovery. Config is YAML+env (`oidc.issuer`, `oidc.client_id`, `oidc.client_secret`/`_file`, `oidc.role_claim` default `groups`, `oidc.role_mapping`, `oidc.display_name`, `oidc.redirect_url`); empty issuer → OIDC disabled, no routes mounted. Migration 0019 adds `users.auth_source`/`oidc_subject` (partial unique index on `oidc_subject`), `sessions.id_token`, and a small `oidc_state` table for state+verifier round-trip (cleaned up every alert tick, 5 min TTL). Login page renders **Sign in with `<display_name>`** above the local form when OIDC is enabled; the SSO button kicks off a 303 to the IdP with state + S256 code_challenge persisted server-side. Callback verifies ID token, fetches `/userinfo` to merge claims (Authelia / many IdPs only put `sub` in the ID token and surface `preferred_username`/`email`/`groups` from userinfo), maps the first matching group to a role; **no match → deny banner**, no row created, audit `user.oidc_login_blocked`. Username-collision with an existing local user → same deny path with `username_taken`. New user → JIT-provisioned with `auth_source='oidc'`, `oidc_subject=<sub>`, `password_hash=''`. Returning user → looked up by `oidc_subject` (stable when usernames change at the IdP), role + email refreshed on every login. Local password login is rejected for `auth_source='oidc'` users. Logout posts to `/logout` and, when the IdP advertised `end_session_endpoint`, follows up with RP-initiated logout (carries `id_token_hint` + `post_logout_redirect_uri=BaseURL`); when not advertised (Authelia in our smoke env), the local session is cleared and the browser lands on `/login`. Users list shows a small **oidc** chip beside enabled/disabled; the edit page disables username/email/role for OIDC users (server-side guard mirrors UI, returns 403). Force-logout, disable, and the last-admin guard from P4-04 all still apply. **Live Authelia sweep verified all four paths against `https://auth.dcglab.co.uk`:** rm-admin → admin role + JIT row + chip + readonly edit; 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 login resolved to the same row by sub. Screenshots in `_diag/p4-05-sweep/`. Out-of-scope and on Phase 6 candidate list: refresh tokens, back-channel logout, multiple providers, post-login PKCE for the cookie itself.
- [x] **P4-07** (S) Per-host tags + dashboard filtering by tag - [x] **P4-07** (S) Per-host tags + dashboard filtering by tag
> **As shipped (2026-05-05):** Tag column already existed on the hosts schema (JSON array, round-tripped through the Host struct since Phase 1) but had no edit UI or filter. Added `Store.SetHostTags` + `Store.DistinctHostTags` (the latter via `json_each` for autocomplete + chip-row population). Inline editor on the host detail header: `+ tag` button reveals a comma-separated input with `<datalist>` autocomplete from the fleet's distinct tags; submit lowercases / trims / dedupes server-side. Tag chips on the host header link to the dashboard pre-filtered. Dashboard chip-row above the hosts table — `All / <tag1> / <tag2> …` with the active chip highlighted via a new `.tag-active` style; `?tag=foo` filters the list with the count showing `N of M`. Operator-band POST `/hosts/{id}/tags` audited as `host.tags_updated`. > **As shipped (2026-05-05):** Tag column already existed on the hosts schema (JSON array, round-tripped through the Host struct since Phase 1) but had no edit UI or filter. Added `Store.SetHostTags` + `Store.DistinctHostTags` (the latter via `json_each` for autocomplete + chip-row population). Inline editor on the host detail header: `+ tag` button reveals a comma-separated input with `<datalist>` autocomplete from the fleet's distinct tags; submit lowercases / trims / dedupes server-side. Tag chips on the host header link to the dashboard pre-filtered. Dashboard chip-row above the hosts table — `All / <tag1> / <tag2> …` with the active chip highlighted via a new `.tag-active` style; `?tag=foo` filters the list with the count showing `N of M`. Operator-band POST `/hosts/{id}/tags` audited as `host.tags_updated`.
File diff suppressed because one or more lines are too long
+24 -1
View File
@@ -10,6 +10,18 @@
<h2 class="text-lg font-medium tracking-[-0.005em] text-center mb-7">Sign in to continue</h2> <h2 class="text-lg font-medium tracking-[-0.005em] text-center mb-7">Sign in to continue</h2>
{{if .OIDCError}}
<div class="panel rounded-[7px] p-4 mb-5"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
<div class="text-bad text-[12.5px]">
{{if eq .OIDCError "no_role_match"}}Your account does not match any role mapping. Contact your administrator.
{{else if eq .OIDCError "username_taken"}}A local account with the same username already exists. Contact your administrator.
{{else if eq .OIDCError "user_disabled"}}Your account has been disabled. Contact your administrator.
{{else}}Sign-in via SSO failed ({{.OIDCError}}). Try again or use a local account.{{end}}
</div>
</div>
{{end}}
{{if .Error}} {{if .Error}}
<div class="mb-4 px-3 py-2.5 rounded-[5px] text-xs" <div class="mb-4 px-3 py-2.5 rounded-[5px] text-xs"
style="background: color-mix(in oklch, var(--bad), transparent 88%); border: 1px solid color-mix(in oklch, var(--bad), transparent 70%); color: oklch(0.85 0.10 25);"> style="background: color-mix(in oklch, var(--bad), transparent 88%); border: 1px solid color-mix(in oklch, var(--bad), transparent 70%); color: oklch(0.85 0.10 25);">
@@ -17,6 +29,17 @@
</div> </div>
{{end}} {{end}}
{{if .OIDCEnabled}}
<a href="/auth/oidc/login" class="btn btn-primary btn-block btn-lg mb-4">
Sign in with {{.OIDCDisplayName}}
</a>
<div class="flex items-center gap-3 my-5 text-[11px] text-ink-fade uppercase tracking-[0.08em]">
<div class="flex-1 border-t border-line-soft"></div>
<span>or sign in with a local account</span>
<div class="flex-1 border-t border-line-soft"></div>
</div>
{{end}}
<form method="post" action="/login"> <form method="post" action="/login">
<div class="mb-3.5"> <div class="mb-3.5">
<label class="field-label" for="login-username">Username</label> <label class="field-label" for="login-username">Username</label>
@@ -33,7 +56,7 @@
<p class="text-pretty text-xs text-ink-mute leading-[1.65]"> <p class="text-pretty text-xs text-ink-mute leading-[1.65]">
Forgot your password? An admin can reset it from Forgot your password? An admin can reset it from
<span class="mono text-ink-mid">Settings → Users</span>. <span class="mono text-ink-mid">Settings → Users</span>.
Theres no recovery email — this is self-hosted infrastructure. There's no recovery email — this is self-hosted infrastructure.
</p> </p>
</div> </div>
</div> </div>
+20 -5
View File
@@ -67,9 +67,20 @@
{{end}} {{end}}
{{else}} {{else}}
{{/* new + edit form. */}} {{/* new + edit form. */}}
{{if and (eq $page.Mode "edit") (eq $page.AuthSource "oidc")}}
<div class="panel rounded-[7px] p-4 mb-5 mt-7"
style="border-color: color-mix(in oklch, var(--accent), transparent 60%);
background: color-mix(in oklch, var(--accent), transparent 95%);">
<div class="text-[12.5px] text-ink-mute leading-[1.6]">
This user is provisioned via OIDC. Username, role, and email are
managed by your IdP and refreshed on each sign-in. Disable /
Enable / Force logout still work locally.
</div>
</div>
{{end}}
<form method="post" <form method="post"
action="{{if eq $page.Mode "new"}}/settings/users/new{{else}}/settings/users/{{$page.ID}}/edit{{end}}" action="{{if eq $page.Mode "new"}}/settings/users/new{{else}}/settings/users/{{$page.ID}}/edit{{end}}"
class="panel mt-7 rounded-[7px] p-6 space-y-4"> class="panel rounded-[7px] p-6 space-y-4 {{if and (eq $page.Mode "edit") (eq $page.AuthSource "oidc")}}mt-3{{else}}mt-7{{end}}">
<div> <div>
<label class="field-label" for="username">Username</label> <label class="field-label" for="username">Username</label>
<input id="username" name="username" type="text" <input id="username" name="username" type="text"
@@ -82,11 +93,13 @@
<div> <div>
<label class="field-label" for="email">Email <span class="text-ink-fade font-normal">· optional</span></label> <label class="field-label" for="email">Email <span class="text-ink-fade font-normal">· optional</span></label>
<input id="email" name="email" type="email" class="field" <input id="email" name="email" type="email" class="field"
{{if and (eq $page.Mode "edit") (eq $page.AuthSource "oidc")}}readonly disabled{{end}}
value="{{$page.Email}}" autocomplete="off" /> value="{{$page.Email}}" autocomplete="off" />
</div> </div>
<div> <div>
<label class="field-label" for="role">Role</label> <label class="field-label" for="role">Role</label>
<select id="role" name="role" class="field"> <select id="role" name="role" class="field"
{{if and (eq $page.Mode "edit") (eq $page.AuthSource "oidc")}}disabled{{end}}>
<option value="admin" {{if eq $page.Role "admin"}}selected{{end}}>admin</option> <option value="admin" {{if eq $page.Role "admin"}}selected{{end}}>admin</option>
<option value="operator" {{if eq $page.Role "operator"}}selected{{end}}>operator</option> <option value="operator" {{if eq $page.Role "operator"}}selected{{end}}>operator</option>
<option value="viewer" {{if eq $page.Role "viewer"}}selected{{end}}>viewer</option> <option value="viewer" {{if eq $page.Role "viewer"}}selected{{end}}>viewer</option>
@@ -104,9 +117,11 @@
<div class="panel mt-5 rounded-[7px] p-6"> <div class="panel mt-5 rounded-[7px] p-6">
<div class="text-[12.5px] text-ink mb-3 font-medium">Other actions</div> <div class="text-[12.5px] text-ink mb-3 font-medium">Other actions</div>
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<form method="post" action="/settings/users/{{$page.ID}}/regenerate-setup"> {{if ne $page.AuthSource "oidc"}}
<button type="submit" class="btn">Regenerate setup link</button> <form method="post" action="/settings/users/{{$page.ID}}/regenerate-setup">
</form> <button type="submit" class="btn">Regenerate setup link</button>
</form>
{{end}}
<form method="post" action="/settings/users/{{$page.ID}}/force-logout"> <form method="post" action="/settings/users/{{$page.ID}}/force-logout">
<button type="submit" class="btn">Force logout</button> <button type="submit" class="btn">Force logout</button>
</form> </form>
+1
View File
@@ -67,6 +67,7 @@
{{if .Disabled}}<span class="tag" style="color: var(--ink-fade);">disabled</span> {{if .Disabled}}<span class="tag" style="color: var(--ink-fade);">disabled</span>
{{else if .MustChangePassword}}<span class="tag" style="color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">setup pending</span> {{else if .MustChangePassword}}<span class="tag" style="color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">setup pending</span>
{{else}}<span class="tag" style="color: var(--ok);">enabled</span>{{end}} {{else}}<span class="tag" style="color: var(--ok);">enabled</span>{{end}}
{{if eq .AuthSource "oidc"}}<span class="tag" style="color: var(--accent); border-color: color-mix(in oklch, var(--accent), transparent 60%); background: color-mix(in oklch, var(--accent), transparent 92%); margin-left: 4px;">oidc</span>{{end}}
</div> </div>
<div class="text-right"> <div class="text-right">
<a href="/settings/users/{{.ID}}/edit" class="btn">Edit</a> <a href="/settings/users/{{.ID}}/edit" class="btn">Edit</a>