Add central login recovery and confirmed shared sign-out
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 44s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-12 10:34:41 +02:00
parent 89694ad6df
commit 074c2ce498
9 changed files with 334 additions and 49 deletions

View file

@ -0,0 +1,17 @@
package config
import (
"strings"
"testing"
)
func TestAccountRecoveryDestinationsAreOwnerConfiguredHTTPS(t *testing.T) {
for _, raw := range []string{"http://users.example", "https://user:password@users.example", "https://users.example/?next=evil", "https://users.example/#fragment", "//users.example"} {
cfg:=&Config{AccountPortalURL:raw,BrowserLogoutURL:"https://auth.example/logout"}
found:=false
for _, err:=range ValidateConfig(cfg) {if strings.HasPrefix(err,"accountPortalURL:") {found=true}}
if !found {t.Errorf("accepted unsafe account URL %q",raw)}
}
cfg:=&Config{AccountPortalURL:"https://users.example"}
found:=false
for _, err:=range ValidateConfig(cfg) {if strings.Contains(err,"configured together") {found=true}}
if !found {t.Fatal("accepted incomplete logout route")}
}

View file

@ -18,16 +18,18 @@ import (
// Config is the top-level server configuration.
type Config struct {
Issuer string `yaml:"issuer"`
Port int `yaml:"port"`
TokenLifetime string `yaml:"tokenLifetime"`
PrivateKeyPEM string `yaml:"privateKeyPem"`
LLDAP lldap.Config `yaml:"lldap"`
Authelia authelia.Config `yaml:"authelia"`
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
Clients []ClientConfig `yaml:"clients"`
Environment string `yaml:"environment"`
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"`
AccountPortalURL string `yaml:"accountPortalURL,omitempty"`
BrowserLogoutURL string `yaml:"browserLogoutURL,omitempty"`
Issuer string `yaml:"issuer"`
Port int `yaml:"port"`
TokenLifetime string `yaml:"tokenLifetime"`
PrivateKeyPEM string `yaml:"privateKeyPem"`
LLDAP lldap.Config `yaml:"lldap"`
Authelia authelia.Config `yaml:"authelia"`
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
Clients []ClientConfig `yaml:"clients"`
Environment string `yaml:"environment"`
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"`
}
// TenantEngineConfig configures the optional tenant_roles cache claim.
@ -85,6 +87,12 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("config: parse %q: %w", path, err)
}
if value := os.Getenv("KEYCAPE_ACCOUNT_PORTAL_URL"); value != "" {
cfg.AccountPortalURL = value
}
if value := os.Getenv("KEYCAPE_BROWSER_LOGOUT_URL"); value != "" {
cfg.BrowserLogoutURL = value
}
return &cfg, nil
}

View file

@ -12,6 +12,18 @@ import (
// Called at startup — the server must exit 1 if any errors are returned.
func ValidateConfig(cfg *Config) []string {
var errs []string
for name, raw := range map[string]string{"accountPortalURL": cfg.AccountPortalURL, "browserLogoutURL": cfg.BrowserLogoutURL} {
if raw == "" {
continue
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
errs = append(errs, name+": must be an HTTPS URL without credentials, query or fragment")
}
}
if (cfg.AccountPortalURL == "") != (cfg.BrowserLogoutURL == "") {
errs = append(errs, "accountPortalURL and browserLogoutURL must be configured together")
}
// Issuer must be a valid URL with an http(s) scheme.
if cfg.Issuer == "" {