P4-05: OIDC login (generic, JIT-provisioned) #16
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -308,7 +308,10 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 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
@@ -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>.
|
||||||
There’s no recovery email — this is self-hosted infrastructure.
|
There's no recovery email — this is self-hosted infrastructure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user