Finish KEY-WP-0008: registration handoff and client MFA isolation
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s

Add signed registration/enrollment handoffs, per-request assurance
policy with login-session isolation, and /logout. coulomb-social
stays AAL1 unless acr_values or another client raises the bar.
This commit is contained in:
tegwick 2026-08-16 01:05:27 +02:00
parent fff9e39478
commit b6af6c5268
22 changed files with 1636 additions and 42 deletions

View file

@ -38,8 +38,10 @@ type ClientConfig struct {
SecretRef string `yaml:"secretRef,omitempty"`
ServiceSubject string `yaml:"serviceSubject,omitempty"`
Tenant string `yaml:"tenant,omitempty"`
Roles []string `yaml:"roles,omitempty"`
MFARequired *bool `yaml:"mfaRequired,omitempty"`
Roles []string `yaml:"roles,omitempty"`
MFARequired *bool `yaml:"mfaRequired,omitempty"`
RegistrationURL string `yaml:"registrationUrl,omitempty"`
EnrollmentURL string `yaml:"enrollmentUrl,omitempty"`
}
// Load reads and parses the YAML config file at path.

View file

@ -127,6 +127,50 @@ clients:
}
}
func TestLoad_ClientMFAAndRegistrationURL(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key")
yaml := `
issuer: "https://kc.example.com"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
clients:
- clientId: "coulomb-social"
displayName: "coulomb.social"
redirectUris:
- "https://coulomb.social/auth/callback/"
clientType: "public"
mfaRequired: false
registrationUrl: "https://users.example.com/register"
`
cfgPath := writeTempFile(t, yaml)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load: unexpected error: %v", err)
}
if len(cfg.Clients) != 1 {
t.Fatalf("clients: got %d", len(cfg.Clients))
}
c := cfg.Clients[0]
if c.MFARequired == nil || *c.MFARequired {
t.Fatalf("mfaRequired: want false, got %+v", c.MFARequired)
}
if c.RegistrationURL != "https://users.example.com/register" {
t.Errorf("registrationUrl: got %q", c.RegistrationURL)
}
}
func TestValidate_InvalidRegistrationURL(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Clients[0].RegistrationURL = "javascript:alert(1)"
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "registrationUrl") {
t.Errorf("expected registrationUrl error, got %v", errs)
}
}
func TestLoad_PrivacyIDEARequireForAll(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key")
yaml := `

View file

@ -63,6 +63,16 @@ func ValidateConfig(cfg *Config) []string {
errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri))
}
}
if c.RegistrationURL != "" {
if err := validateHandoffURL(c.RegistrationURL); err != nil {
errs = append(errs, prefix+": registrationUrl: "+err.Error())
}
}
if c.EnrollmentURL != "" {
if err := validateHandoffURL(c.EnrollmentURL); err != nil {
errs = append(errs, prefix+": enrollmentUrl: "+err.Error())
}
}
}
// Private key PEM path must be provided (existence is checked at startup).
@ -81,3 +91,20 @@ func contains(values []string, wanted string) bool {
}
return false
}
func validateHandoffURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("%q is not an absolute URL", raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%q scheme must be http or https", raw)
}
if u.User != nil {
return fmt.Errorf("%q must not contain userinfo", raw)
}
if strings.ContainsAny(u.Host, "*?") || strings.ContainsAny(u.Path, "*") {
return fmt.Errorf("%q must not contain wildcards", raw)
}
return nil
}