generated from coulomb/repo-seed
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:
302
src/internal/adapters/authelia/adapter_test.go
Normal file
302
src/internal/adapters/authelia/adapter_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user