diff --git a/src/internal/adapters/authelia/adapter.go b/src/internal/adapters/authelia/adapter.go new file mode 100644 index 0000000..146dc49 --- /dev/null +++ b/src/internal/adapters/authelia/adapter.go @@ -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 +} diff --git a/src/internal/adapters/authelia/adapter_test.go b/src/internal/adapters/authelia/adapter_test.go new file mode 100644 index 0000000..1164857 --- /dev/null +++ b/src/internal/adapters/authelia/adapter_test.go @@ -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") + } +} diff --git a/src/internal/adapters/authelia/config.go b/src/internal/adapters/authelia/config.go new file mode 100644 index 0000000..35234d0 --- /dev/null +++ b/src/internal/adapters/authelia/config.go @@ -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{} diff --git a/src/internal/adapters/privacyidea/adapter.go b/src/internal/adapters/privacyidea/adapter.go new file mode 100644 index 0000000..4b07511 --- /dev/null +++ b/src/internal/adapters/privacyidea/adapter.go @@ -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"` +} diff --git a/src/internal/adapters/privacyidea/adapter_test.go b/src/internal/adapters/privacyidea/adapter_test.go new file mode 100644 index 0000000..670defa --- /dev/null +++ b/src/internal/adapters/privacyidea/adapter_test.go @@ -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") +} diff --git a/src/internal/adapters/privacyidea/config.go b/src/internal/adapters/privacyidea/config.go new file mode 100644 index 0000000..533ffc6 --- /dev/null +++ b/src/internal/adapters/privacyidea/config.go @@ -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{} diff --git a/src/internal/domain/auth.go b/src/internal/domain/auth.go new file mode 100644 index 0000000..534124e --- /dev/null +++ b/src/internal/domain/auth.go @@ -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") diff --git a/src/internal/domain/mfa.go b/src/internal/domain/mfa.go new file mode 100644 index 0000000..0f71feb --- /dev/null +++ b/src/internal/domain/mfa.go @@ -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")