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:
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,215 @@
package authelia
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
)
// AutheliaAdapter implements domain.AuthProvider by delegating to Authelia's
// OIDC endpoints. All Authelia tokens and cookies are confined to this package.
type AutheliaAdapter struct {
cfg Config
client HTTPClient
}
// New returns a production-ready AutheliaAdapter.
// If httpClient is nil the default net/http.Client is used.
func New(cfg Config, httpClient HTTPClient) *AutheliaAdapter {
if httpClient == nil {
httpClient = defaultHTTPClient
}
return &AutheliaAdapter{cfg: cfg, client: httpClient}
}
// ---------------------------------------------------------------------------
// domain.AuthProvider implementation
// ---------------------------------------------------------------------------
// AuthorizeURL builds the Authelia OIDC authorization URL to which the user
// should be redirected.
func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
base := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/authorization"
q := url.Values{}
q.Set("client_id", req.ClientID)
q.Set("redirect_uri", req.RedirectURI)
q.Set("response_type", "code")
q.Set("state", req.State)
if req.Nonce != "" {
q.Set("nonce", req.Nonce)
}
if len(req.Scopes) > 0 {
q.Set("scope", strings.Join(req.Scopes, " "))
} else {
q.Set("scope", "openid profile")
}
if req.PKCEChallenge != "" {
q.Set("code_challenge", req.PKCEChallenge)
q.Set("code_challenge_method", req.PKCEChallengeMethod)
}
return base + "?" + q.Encode(), nil
}
// HandleCallback exchanges the authorization code for tokens and extracts the
// authenticated user identity. Authelia tokens are never returned — only the
// normalized AuthResult is.
func (a *AutheliaAdapter) HandleCallback(ctx context.Context, params domain.CallbackParams) (*domain.AuthResult, error) {
emitter := telemetry.EmitterFromContext(ctx)
// Surface callback-level errors from Authelia immediately.
if params.Error != "" {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: params.Error,
})
return nil, domain.ErrAuthFailed
}
// Exchange the authorization code for tokens.
tokenResp, err := a.exchangeCode(ctx, params.Code)
if err != nil {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: "token_exchange_error",
})
return nil, domain.ErrAuthFailed
}
// Parse the ID token claims (no signature verification — internal service boundary).
claims, err := parseIDTokenClaims(tokenResp.IDToken)
if err != nil {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: "id_token_parse_error",
})
return nil, domain.ErrAuthFailed
}
// Extract username: prefer preferred_username, fall back to sub.
username := stringClaim(claims, "preferred_username")
if username == "" {
username = stringClaim(claims, "sub")
}
if username == "" {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: "missing_username_claim",
})
return nil, domain.ErrAuthFailed
}
// Security boundary: only the ID token claims are forwarded.
// The access_token and refresh_token remain within this adapter.
return &domain.AuthResult{
Username: username,
Claims: claims,
}, nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// tokenResponse is the subset of the Authelia token endpoint response that
// this adapter needs. Fields beyond IDToken are intentionally not forwarded.
type tokenResponse struct {
IDToken string `json:"id_token"`
}
// exchangeCode sends a POST to Authelia's token endpoint and returns the
// parsed token response. On any HTTP or status error it returns a non-nil error.
func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenResponse, error) {
tokenURL := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/token"
body := url.Values{}
body.Set("grant_type", "authorization_code")
body.Set("code", code)
body.Set("redirect_uri", a.cfg.RedirectURI)
body.Set("client_id", a.cfg.ClientID)
body.Set("client_secret", a.cfg.ClientSecret)
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(body.Encode()))
if err != nil {
return nil, fmt.Errorf("authelia: build token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("authelia: token exchange: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("authelia: token endpoint returned %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("authelia: read token response: %w", err)
}
var tr tokenResponse
if err := json.Unmarshal(raw, &tr); err != nil {
return nil, fmt.Errorf("authelia: decode token response: %w", err)
}
return &tr, nil
}
// parseIDTokenClaims extracts the JWT payload claims without verifying the
// signature. This is intentional — the token is received directly from the
// upstream OIDC provider over a server-to-server TLS connection.
func parseIDTokenClaims(idToken string) (map[string]interface{}, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("authelia: malformed id_token: expected 3 parts, got %d", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("authelia: decode id_token payload: %w", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("authelia: unmarshal id_token claims: %w", err)
}
return claims, nil
}
// stringClaim extracts a string value from a claims map, returning "" if
// the key is absent or the value is not a string.
func stringClaim(claims map[string]interface{}, key string) string {
v, ok := claims[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}

View File

@@ -0,0 +1,302 @@
package authelia_test
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"keycape/internal/adapters/authelia"
"keycape/internal/domain"
)
// ---------------------------------------------------------------------------
// Mock HTTP client
// ---------------------------------------------------------------------------
// mockHTTPClient implements authelia.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() authelia.Config {
return authelia.Config{
BaseURL: "https://authelia.local",
ClientID: "keycape",
ClientSecret: "test-secret",
RedirectURI: "https://keycape.local/callback",
}
}
// buildTokenResponse builds a fake token endpoint JSON response.
// The ID token is a minimal unsigned JWT (header.claims.signature) with the given claims.
func buildTokenResponse(claims map[string]interface{}) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
claimsJSON, _ := json.Marshal(claims)
claimsEnc := base64.RawURLEncoding.EncodeToString(claimsJSON)
idToken := header + "." + claimsEnc + ".fakesig"
body := fmt.Sprintf(`{"access_token":"at","token_type":"Bearer","id_token":%q}`,
idToken)
return body
}
// 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"}},
}
}
// ---------------------------------------------------------------------------
// AuthorizeURL
// ---------------------------------------------------------------------------
func TestAuthorizeURL_ContainsRequiredParams(t *testing.T) {
adapter := authelia.New(testConfig(), &mockHTTPClient{})
req := domain.AuthRequest{
ClientID: "myapp",
RedirectURI: "https://myapp.local/cb",
State: "state-abc",
Nonce: "nonce-xyz",
Scopes: []string{"openid", "profile"},
PKCEChallenge: "challenge123",
PKCEChallengeMethod: "S256",
}
u, err := adapter.AuthorizeURL(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
checks := []string{
"client_id=myapp",
"redirect_uri=",
"response_type=code",
"state=state-abc",
"nonce=nonce-xyz",
"code_challenge=challenge123",
"code_challenge_method=S256",
"scope=",
"openid",
}
for _, want := range checks {
if !strings.Contains(u, want) {
t.Errorf("AuthorizeURL missing %q in: %s", want, u)
}
}
}
func TestAuthorizeURL_UsesBaseURL(t *testing.T) {
adapter := authelia.New(testConfig(), &mockHTTPClient{})
req := domain.AuthRequest{
ClientID: "app",
RedirectURI: "https://app.local/cb",
State: "s",
PKCEChallenge: "c",
PKCEChallengeMethod: "S256",
Scopes: []string{"openid"},
}
u, err := adapter.AuthorizeURL(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(u, "https://authelia.local") {
t.Errorf("expected URL to start with BaseURL, got: %s", u)
}
}
// ---------------------------------------------------------------------------
// HandleCallback — successful token exchange
// ---------------------------------------------------------------------------
func TestHandleCallback_Success_PreferredUsername(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-123",
"preferred_username": "alice",
"email": "alice@example.com",
})
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Errorf("expected POST, got %s", req.Method)
}
return jsonResponse(tokenBody), nil
},
}
adapter := authelia.New(testConfig(), client)
result, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "auth-code-xyz",
State: "state-abc",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Username != "alice" {
t.Errorf("Username: want %q, got %q", "alice", result.Username)
}
if result.Claims == nil {
t.Error("expected non-nil Claims map")
}
}
func TestHandleCallback_Success_FallsBackToSub(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-456",
})
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenBody), nil
},
}
adapter := authelia.New(testConfig(), client)
result, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "code",
State: "state",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Username != "user-uuid-456" {
t.Errorf("Username fallback to sub: want %q, got %q", "user-uuid-456", result.Username)
}
}
// ---------------------------------------------------------------------------
// HandleCallback — error propagation
// ---------------------------------------------------------------------------
func TestHandleCallback_CallbackError_ReturnsErrAuthFailed(t *testing.T) {
adapter := authelia.New(testConfig(), &mockHTTPClient{})
_, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Error: "access_denied",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if err != domain.ErrAuthFailed {
t.Errorf("expected domain.ErrAuthFailed, got %v", err)
}
}
func TestHandleCallback_HTTPError_ReturnsErrAuthFailed(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(strings.NewReader(`{"error":"invalid_client"}`)),
}, nil
},
}
adapter := authelia.New(testConfig(), client)
_, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "bad-code",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if err != domain.ErrAuthFailed {
t.Errorf("expected domain.ErrAuthFailed, got %v", err)
}
}
func TestHandleCallback_MissingUsernameClaim_ReturnsErrAuthFailed(t *testing.T) {
// JWT with no sub or preferred_username.
tokenBody := buildTokenResponse(map[string]interface{}{
"email": "anon@example.com",
})
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenBody), nil
},
}
adapter := authelia.New(testConfig(), client)
_, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "code",
})
if err == nil {
t.Fatal("expected error for missing username claim, got nil")
}
if err != domain.ErrAuthFailed {
t.Errorf("expected domain.ErrAuthFailed, got %v", err)
}
}
func TestHandleCallback_TokenExchangeNetworkError_ReturnsErrAuthFailed(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("connection refused")
},
}
adapter := authelia.New(testConfig(), client)
_, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "code",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if err != domain.ErrAuthFailed {
t.Errorf("expected domain.ErrAuthFailed, got %v", err)
}
}
// ---------------------------------------------------------------------------
// Security: AuthResult must not contain raw tokens
// ---------------------------------------------------------------------------
func TestHandleCallback_AuthResultContainsNoRawTokens(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "uid",
"preferred_username": "bob",
})
// Include an access_token in the response to verify it is not forwarded.
fullBody := strings.Replace(tokenBody, `"id_token"`, `"access_token":"raw-at","id_token"`, 1)
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(fullBody), nil
},
}
adapter := authelia.New(testConfig(), client)
result, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Claims must come from the ID token payload, not from the outer token response.
// In particular, "access_token" must not appear as a claim key.
if _, ok := result.Claims["access_token"]; ok {
t.Error("AuthResult.Claims must not expose raw access_token — security boundary violation")
}
}

