package authelia_test import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "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{}) // Downstream client values — must NOT appear in the Authelia URL. 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) } // Must use adapter's own client_id and redirect_uri, not the downstream client's. required := []string{ "client_id=keycape", "redirect_uri=", "response_type=code", "state=state-abc", "scope=", "openid", } for _, want := range required { if !strings.Contains(u, want) { t.Errorf("AuthorizeURL missing %q in: %s", want, u) } } // Downstream client_id must NOT be forwarded to Authelia. if strings.Contains(u, "client_id=myapp") { t.Errorf("AuthorizeURL must not forward downstream client_id to Authelia, got: %s", u) } // PKCE must NOT be forwarded — confidential client uses client_secret instead. if strings.Contains(u, "code_challenge") { t.Errorf("AuthorizeURL must not include PKCE params for confidential client, got: %s", 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) } } func TestAuthorizeURL_UsesBrowserBaseURLWhenConfigured(t *testing.T) { cfg := testConfig() cfg.BaseURL = "http://authelia.sso.svc.cluster.local:9091" cfg.BrowserBaseURL = "https://auth.coulomb.social" adapter := authelia.New(cfg, &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://auth.coulomb.social") { t.Errorf("expected URL to start with BrowserBaseURL, got: %s", u) } if strings.Contains(u, "authelia.sso.svc.cluster.local") { t.Errorf("browser redirect must not use internal service URL, 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) } gotID, gotSecret, ok := req.BasicAuth() if !ok { t.Error("expected client_secret_basic authentication") } if gotID != "keycape" || gotSecret != "test-secret" { t.Errorf("unexpected basic auth credentials for client %q", gotID) } rawBody, err := io.ReadAll(req.Body) if err != nil { t.Fatalf("read request body: %v", err) } form, err := url.ParseQuery(string(rawBody)) if err != nil { t.Fatalf("parse request body: %v", err) } if form.Get("client_secret") != "" { t.Error("client_secret must not be sent in the form body") } if form.Get("client_id") != "keycape" { t.Errorf("client_id: want keycape, got %q", form.Get("client_id")) } 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_UsesTokenBaseURLWhenConfigured(t *testing.T) { tokenBody := buildTokenResponse(map[string]interface{}{ "sub": "user-uuid-123", "preferred_username": "alice", }) var tokenURL string client := &mockHTTPClient{ doFn: func(req *http.Request) (*http.Response, error) { tokenURL = req.URL.String() return jsonResponse(tokenBody), nil }, } cfg := testConfig() cfg.BaseURL = "https://auth.coulomb.social" cfg.TokenBaseURL = "http://authelia.sso.svc.cluster.local:9091" adapter := authelia.New(cfg, client) if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"}); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.HasPrefix(tokenURL, "http://authelia.sso.svc.cluster.local:9091") { t.Errorf("expected token exchange to use TokenBaseURL, got: %s", tokenURL) } } 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") } }