feat: implement T11, T12 — Authelia adapter, privacyIDEA adapter

- T11: AutheliaAdapter delegating login UI and session; Authelia tokens never leak to profile layer
- T12: PrivacyIDEAAdapter delegating MFA 100% — no MFA logic in KeyCape

21 adapter tests pass, vet clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-03-13 01:50:31 +01:00
parent b0adbc5daa
commit d05c73dc19
8 changed files with 1113 additions and 0 deletions

View file

@ -0,0 +1,153 @@
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"`
}

View file

@ -0,0 +1,309 @@
package privacyidea_test
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"
"keycape/internal/adapters/privacyidea"
"keycape/internal/domain"
)
// ---------------------------------------------------------------------------
// Mock HTTP client
// ---------------------------------------------------------------------------
// mockHTTPClient implements privacyidea.HTTPClient for test injection.
type mockHTTPClient struct {
doFn func(req *http.Request) (*http.Response, error)
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
if m.doFn != nil {
return m.doFn(req)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("{}")),
}, nil
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
// testConfig returns a minimal Config suitable for tests.
func testConfig() privacyidea.Config {
return privacyidea.Config{
BaseURL: "https://privacyidea.local",
AdminToken: "service-jwt",
Realm: "netkingdom",
}
}
// jsonResponse returns a *http.Response with a JSON body and status 200.
func jsonResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
// tokenListResponse builds a privacyIDEA /token/ JSON response.
func tokenListResponse(tokens []map[string]interface{}) string {
tokenJSON := "["
for i, t := range tokens {
if i > 0 {
tokenJSON += ","
}
active, _ := t["active"].(bool)
tokenJSON += fmt.Sprintf(`{"serial":"TOK%d","active":%v}`, i, active)
}
tokenJSON += "]"
return fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s}}}`, tokenJSON)
}
// validateResponse builds a privacyIDEA /validate/check JSON response.
func validateResponse(success bool) string {
return fmt.Sprintf(`{"result":{"status":true,"value":%v}}`, success)
}
// ---------------------------------------------------------------------------
// CheckMFARequired — tokens present
// ---------------------------------------------------------------------------
func TestCheckMFARequired_ActiveTokenPresent_ReturnsTrue(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodGet {
t.Errorf("expected GET, got %s", req.Method)
}
if !strings.Contains(req.URL.String(), "alice") {
t.Errorf("expected user in URL, got: %s", req.URL)
}
return jsonResponse(tokenListResponse([]map[string]interface{}{
{"active": true},
})), nil
},
}
adapter := privacyidea.New(testConfig(), 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 active token present")
}
}
func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenListResponse([]map[string]interface{}{
{"active": false},
})), nil
},
}
adapter := privacyidea.New(testConfig(), client)
required, err := adapter.CheckMFARequired(context.Background(), "bob")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if required {
t.Error("expected MFA required=false when only inactive tokens present")
}
}
func TestCheckMFARequired_NoTokens_ReturnsFalse(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenListResponse(nil)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
required, err := adapter.CheckMFARequired(context.Background(), "charlie")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if required {
t.Error("expected MFA required=false when no tokens")
}
}
// ---------------------------------------------------------------------------
// CheckMFARequired — error cases (fail closed)
// ---------------------------------------------------------------------------
func TestCheckMFARequired_HTTPError_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("connection refused")
},
}
adapter := privacyidea.New(testConfig(), client)
_, err := adapter.CheckMFARequired(context.Background(), "alice")
if err == nil {
t.Fatal("expected error on HTTP failure, got nil (must fail closed)")
}
}
func TestCheckMFARequired_Non200Status_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(`{"result":{"status":false}}`)),
}, nil
},
}
adapter := privacyidea.New(testConfig(), client)
_, err := adapter.CheckMFARequired(context.Background(), "alice")
if err == nil {
t.Fatal("expected error on non-200 status, got nil (must fail closed)")
}
}
// ---------------------------------------------------------------------------
// CheckMFARequired — Authorization header
// ---------------------------------------------------------------------------
func TestCheckMFARequired_SendsAdminToken(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
auth := req.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
t.Errorf("expected Bearer token in Authorization, got %q", auth)
}
if !strings.Contains(auth, "service-jwt") {
t.Errorf("expected admin token in Authorization header, got %q", auth)
}
return jsonResponse(tokenListResponse(nil)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
_, _ = adapter.CheckMFARequired(context.Background(), "alice")
}
// ---------------------------------------------------------------------------
// ValidateMFAToken — success
// ---------------------------------------------------------------------------
func TestValidateMFAToken_ValidOTP_ReturnsNil(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Errorf("expected POST, got %s", req.Method)
}
body, _ := io.ReadAll(req.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "alice") {
t.Errorf("expected user in POST body, got: %s", bodyStr)
}
if !strings.Contains(bodyStr, "123456") {
t.Errorf("expected OTP in POST body, got: %s", bodyStr)
}
return jsonResponse(validateResponse(true)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
if err != nil {
t.Errorf("expected nil error for valid OTP, got %v", err)
}
}
// ---------------------------------------------------------------------------
// ValidateMFAToken — failure
// ---------------------------------------------------------------------------
func TestValidateMFAToken_InvalidOTP_ReturnsErrMFAFailed(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(validateResponse(false)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "wrong")
if err == nil {
t.Fatal("expected ErrMFAFailed, got nil")
}
if err != domain.ErrMFAFailed {
t.Errorf("expected domain.ErrMFAFailed, got %v", err)
}
}
func TestValidateMFAToken_HTTPError_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("network failure")
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
if err == nil {
t.Fatal("expected error on HTTP failure, got nil (must fail closed)")
}
}
func TestValidateMFAToken_Non200Status_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Body: io.NopCloser(strings.NewReader(`{}`)),
}, nil
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
if err == nil {
t.Fatal("expected error on non-200 status, got nil (must fail closed)")
}
}
// ---------------------------------------------------------------------------
// ValidateMFAToken — realm is included in request
// ---------------------------------------------------------------------------
func TestValidateMFAToken_IncludesRealm(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
if !strings.Contains(string(body), "netkingdom") {
t.Errorf("expected realm in POST body, got: %s", body)
}
return jsonResponse(validateResponse(true)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
_ = adapter.ValidateMFAToken(context.Background(), "alice", "000000")
}
func TestCheckMFARequired_IncludesRealm(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.String(), "netkingdom") {
t.Errorf("expected realm in request URL, got: %s", req.URL)
}
return jsonResponse(tokenListResponse(nil)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
_, _ = adapter.CheckMFARequired(context.Background(), "alice")
}

View file

@ -0,0 +1,36 @@
// Package privacyidea implements the domain.MFAProvider interface by delegating
// all MFA decisions to a privacyIDEA server. KeyCape contains no MFA logic —
// every check and validation call is forwarded verbatim to privacyIDEA.
package privacyidea
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
// AdminToken is the service-account JWT used to authenticate requests to the
// privacyIDEA admin API.
AdminToken string
// Realm is the privacyIDEA realm to scope token and validate requests.
// Defaults to "netkingdom" when empty.
Realm string
}
// realm returns the effective realm, falling back to "netkingdom".
func (c Config) realm() string {
if c.Realm != "" {
return c.Realm
}
return "netkingdom"
}
// HTTPClient is a minimal interface over net/http.Client for test injection.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// defaultHTTPClient is the production HTTP client used when none is injected.
var defaultHTTPClient HTTPClient = &http.Client{}