package privacyidea import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "keycape/internal/domain" ) // PrivacyIDEAAdapter implements domain.MFAProvider by delegating to privacyIDEA's // REST API. No MFA logic is implemented here — every decision is owned by // privacyIDEA. The adapter fails closed: any infrastructure error is returned // as a non-nil error so the caller cannot proceed without a definitive answer. type PrivacyIDEAAdapter struct { cfg Config client HTTPClient } // New returns a production-ready PrivacyIDEAAdapter. // If httpClient is nil the default net/http.Client is used. func New(cfg Config, httpClient HTTPClient) *PrivacyIDEAAdapter { if httpClient == nil { httpClient = defaultHTTPClient } return &PrivacyIDEAAdapter{cfg: cfg, client: httpClient} } // --------------------------------------------------------------------------- // domain.MFAProvider implementation // --------------------------------------------------------------------------- // CheckMFARequired returns true if the user has at least one active MFA token // 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) { endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/" q := url.Values{} q.Set("user", userID) q.Set("realm", a.cfg.realm()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+q.Encode(), nil) if err != nil { return false, fmt.Errorf("privacyidea: build token list request: %w", err) } req.Header.Set("Authorization", "Bearer "+a.cfg.AdminToken) resp, err := a.client.Do(req) if err != nil { return false, fmt.Errorf("privacyidea: token list request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Errorf("privacyidea: token list returned status %d", resp.StatusCode) } raw, err := io.ReadAll(resp.Body) if err != nil { return false, fmt.Errorf("privacyidea: read token list response: %w", err) } var parsed tokenListResponse if err := json.Unmarshal(raw, &parsed); err != nil { return false, fmt.Errorf("privacyidea: decode token list response: %w", err) } for _, tok := range parsed.Result.Value.Tokens { if tok.Active { return true, nil } } return false, 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. // Fails closed: infrastructure errors are surfaced, not swallowed. func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token string) error { endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/validate/check" form := url.Values{} form.Set("user", userID) form.Set("pass", token) form.Set("realm", a.cfg.realm()) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return fmt.Errorf("privacyidea: build validate request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", "Bearer "+a.cfg.AdminToken) resp, err := a.client.Do(req) if err != nil { return fmt.Errorf("privacyidea: validate request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("privacyidea: validate endpoint returned status %d", resp.StatusCode) } raw, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("privacyidea: read validate response: %w", err) } var parsed validateResponse if err := json.Unmarshal(raw, &parsed); err != nil { return fmt.Errorf("privacyidea: decode validate response: %w", err) } if !parsed.Result.Value { return domain.ErrMFAFailed } return nil } // --------------------------------------------------------------------------- // JSON response types (internal to this package) // --------------------------------------------------------------------------- // tokenListResponse models the privacyIDEA GET /token/ response envelope. type tokenListResponse struct { Result struct { Status bool `json:"status"` Value struct { Tokens []tokenEntry `json:"tokens"` } `json:"value"` } `json:"result"` } // tokenEntry represents a single token entry in the token list response. type tokenEntry struct { Serial string `json:"serial"` Active bool `json:"active"` } // validateResponse models the privacyIDEA POST /validate/check response envelope. type validateResponse struct { Result struct { Status bool `json:"status"` Value bool `json:"value"` } `json:"result"` }