Files
key-cape/src/internal/adapters/authelia/adapter_test.go
tegwick 56d279a8e6
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Use basic auth for Authelia token exchange
2026-05-24 18:04:28 +02:00

386 lines
12 KiB
Go

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")
}
}