diff --git a/cmd/server/main.go b/cmd/server/main.go index cb3a207..a97022d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -19,6 +19,7 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" 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/oidc" "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/store" @@ -92,6 +93,17 @@ func run() error { 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{ Cfg: cfg, Store: st, @@ -102,6 +114,7 @@ func run() error { NotificationHub: notifHub, UI: renderer, Version: version, + OIDC: oidcClient, } // First-run bootstrap: if the users table is empty, mint a one-time diff --git a/go.mod b/go.mod index b392766..4a2e12d 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,26 @@ module gitea.dcglab.co.uk/steve/restic-manager go 1.25.0 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/golang-jwt/jwt/v5 v5.3.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/oauth2 v0.36.0 + golang.org/x/sys v0.43.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.0 ) require ( - github.com/coder/websocket v1.8.14 // 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/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // 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/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 3fcf455..b48b8bf 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,15 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= 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/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/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/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/alert/engine.go b/internal/alert/engine.go index e0205b9..607ed91 100644 --- a/internal/alert/engine.go +++ b/internal/alert/engine.go @@ -193,6 +193,9 @@ func (e *Engine) tick(ctx context.Context, now time.Time) { if _, err := e.store.CleanupExpiredSetupTokens(ctx, now); err != nil { 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) if err != nil { diff --git a/internal/server/config/config.go b/internal/server/config/config.go index 0d883cf..3106775 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -30,7 +30,9 @@ type Config struct { // Defaults to true. Set RM_COOKIE_SECURE=false only for local HTTP // testing — production deployments are always behind a TLS proxy // 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: @@ -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() } diff --git a/internal/server/config/oidc.go b/internal/server/config/oidc.go new file mode 100644 index 0000000..5681037 --- /dev/null +++ b/internal/server/config/oidc.go @@ -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 +} diff --git a/internal/server/config/oidc_test.go b/internal/server/config/oidc_test.go new file mode 100644 index 0000000..ea01c17 --- /dev/null +++ b/internal/server/config/oidc_test.go @@ -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) + } +} diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index 508c6b4..af37ea8 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -56,6 +56,9 @@ func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Req // existence to a probing attacker. return nil, errInvalidCredentials } + if u.AuthSource == "oidc" { + return nil, errInvalidCredentials + } if err := auth.VerifyPassword(u.PasswordHash, password); err != nil { return nil, errInvalidCredentials } diff --git a/internal/server/http/oidc_handlers.go b/internal/server/http/oidc_handlers.go new file mode 100644 index 0000000..45763c8 --- /dev/null +++ b/internal/server/http/oidc_handlers.go @@ -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) +} diff --git a/internal/server/http/oidc_handlers_test.go b/internal/server/http/oidc_handlers_test.go new file mode 100644 index 0000000..de48ffd --- /dev/null +++ b/internal/server/http/oidc_handlers_test.go @@ -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) + } +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index ba7c51a..41048ea 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -17,6 +17,7 @@ import ( "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/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/ws" "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 // /bootstrap endpoint accepts it to create the first admin user. 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. @@ -133,13 +137,19 @@ func (s *Server) routes(r chi.Router) { r.Get("/ws/agent/pending", s.handlePendingWS) 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 { r.Get("/login", s.handleUILoginGet) r.Post("/login", s.handleUILoginPost) - r.Post("/logout", s.handleUILogoutPost) r.Get("/setup", s.handleUISetupGet) 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. r.Group(func(r chi.Router) { diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 734bada..798630e 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -8,6 +8,7 @@ import ( "io/fs" "log/slog" stdhttp "net/http" + "net/url" "strings" "time" @@ -921,7 +922,14 @@ func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) 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 { slog.Error("ui: render login", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) @@ -947,6 +955,10 @@ func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request) Username: username, Error: "Invalid username or password.", } + if s.deps.OIDC != nil { + view.OIDCEnabled = true + view.OIDCDisplayName = s.deps.OIDC.DisplayName() + } w.WriteHeader(stdhttp.StatusUnauthorized) if err := s.deps.UI.Render(w, "login", view); err != nil { 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) } -// handleUILogoutPost is the form-submit twin of /api/auth/logout. It -// drops the session cookie and redirects to /login. +// handleUILogoutPost is the form-submit twin of /api/auth/logout. For +// 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) { - if c, err := r.Cookie(sessionCookieName); err == nil { - _ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value)) + c, err := r.Cookie(sessionCookieName) + 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{ Name: sessionCookieName, Value: "", @@ -971,5 +1008,5 @@ func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request Secure: s.deps.Cfg.CookieSecure, SameSite: stdhttp.SameSiteLaxMode, }) - stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + stdhttp.Redirect(w, r, dest, stdhttp.StatusSeeOther) } diff --git a/internal/server/http/ui_users.go b/internal/server/http/ui_users.go index 08ee401..f9c1eec 100644 --- a/internal/server/http/ui_users.go +++ b/internal/server/http/ui_users.go @@ -51,6 +51,7 @@ type userRow struct { LastLoginAt string // pre-formatted "2006-01-02 15:04:05" or "never" Disabled bool MustChangePassword bool + AuthSource string } 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, Disabled: ux.DisabledAt != nil, MustChangePassword: ux.MustChangePassword, + AuthSource: ux.AuthSource, }) } @@ -157,7 +159,8 @@ type userFormPage struct { // to add a username that already exists (disabled). Triggers a // banner on the edit page explaining why and steering them at // 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) { @@ -294,8 +297,9 @@ func (s *Server) handleUIUserEditGet(w stdhttp.ResponseWriter, r *stdhttp.Reques view.Page = userFormPage{ Mode: "edit", ID: target.ID, Username: target.Username, Email: em, Role: string(target.Role), - Disabled: target.DisabledAt != nil, - Reenable: r.URL.Query().Get("reenable") == "1", + Disabled: target.DisabledAt != nil, + Reenable: r.URL.Query().Get("reenable") == "1", + AuthSource: target.AuthSource, } _ = 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) 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")) if !ok { stdhttp.Error(w, "bad role", stdhttp.StatusBadRequest) diff --git a/internal/server/oidc/oidc.go b/internal/server/oidc/oidc.go new file mode 100644 index 0000000..7643678 --- /dev/null +++ b/internal/server/oidc/oidc.go @@ -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) +} diff --git a/internal/server/oidc/oidc_test.go b/internal/server/oidc/oidc_test.go new file mode 100644 index 0000000..509feec --- /dev/null +++ b/internal/server/oidc/oidc_test.go @@ -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") + } +} diff --git a/internal/server/oidc/oidctest/stub.go b/internal/server/oidc/oidctest/stub.go new file mode 100644 index 0000000..e1e4b95 --- /dev/null +++ b/internal/server/oidc/oidctest/stub.go @@ -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 +} diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go index 68f0e2e..3b3c446 100644 --- a/internal/server/ui/ui.go +++ b/internal/server/ui/ui.go @@ -56,6 +56,19 @@ type ViewData struct { // today; other pages can adopt the same field. 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 // own struct. Page any diff --git a/internal/store/migrations/0019_oidc.sql b/internal/store/migrations/0019_oidc.sql new file mode 100644 index 0000000..1f6cea6 --- /dev/null +++ b/internal/store/migrations/0019_oidc.sql @@ -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); diff --git a/internal/store/oidc_state.go b/internal/store/oidc_state.go new file mode 100644 index 0000000..cb6a5c9 --- /dev/null +++ b/internal/store/oidc_state.go @@ -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 +} diff --git a/internal/store/oidc_state_test.go b/internal/store/oidc_state_test.go new file mode 100644 index 0000000..a28b176 --- /dev/null +++ b/internal/store/oidc_state_test.go @@ -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) + } +} diff --git a/internal/store/sessions.go b/internal/store/sessions.go index a2ef31c..b02e90d 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -12,13 +12,14 @@ import ( // 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 { _, err := s.db.ExecContext(ctx, - `INSERT INTO sessions (id, user_id, created_at, expires_at, ip, ua) - VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO sessions (id, user_id, created_at, expires_at, ip, ua, id_token) + VALUES (?, ?, ?, ?, ?, ?, ?)`, tokenHash, sess.UserID, sess.CreatedAt.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 { 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. func (s *Store) LookupSession(ctx context.Context, tokenHash string) (*Session, error) { 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 WHERE id = ? AND expires_at > ?`, tokenHash, time.Now().UTC().Format(time.RFC3339Nano)) var sess Session var created, expires string - var ip, ua sql.NullString - if err := row.Scan(&sess.ID, &sess.UserID, &created, &expires, &ip, &ua); err != nil { + var ip, ua, idTok sql.NullString + if err := row.Scan(&sess.ID, &sess.UserID, &created, &expires, &ip, &ua, &idTok); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -62,6 +63,9 @@ func (s *Store) LookupSession(ctx context.Context, tokenHash string) (*Session, if ua.Valid { sess.UA = ua.String } + if idTok.Valid { + sess.IDToken = idTok.String + } return &sess, nil } diff --git a/internal/store/sessions_test.go b/internal/store/sessions_test.go index 81222ee..0dcd553 100644 --- a/internal/store/sessions_test.go +++ b/internal/store/sessions_test.go @@ -43,3 +43,34 @@ func TestDeleteSessionsByUserID(t *testing.T) { 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) + } +} diff --git a/internal/store/types.go b/internal/store/types.go index 88758e4..0a69dee 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -16,8 +16,18 @@ type User struct { Email *string // optional; nil = not set DisabledAt *time.Time // nil = enabled MustChangePassword bool - CreatedAt time.Time - LastLoginAt *time.Time + // AuthSource is "local" (created by admin or bootstrap) or + // "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. @@ -40,6 +50,10 @@ type Session struct { ExpiresAt time.Time IP 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 diff --git a/internal/store/users.go b/internal/store/users.go index f414e92..ed0ddb6 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -18,12 +18,18 @@ func (s *Store) CreateUser(ctx context.Context, u User) error { if u.MustChangePassword { must = 1 } + authSource := u.AuthSource + if authSource == "" { + authSource = "local" + } _, err := s.db.ExecContext(ctx, `INSERT INTO users (id, username, password_hash, role, email, - must_change_password, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + must_change_password, auth_source, + oidc_subject, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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)) if err != nil { return fmt.Errorf("store: create user: %w", err) @@ -31,24 +37,49 @@ func (s *Store) CreateUser(ctx context.Context, u User) error { 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. func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { row := s.db.QueryRowContext(ctx, - `SELECT id, username, password_hash, role, email, disabled_at, - must_change_password, created_at, last_login_at - FROM users WHERE LOWER(username) = LOWER(?)`, username) + `SELECT `+userSelectCols+` FROM users WHERE LOWER(username) = LOWER(?)`, + username) return scanUser(row.Scan) } // GetUserByID looks up a user by id. Returns ErrNotFound on miss. func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { row := s.db.QueryRowContext(ctx, - `SELECT id, username, password_hash, role, email, disabled_at, - must_change_password, created_at, last_login_at - FROM users WHERE id = ?`, id) + `SELECT `+userSelectCols+` FROM users WHERE id = ?`, id) 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 // allowlisted in usersOrderColumn so callers can't inject SQL via // 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. asc = true } - q := `SELECT id, username, password_hash, role, email, disabled_at, - must_change_password, created_at, last_login_at - FROM users ORDER BY ` + usersOrderColumn(sort.OrderBy, asc) + q := `SELECT ` + userSelectCols + ` FROM users ORDER BY ` + + usersOrderColumn(sort.OrderBy, asc) rows, err := s.db.QueryContext(ctx, q) if err != nil { 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) { var u User var role string - var email, disabledAt, lastLogin sql.NullString + var email, disabledAt, oidcSub, lastLogin sql.NullString var must int + var authSource string var created string 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) { return nil, ErrNotFound } @@ -240,6 +272,11 @@ func scanUser(scan func(...any) error) (*User, error) { u.DisabledAt = &t } u.MustChangePassword = must == 1 + u.AuthSource = authSource + if oidcSub.Valid { + v := oidcSub.String + u.OIDCSubject = &v + } t, _ := time.Parse(time.RFC3339Nano, created) u.CreatedAt = t if lastLogin.Valid { diff --git a/internal/store/users_test.go b/internal/store/users_test.go index a7684a9..ce4679b 100644 --- a/internal/store/users_test.go +++ b/internal/store/users_test.go @@ -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) { t.Parallel() s := openTestStore(t) diff --git a/tasks.md b/tasks.md index 106114b..f251338 100644 --- a/tasks.md +++ b/tasks.md @@ -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. > > **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 ``** 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=`, `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.example.invalid`:** 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 > **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 `` 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 / / …` 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`. diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 57a1fa8..80fd24d 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag.tag-active{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%);color:var(--accent)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header,.user-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover,.user-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph,.user-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag.tag-active{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%);color:var(--accent)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header,.user-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover,.user-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph,.user-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} diff --git a/web/templates/pages/login.html b/web/templates/pages/login.html index 0e8abf2..83a672c 100644 --- a/web/templates/pages/login.html +++ b/web/templates/pages/login.html @@ -10,6 +10,18 @@

Sign in to continue

+ {{if .OIDCError}} +
+
+ {{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}} +
+
+ {{end}} + {{if .Error}}
@@ -17,6 +29,17 @@
{{end}} + {{if .OIDCEnabled}} + + Sign in with {{.OIDCDisplayName}} + +
+
+ or sign in with a local account +
+
+ {{end}} +
@@ -33,7 +56,7 @@

Forgot your password? An admin can reset it from Settings → Users. - There’s no recovery email — this is self-hosted infrastructure. + There's no recovery email — this is self-hosted infrastructure.

diff --git a/web/templates/pages/user_edit.html b/web/templates/pages/user_edit.html index 4014fad..9e8cd94 100644 --- a/web/templates/pages/user_edit.html +++ b/web/templates/pages/user_edit.html @@ -67,9 +67,20 @@ {{end}} {{else}} {{/* new + edit form. */}} + {{if and (eq $page.Mode "edit") (eq $page.AuthSource "oidc")}} +
+
+ 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. +
+
+ {{end}} + 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}}">
- @@ -104,9 +117,11 @@
Other actions
- - - + {{if ne $page.AuthSource "oidc"}} +
+ +
+ {{end}}
diff --git a/web/templates/pages/users.html b/web/templates/pages/users.html index 6b411b4..6022c55 100644 --- a/web/templates/pages/users.html +++ b/web/templates/pages/users.html @@ -67,6 +67,7 @@ {{if .Disabled}}disabled {{else if .MustChangePassword}}setup pending {{else}}enabled{{end}} + {{if eq .AuthSource "oidc"}}oidc{{end}}