View File

@@ -0,0 +1,30 @@
// Package authelia implements the domain.AuthProvider interface using Authelia
// as an upstream OIDC provider. Authelia tokens and session cookies are fully
// contained within this package and are never exposed to the server/ layer.
package authelia
import "net/http"
// Config holds all connection parameters for the Authelia adapter.
type Config struct {
// BaseURL is the Authelia server base URL, e.g. "https://authelia.local".
BaseURL string
// ClientID is the client ID registered in Authelia for KeyCape.
ClientID string
// ClientSecret is the client secret for the KeyCape client registration.
ClientSecret string
// RedirectURI is the callback URL registered in Authelia that points back
// to KeyCape's callback handler.
RedirectURI string
}
// 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{}

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{}

View File

@@ -0,0 +1,45 @@
package domain
import (
"context"
"errors"
)
// AuthProvider handles login UI delegation and session management.
// The server layer uses only this interface — no Authelia types leak out.
type AuthProvider interface {
// AuthorizeURL returns the URL to redirect the user to for login.
AuthorizeURL(ctx context.Context, req AuthRequest) (string, error)
// HandleCallback extracts the authenticated user identity from a callback request.
// Returns ErrAuthFailed if authentication was not successful.
HandleCallback(ctx context.Context, callbackParams CallbackParams) (*AuthResult, error)
}
// AuthRequest contains the parameters for initiating an auth flow.
type AuthRequest struct {
ClientID string
RedirectURI string
State string
Nonce string
Scopes []string
PKCEChallenge string
PKCEChallengeMethod string
}
// CallbackParams are the query params received on the redirect callback.
type CallbackParams struct {
Code string
State string
Error string
}
// AuthResult is the normalized identity returned after successful authentication.
type AuthResult struct {
Username string
// Raw identity claims from the backend (not exposed to OIDC layer directly)
Claims map[string]interface{}
}
// ErrAuthFailed is returned by AuthProvider.HandleCallback when authentication was not successful.
var ErrAuthFailed = errors.New("authentication failed")

View File

@@ -0,0 +1,23 @@
package domain
import (
"context"
"errors"
)
// MFAProvider checks MFA requirements and validates MFA tokens.
// KeyCape must NOT implement MFA logic — it delegates entirely to this interface.
type MFAProvider interface {
// CheckMFARequired returns true if MFA is required for the given user.
CheckMFARequired(ctx context.Context, userID string) (bool, error)
// ValidateMFAToken validates the given OTP token for the user.
// Returns ErrMFAFailed if the token is invalid or expired.
ValidateMFAToken(ctx context.Context, userID, token string) error
}
// ErrMFAFailed is returned when the MFA token is invalid or expired.
var ErrMFAFailed = errors.New("mfa validation failed")
// ErrMFANotEnrolled is returned when the user has no MFA enrollment.
var ErrMFANotEnrolled = errors.New("user has no MFA enrollment")