diff --git a/CHANGELOG.md b/CHANGELOG.md index a1caf50..914dc92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versi ### Added - **Scheduled off-host database backups** ([issue #104](https://github.com/X4Applegate/caddyui/issues/104)): under **Settings → Backup**, enable automatic SQLite snapshots (`VACUUM INTO`) written to a directory on an interval, keeping the newest N. Point the directory at a mounted volume, network share, or object-store gateway to keep copies off the host. A **Back up now** button runs one on demand. (MariaDB installs continue to use their platform's own backup tooling.) +- **OIDC / SSO login** ([issue #106](https://github.com/X4Applegate/caddyui/issues/106)): sign in to CaddyUI through an external identity provider (Authelia, Authentik, Keycloak, Google, …) alongside local password + TOTP — configure it under **Settings → Security**. Uses the standard auth-code flow with `state` + `nonce`, and delegates ID-token verification (JWKS, signature, `iss`/`aud`/`exp`, nonce) to the vetted `go-oidc` library. A verified email is required; it matches an existing CaddyUI user by email, or — opt-in — provisions a read-only account on first sign-in. Local login always remains available. ## [2.51.0] - 2026-09-17 - Per-host rate limiting diff --git a/README.md b/README.md index e31f82c..daaec35 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ run CaddyUI directly in an LXC, VM, or bare-metal host. ### Analytics, certificates and local services in v2.39 – v2.52 +- **OIDC / SSO login** *(v2.52.0)* — sign in through an external identity provider (Authelia, Authentik, Keycloak, Google, …) alongside local password + TOTP; configure under Settings → Security. Standard auth-code flow with state/nonce and `go-oidc` token verification; verified-email matching with opt-in auto-provisioning. - **Scheduled off-host backups** *(v2.52.0)* — automatic SQLite snapshots to a directory on an interval (keep the newest N), plus a **Back up now** button, under Settings → Backup. Point the directory at a mounted volume or share to keep copies off the host. - **Per-host rate limiting** *(v2.51.0)* — cap a host at *N requests per M seconds, per client IP* (429 on excess), via the `caddy-ratelimit` module now built into `Dockerfile.caddy`. Rebuild your custom Caddy image to use it. - **Block an IP from analytics** *(v2.50.0)* — a visitor's drill-down page gains one-click **Block on this host** and **Block everywhere (fleet-wide)** actions; the global blocklist is managed under Settings → Security. Turns "who's probing me?" into a one-click 403. diff --git a/go.mod b/go.mod index 8080eb3..4cca4d9 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,12 @@ require ( github.com/aws/aws-sdk-go-v2 v1.47.0 github.com/aws/aws-sdk-go-v2/credentials v1.20.5 github.com/aws/aws-sdk-go-v2/service/route53 v1.70.0 + github.com/coreos/go-oidc/v3 v3.21.0 github.com/go-chi/chi/v5 v5.3.2 github.com/go-sql-driver/mysql v1.10.1 github.com/pquerna/otp v1.5.0 golang.org/x/crypto v0.57.0 + golang.org/x/oauth2 v0.37.0 modernc.org/sqlite v1.59.0 ) @@ -20,6 +22,7 @@ require ( github.com/aws/smithy-go v1.28.1 // indirect github.com/boombuler/barcode v1.0.2 // 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.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index d8323a0..7b1c6f1 100644 --- a/go.sum +++ b/go.sum @@ -15,12 +15,16 @@ github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqx github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +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/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= @@ -46,6 +50,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= diff --git a/internal/server/oidc.go b/internal/server/oidc.go new file mode 100644 index 0000000..a036b62 --- /dev/null +++ b/internal/server/oidc.go @@ -0,0 +1,275 @@ +package server + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/X4Applegate/caddyui/internal/auth" + "github.com/X4Applegate/caddyui/internal/models" +) + +// Optional OIDC / SSO login (issue #106). CaddyUI can accept sign-ins from an +// external identity provider (Authelia, Authentik, Keycloak, Google, …) in +// addition to local password + TOTP. ID-token verification (JWKS, signature, +// iss/aud/exp, nonce) is delegated to the vetted go-oidc library rather than +// hand-rolled. Local login always remains available. +const ( + settingOIDCEnabled = "oidc_enabled" + settingOIDCIssuer = "oidc_issuer" + settingOIDCClientID = "oidc_client_id" + settingOIDCClientSecret = "oidc_client_secret" + settingOIDCRedirectURL = "oidc_redirect_url" + settingOIDCAutoCreate = "oidc_auto_create" + settingOIDCButtonLabel = "oidc_button_label" + + oidcStateCookie = "caddyui_oidc_state" + oidcNonceCookie = "caddyui_oidc_nonce" + oidcDefaultButtonLabel = "Sign in with SSO" +) + +type oidcConfig struct { + Enabled bool + Issuer string + ClientID string + ClientSecret string + RedirectURL string + AutoCreate bool + ButtonLabel string +} + +func (s *Server) oidcConfig() oidcConfig { + get := func(k string) string { return strings.TrimSpace(mustGetSetting(s.DB, k)) } + label := get(settingOIDCButtonLabel) + if label == "" { + label = oidcDefaultButtonLabel + } + return oidcConfig{ + Enabled: mustGetSetting(s.DB, settingOIDCEnabled) == "1", + Issuer: get(settingOIDCIssuer), + ClientID: get(settingOIDCClientID), + ClientSecret: strings.TrimSpace(mustGetSetting(s.DB, settingOIDCClientSecret)), + RedirectURL: get(settingOIDCRedirectURL), + AutoCreate: mustGetSetting(s.DB, settingOIDCAutoCreate) == "1", + ButtonLabel: label, + } +} + +// ready reports whether SSO is enabled and has the minimum configuration to run. +func (c oidcConfig) ready() bool { + return c.Enabled && c.Issuer != "" && c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != "" +} + +// oidcProviderCache memoises the discovery document per issuer so each login +// doesn't re-fetch /.well-known/openid-configuration. +var oidcProviderCache sync.Map // issuer -> *oidc.Provider + +func oidcProviderFor(ctx context.Context, issuer string) (*oidc.Provider, error) { + if v, ok := oidcProviderCache.Load(issuer); ok { + return v.(*oidc.Provider), nil + } + p, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, err + } + oidcProviderCache.Store(issuer, p) + return p, nil +} + +func (s *Server) oidcClients(ctx context.Context, cfg oidcConfig) (*oauth2.Config, *oidc.IDTokenVerifier, error) { + provider, err := oidcProviderFor(ctx, cfg.Issuer) + if err != nil { + return nil, nil, err + } + oauth2Cfg := &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "email", "profile"}, + } + verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) + return oauth2Cfg, verifier, nil +} + +func oidcRandom() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func oidcSecure(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func setOIDCFlowCookie(w http.ResponseWriter, r *http.Request, name, value string) { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: value, Path: "/", MaxAge: 600, + HttpOnly: true, Secure: oidcSecure(r), SameSite: http.SameSiteLaxMode, + }) +} + +func clearOIDCFlowCookie(w http.ResponseWriter, r *http.Request, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: oidcSecure(r), SameSite: http.SameSiteLaxMode, + }) +} + +// getOIDCLogin starts the auth-code flow: mint state + nonce, stash them in +// short-lived cookies, and redirect to the provider. +func (s *Server) getOIDCLogin(w http.ResponseWriter, r *http.Request) { + cfg := s.oidcConfig() + if !cfg.ready() { + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + oauth2Cfg, _, err := s.oidcClients(r.Context(), cfg) + if err != nil { + log.Printf("oidc: provider init: %v", err) + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + state, nonce := oidcRandom(), oidcRandom() + setOIDCFlowCookie(w, r, oidcStateCookie, state) + setOIDCFlowCookie(w, r, oidcNonceCookie, nonce) + http.Redirect(w, r, oauth2Cfg.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusSeeOther) +} + +// getOIDCCallback completes the flow: verify state, exchange the code, verify +// the ID token + nonce, then map the email to a CaddyUI user and sign in. +func (s *Server) getOIDCCallback(w http.ResponseWriter, r *http.Request) { + cfg := s.oidcConfig() + if !cfg.ready() { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + // One-time flow cookies — clear them regardless of outcome. + stateCookie, _ := r.Cookie(oidcStateCookie) + nonceCookie, _ := r.Cookie(oidcNonceCookie) + clearOIDCFlowCookie(w, r, oidcStateCookie) + clearOIDCFlowCookie(w, r, oidcNonceCookie) + + if errParam := r.URL.Query().Get("error"); errParam != "" { + log.Printf("oidc: provider returned error: %s", errParam) + http.Redirect(w, r, "/login?error=sso_denied", http.StatusSeeOther) + return + } + if stateCookie == nil || stateCookie.Value == "" || r.URL.Query().Get("state") != stateCookie.Value { + http.Redirect(w, r, "/login?error=sso_state", http.StatusSeeOther) + return + } + + ctx := r.Context() + oauth2Cfg, verifier, err := s.oidcClients(ctx, cfg) + if err != nil { + log.Printf("oidc: provider init: %v", err) + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + token, err := oauth2Cfg.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + log.Printf("oidc: token exchange: %v", err) + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + log.Printf("oidc: id token verify: %v", err) + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + if nonceCookie == nil || nonceCookie.Value == "" || idToken.Nonce != nonceCookie.Value { + http.Redirect(w, r, "/login?error=sso_nonce", http.StatusSeeOther) + return + } + + var claims struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + } + if err := idToken.Claims(&claims); err != nil { + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + email := strings.ToLower(strings.TrimSpace(claims.Email)) + if email == "" || !claims.EmailVerified { + // An unverified (or missing) email must not be trusted to match or + // create an account — it would allow impersonation on IdPs that let a + // user set an arbitrary address. + log.Printf("oidc: rejecting sign-in: email empty or unverified (%q verified=%v)", email, claims.EmailVerified) + http.Redirect(w, r, "/login?error=sso_email", http.StatusSeeOther) + return + } + + u, err := models.GetUserByEmail(s.DB, email) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + // A real DB failure must not be mistaken for "no such user" (which + // would wrongly deny — or, with auto-create on, try to provision). + log.Printf("oidc: lookup %s: %v", email, err) + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + if u == nil { + if !cfg.AutoCreate { + log.Printf("oidc: no CaddyUI account for %s and auto-create is off", email) + http.Redirect(w, r, "/login?error=sso_nouser", http.StatusSeeOther) + return + } + name := strings.TrimSpace(claims.Name) + if name == "" { + name = email + } + // New SSO users get the read-only role and an unusable password (a + // random non-bcrypt string), so they can't password-log-in. Promote + // them under Users if they need more. + if _, cerr := models.CreateUser(s.DB, email, oidcRandom(), name, models.RoleView); cerr != nil { + log.Printf("oidc: provision %s: %v", email, cerr) + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + if u, err = models.GetUserByEmail(s.DB, email); err != nil || u == nil { + http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther) + return + } + } + + // Honour a user's local TOTP as a second factor even over SSO — otherwise + // enabling SSO would silently downgrade every TOTP-protected account. Route + // through the same pending-TOTP challenge that local login uses. + if u.TOTPEnabled && u.TOTPSecret != "" { + tok := oidcRandom() + s.pendingTOTP.Store(tok, u.ID) + go func() { + time.Sleep(5 * time.Minute) + s.pendingTOTP.Delete(tok) + }() + http.Redirect(w, r, "/login/totp?t="+tok, http.StatusSeeOther) + return + } + + tok, exp, err := auth.CreateSessionWithTTL(s.DB, u.ID, s.sessionTTL()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + auth.SetSessionCookie(w, r, tok, exp) + _ = models.LogActivity(s.DB, 0, u.Email, "login_success_sso", "ip:"+clientIPFromRequest(r), r.UserAgent(), true) + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/internal/server/oidc_test.go b/internal/server/oidc_test.go new file mode 100644 index 0000000..c24e7ab --- /dev/null +++ b/internal/server/oidc_test.go @@ -0,0 +1,45 @@ +package server + +import ( + "strings" + "testing" +) + +func TestOIDCConfigReady(t *testing.T) { + full := oidcConfig{ + Enabled: true, Issuer: "https://sso.example.com", ClientID: "cid", + ClientSecret: "secret", RedirectURL: "https://caddyui.example.com/auth/oidc/callback", + } + if !full.ready() { + t.Fatal("fully-configured OIDC should be ready") + } + // Each missing required field makes it not ready. + cases := map[string]func(c *oidcConfig){ + "disabled": func(c *oidcConfig) { c.Enabled = false }, + "no issuer": func(c *oidcConfig) { c.Issuer = "" }, + "no clientID": func(c *oidcConfig) { c.ClientID = "" }, + "no secret": func(c *oidcConfig) { c.ClientSecret = "" }, + "no redirect": func(c *oidcConfig) { c.RedirectURL = "" }, + } + for name, mut := range cases { + c := full + mut(&c) + if c.ready() { + t.Fatalf("%s: expected not ready", name) + } + } +} + +func TestOIDCRandomIsDistinctURLSafe(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 100; i++ { + v := oidcRandom() + if v == "" || seen[v] { + t.Fatalf("oidcRandom collision or empty: %q", v) + } + if strings.ContainsAny(v, "+/=") { + t.Fatalf("oidcRandom not URL-safe: %q", v) + } + seen[v] = true + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 4132c5a..2e7d07f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -518,6 +518,8 @@ func (s *Server) Routes() http.Handler { r.Get("/login", s.getLogin) r.Post("/login", s.postLogin) r.Post("/logout", s.postLogout) + r.Get("/auth/oidc/login", s.getOIDCLogin) // issue #106 (SSO) + r.Get("/auth/oidc/callback", s.getOIDCCallback) // issue #106 (SSO) r.Get("/login/totp", s.getTOTPVerify) r.Post("/login/totp", s.postTOTPVerify) r.Get("/forgot-password", s.getForgotPassword) @@ -1912,6 +1914,24 @@ func (s *Server) getLogin(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("invited") == "1" { data["Invited"] = true } + // issue #106: offer the SSO button when OIDC is configured; surface any + // SSO error the callback bounced back with a friendly message. + if oc := s.oidcConfig(); oc.ready() { + data["OIDCEnabled"] = true + data["OIDCButtonLabel"] = oc.ButtonLabel + } + if e := r.URL.Query().Get("error"); strings.HasPrefix(e, "sso") { + msg := "Single sign-on failed. Try again or use your password." + switch e { + case "sso_nouser": + msg = "No CaddyUI account matches your SSO identity. Ask an admin to add you." + case "sso_email": + msg = "Your identity provider didn't return a verified email address." + case "sso_denied": + msg = "Single sign-on was cancelled." + } + data["Error"] = msg + } s.render(w, r, "login.html", data) } @@ -14632,8 +14652,17 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { "BackupScheduleKeep": mustGetSetting(s.DB, settingBackupScheduleKeep), "BackupOK": r.URL.Query().Get("backupok"), "BackupErr": r.URL.Query().Get("backuperr"), - "Success": success, - "ClearedName": clearedName, + // issue #106: OIDC / SSO. The client secret is never rendered back — a + // bool tells the template whether one is already stored. + "OIDCEnabled": mustGetSetting(s.DB, settingOIDCEnabled) == "1", + "OIDCIssuer": mustGetSetting(s.DB, settingOIDCIssuer), + "OIDCClientID": mustGetSetting(s.DB, settingOIDCClientID), + "OIDCClientSecretSet": strings.TrimSpace(mustGetSetting(s.DB, settingOIDCClientSecret)) != "", + "OIDCRedirectURL": mustGetSetting(s.DB, settingOIDCRedirectURL), + "OIDCAutoCreate": mustGetSetting(s.DB, settingOIDCAutoCreate) == "1", + "OIDCButtonLabel": mustGetSetting(s.DB, settingOIDCButtonLabel), + "Success": success, + "ClearedName": clearedName, // v2.7.0: analytics card "AnalyticsEnabled": analyticsCfg.Enabled, "ExpectationsAutoRollback": expectationsAutoRollbackEnabled(s), // v2.38.0 @@ -14895,6 +14924,14 @@ func (s *Server) postSettings(w http.ResponseWriter, r *http.Request) { if r.FormValue("backup_schedule_enabled") == "on" { backupSchedEnabled = "1" } + oidcEnabled := "0" + if r.FormValue("oidc_enabled") == "on" { + oidcEnabled = "1" + } + oidcAutoCreate := "0" + if r.FormValue("oidc_auto_create") == "on" { + oidcAutoCreate = "1" + } kv := map[string]string{ settingDNSVerifyResolver: strings.TrimSpace(r.FormValue("dns_verify_resolver")), // issue #98 settingGlobalIPBlocklist: strings.TrimSpace(r.FormValue("global_ip_blocklist")), // issue #100 @@ -14903,11 +14940,18 @@ func (s *Server) postSettings(w http.ResponseWriter, r *http.Request) { settingBackupScheduleDir: strings.TrimSpace(r.FormValue("backup_schedule_dir")), settingBackupScheduleInterval: strings.TrimSpace(r.FormValue("backup_schedule_interval_hours")), settingBackupScheduleKeep: strings.TrimSpace(r.FormValue("backup_schedule_keep")), - settingNotifyWebhookURL: webhookURL, - settingNotifyWebhookSecret: webhookSecret, - settingNotifyNtfyURL: ntfyURL, // v2.12.51 - settingNotifyDaysBefore: strconv.Itoa(daysBefore), - settingSMTPHost: smtpHost, + // issue #106: OIDC / SSO login (client secret handled below, keep-blank). + settingOIDCEnabled: oidcEnabled, + settingOIDCIssuer: strings.TrimSpace(r.FormValue("oidc_issuer")), + settingOIDCClientID: strings.TrimSpace(r.FormValue("oidc_client_id")), + settingOIDCRedirectURL: strings.TrimSpace(r.FormValue("oidc_redirect_url")), + settingOIDCAutoCreate: oidcAutoCreate, + settingOIDCButtonLabel: strings.TrimSpace(r.FormValue("oidc_button_label")), + settingNotifyWebhookURL: webhookURL, + settingNotifyWebhookSecret: webhookSecret, + settingNotifyNtfyURL: ntfyURL, // v2.12.51 + settingNotifyDaysBefore: strconv.Itoa(daysBefore), + settingSMTPHost: smtpHost, // v2.11.15: AI assistant settings. settingAIEnabled: func() string { for _, v := range r.PostForm["ai_enabled"] { @@ -15084,6 +15128,10 @@ func (s *Server) postSettings(w http.ResponseWriter, r *http.Request) { if smtpPassword != "" { kv[settingSMTPPassword] = smtpPassword } + // issue #106: OIDC client secret — keep-blank-to-preserve, like other secrets. + if v := r.FormValue("oidc_client_secret"); strings.TrimSpace(v) != "" { + kv[settingOIDCClientSecret] = strings.TrimSpace(v) + } // v2.27.0: captcha secret keys — same keep-blank-to-preserve pattern. The // Settings template no longer renders them into the form, so a blank field // means "keep the stored value", not "clear it". To actually clear a key, diff --git a/internal/server/settings_sections.go b/internal/server/settings_sections.go index 7b22470..722a226 100644 --- a/internal/server/settings_sections.go +++ b/internal/server/settings_sections.go @@ -117,6 +117,13 @@ var settingsKeySection = map[string]string{ settingDisableHTTP3: "security", settingAdminAllowlist: "security", settingGlobalIPBlocklist: "security", + settingOIDCEnabled: "security", + settingOIDCIssuer: "security", + settingOIDCClientID: "security", + settingOIDCClientSecret: "security", + settingOIDCRedirectURL: "security", + settingOIDCAutoCreate: "security", + settingOIDCButtonLabel: "security", settingSessionDays: "security", settingMaxLoginAttempts: "security", settingCaptchaProvider: "security", diff --git a/web/templates/login.html b/web/templates/login.html index 0d4575a..ae2464c 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -37,6 +37,17 @@
Let admins sign in through an external identity provider (Authelia, Authentik, Keycloak, Google, …) alongside local password + TOTP. Register CaddyUI as a confidential client and set its redirect URL to the callback below.
+