Verify reader scope before accepting absence of enrolled factors
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m6s
Build and Publish Container Image / build-and-push (push) Successful in 53s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 16:59:09 +02:00
parent 122a0d1369
commit 113f3a6296
6 changed files with 221 additions and 7 deletions

View file

@ -118,9 +118,51 @@ func (a *PrivacyIDEAAdapter) hasActiveToken(ctx context.Context, userID string)
if *parsed.Result.Value.Count > len(parsed.Result.Value.Tokens) {
return false, fmt.Errorf("privacyidea: incomplete token list page")
}
if err := a.verifyReadScope(ctx); err != nil {
return false, err
}
return false, nil
}
// verifyReadScope distinguishes no enrolled factor from lost realm visibility.
// privacyIDEA returns a successful empty list for a reader with withdrawn rights.
func (a *PrivacyIDEAAdapter) verifyReadScope(ctx context.Context) error {
if a.cfg.ReadProbeSerial == "" {
return fmt.Errorf("privacyidea: factor-read scope proof is not configured")
}
q := url.Values{"serial": {a.cfg.ReadProbeSerial}, "tokenrealm": {a.cfg.realm()}}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(a.cfg.BaseURL, "/")+"/token/?"+q.Encode(), nil)
if err != nil {
return fmt.Errorf("privacyidea: cannot build scope proof")
}
credential, err := a.adminCredential()
if err != nil {
return err
}
req.Header.Set("Authorization", credential)
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("privacyidea: scope proof unavailable")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("privacyidea: scope proof rejected")
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1048577))
if err != nil || len(raw) > 1048576 {
return fmt.Errorf("privacyidea: invalid scope proof")
}
var proof tokenListResponse
if json.Unmarshal(raw, &proof) != nil || !proof.Result.Status || proof.Result.Value.Count == nil || *proof.Result.Value.Count != 1 || len(proof.Result.Value.Tokens) != 1 {
return fmt.Errorf("privacyidea: reader scope is unverified")
}
token := proof.Result.Value.Tokens[0]
if token.Serial != a.cfg.ReadProbeSerial || token.Active == nil || *token.Active || token.UserID == nil || *token.UserID != "" || len(token.Realms) != 1 || token.Realms[0] != a.cfg.realm() {
return fmt.Errorf("privacyidea: reader scope proof does not match")
}
return nil
}
// ValidateMFAToken validates the given OTP token for the user via privacyIDEA's
// /validate/check endpoint. Returns nil on success, domain.ErrMFAFailed if the
// token is invalid, and a wrapped infrastructure error on any network/HTTP failure.
@ -225,9 +267,11 @@ type tokenListResponse struct {
// tokenEntry represents a single token entry in the token list response.
type tokenEntry struct {
Serial string `json:"serial"`
Active *bool `json:"active"`
RolloutState *string `json:"rollout_state"`
UserID *string `json:"user_id"`
Realms []string `json:"realms"`
Serial string `json:"serial"`
Active *bool `json:"active"`
RolloutState *string `json:"rollout_state"`
}
// validateResponse models the privacyIDEA POST /validate/check response envelope.

View file

@ -18,10 +18,17 @@ import (
// mockHTTPClient implements privacyidea.HTTPClient for test injection.
type mockHTTPClient struct {
doFn func(req *http.Request) (*http.Response, error)
doFn func(req *http.Request) (*http.Response, error)
probeFn func(req *http.Request) (*http.Response, error)
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
if req.URL.Query().Get("serial") == "TEST-PROBE" {
if m.probeFn != nil {
return m.probeFn(req)
}
return jsonResponse(`{"result":{"status":true,"value":{"count":1,"tokens":[{"serial":"TEST-PROBE","active":false,"user_id":"","realms":["netkingdom"]}]}}}`), nil
}
if m.doFn != nil {
return m.doFn(req)
}
@ -38,9 +45,10 @@ func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
// testConfig returns a minimal Config suitable for tests.
func testConfig() privacyidea.Config {
return privacyidea.Config{
BaseURL: "https://privacyidea.local",
AdminToken: "service-jwt",
Realm: "netkingdom",
BaseURL: "https://privacyidea.local",
AdminToken: "service-jwt",
ReadProbeSerial: "TEST-PROBE",
Realm: "netkingdom",
}
}

View file

@ -21,6 +21,10 @@ type Config struct {
// Configure exactly one source. Failed reads never fall back to a cached token.
AdminTokenFile string `yaml:"adminTokenFile,omitempty"`
// ReadProbeSerial identifies an unassigned, disabled token in the same realm.
// Its visibility proves reader access before interpreting an empty user list.
ReadProbeSerial string `yaml:"readProbeSerial,omitempty"`
// Realm is the privacyIDEA realm to scope token and validate requests.
// Defaults to "netkingdom" when empty.
Realm string `yaml:"realm"`

View file

@ -0,0 +1,41 @@
package privacyidea_test
import (
"context"
"keycape/internal/adapters/privacyidea"
"net/http"
"testing"
)
func TestEmptyFactorListRequiresLiveReaderScope(t *testing.T) {
for _, tc := range []struct {
name, body string
fail bool
}{
{"scope visible", `{"result":{"status":true,"value":{"count":1,"tokens":[{"serial":"TEST-PROBE","active":false,"user_id":"","realms":["netkingdom"]}]}}}`, false},
{"permission withdrawn", `{"result":{"status":true,"value":{"count":0,"tokens":[]}}}`, true},
{"probe assigned to a user", `{"result":{"status":true,"value":{"count":1,"tokens":[{"serial":"TEST-PROBE","active":false,"user_id":"alice","realms":["netkingdom"]}]}}}`, true},
{"different realm", `{"result":{"status":true,"value":{"count":1,"tokens":[{"serial":"TEST-PROBE","active":false,"user_id":"","realms":["other"]}]}}}`, true},
} {
t.Run(tc.name, func(t *testing.T) {
client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { return jsonResponse(tokenListResponse(nil)), nil }, probeFn: func(r *http.Request) (*http.Response, error) {
if r.URL.Query().Get("tokenrealm") != "netkingdom" || r.URL.Query().Get("user") != "" {
t.Fatal("scope probe incorrectly bound")
}
return jsonResponse(tc.body), nil
}}
got, err := privacyidea.New(testConfig(), client).HasEnrolledFactor(context.Background(), "alice")
if got || (err != nil) != tc.fail {
t.Fatalf("enrolled=%v error=%v", got, err)
}
})
}
}
func TestNoProbeConfigurationCannotMeanNoFactor(t *testing.T) {
cfg := testConfig()
cfg.ReadProbeSerial = ""
client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { return jsonResponse(tokenListResponse(nil)), nil }}
if _, err := privacyidea.New(cfg, client).HasEnrolledFactor(context.Background(), "alice"); err == nil {
t.Fatal("unverified empty result accepted")
}
}