diff --git a/src/internal/adapters/privacyidea/adapter.go b/src/internal/adapters/privacyidea/adapter.go index 4b07511..cfdf4ce 100644 --- a/src/internal/adapters/privacyidea/adapter.go +++ b/src/internal/adapters/privacyidea/adapter.go @@ -38,6 +38,10 @@ func New(cfg Config, httpClient HTTPClient) *PrivacyIDEAAdapter { // registered in privacyIDEA. Fails closed: any infrastructure error returns // (false, err) so callers cannot bypass the check. func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) { + if a.cfg.RequireForAll { + return true, nil + } + endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/" q := url.Values{} diff --git a/src/internal/adapters/privacyidea/adapter_test.go b/src/internal/adapters/privacyidea/adapter_test.go index 670defa..99f07e1 100644 --- a/src/internal/adapters/privacyidea/adapter_test.go +++ b/src/internal/adapters/privacyidea/adapter_test.go @@ -101,6 +101,27 @@ func TestCheckMFARequired_ActiveTokenPresent_ReturnsTrue(t *testing.T) { } } +func TestCheckMFARequired_RequireForAll_ReturnsTrueWithoutTokenList(t *testing.T) { + client := &mockHTTPClient{ + doFn: func(_ *http.Request) (*http.Response, error) { + t.Fatal("token-list endpoint must not be called when RequireForAll is enabled") + return nil, nil + }, + } + + cfg := testConfig() + cfg.RequireForAll = true + adapter := privacyidea.New(cfg, client) + + required, err := adapter.CheckMFARequired(context.Background(), "alice") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !required { + t.Error("expected MFA required=true when RequireForAll is enabled") + } +} + func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) { client := &mockHTTPClient{ doFn: func(_ *http.Request) (*http.Response, error) { diff --git a/src/internal/adapters/privacyidea/config.go b/src/internal/adapters/privacyidea/config.go index 533ffc6..cfe05e2 100644 --- a/src/internal/adapters/privacyidea/config.go +++ b/src/internal/adapters/privacyidea/config.go @@ -8,15 +8,20 @@ import "net/http" // Config holds all connection parameters for the privacyIDEA adapter. type Config struct { // BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local". - BaseURL string + BaseURL string `yaml:"baseURL"` // AdminToken is the service-account JWT used to authenticate requests to the // privacyIDEA admin API. - AdminToken string + AdminToken string `yaml:"adminToken"` // Realm is the privacyIDEA realm to scope token and validate requests. // Defaults to "netkingdom" when empty. - Realm string + Realm string `yaml:"realm"` + + // RequireForAll skips privacyIDEA token-list discovery and requires MFA for + // every authenticated upstream user. This is useful during bootstrap when + // token-list admin credentials may not be durable yet. + RequireForAll bool `yaml:"requireForAll,omitempty"` } // realm returns the effective realm, falling back to "netkingdom". diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index 4413cd9..f5afd2c 100644 --- a/src/internal/config/config_test.go +++ b/src/internal/config/config_test.go @@ -127,6 +127,40 @@ clients: } } +func TestLoad_PrivacyIDEARequireForAll(t *testing.T) { + keyPath := writeTempFile(t, "placeholder-key") + yaml := ` +issuer: "https://kc.example.com" +port: 8080 +tokenLifetime: "15m" +privateKeyPem: "` + keyPath + `" +environment: "dev" +privacyidea: + baseURL: "http://privacyidea.mfa.svc.cluster.local:8080" + adminToken: "service-token" + realm: "coulomb" + requireForAll: true +clients: + - clientId: "netkingdom-bootstrap-console" + displayName: "NetKingdom Bootstrap Console" + redirectUris: + - "http://127.0.0.1:8876/oidc/callback" + clientType: "public" +` + cfgPath := writeTempFile(t, yaml) + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load: unexpected error: %v", err) + } + if cfg.PrivacyIDEA.Realm != "coulomb" { + t.Errorf("PrivacyIDEA.Realm: got %q", cfg.PrivacyIDEA.Realm) + } + if !cfg.PrivacyIDEA.RequireForAll { + t.Error("PrivacyIDEA.RequireForAll should load from YAML") + } +} + func TestLoad_FileNotFound(t *testing.T) { _, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml")) if err == nil { diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index 8a541d2..b3160b7 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -279,6 +279,14 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ // Check MFA requirement. mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username) if err != nil { + h.Emitter.Emit(ctx, telemetry.Event{ + Timestamp: time.Now(), + EventType: telemetry.EventAuthFailure, + ClientID: ps.ClientID, + Endpoint: "/authorize/callback", + Result: "failure", + ErrorType: "mfa_check_error", + }) http.Error(w, "mfa check error", http.StatusInternalServerError) return }