generated from coulomb/repo-seed
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
386 lines
12 KiB
Go
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")
|
|
}
|
|
}
|