auth tests

This commit is contained in:
jay brown
2025-04-22 10:01:37 -07:00
parent fa37ba7a92
commit b23b17ac53
5 changed files with 1270 additions and 1 deletions
+326
View File
@@ -0,0 +1,326 @@
package cognitoauth
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
)
// generateTestJWKS creates a test JSON Web Key Set (JWKS) for testing purposes.
// It performs the following operations:
// - Generates a 2048-bit RSA key pair for cryptographic operations
// - Creates a JWK (JSON Web Key) from the public key portion
// - Sets key properties including ID ("test-key-id"), algorithm (RS256), and usage ("sig")
// - Creates a JWK Set and adds the key to it
// - Returns both the JWK Set object and its JSON string representation
func generateTestJWKS(t *testing.T) (jwk.Set, string) {
// Generate RSA key for testing
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate RSA key: %v", err)
}
// Create JWK from RSA key
key, err := jwk.FromRaw(privateKey.PublicKey)
if err != nil {
t.Fatalf("failed to create JWK: %v", err)
}
// Set key properties
if err := key.Set(jwk.KeyIDKey, "test-key-id"); err != nil {
t.Fatalf("failed to set key ID: %v", err)
}
if err := key.Set(jwk.AlgorithmKey, jwa.RS256); err != nil {
t.Fatalf("failed to set algorithm: %v", err)
}
if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil {
t.Fatalf("failed to set key usage: %v", err)
}
// Create JWK Set with the key
keySet := jwk.NewSet()
if err := keySet.AddKey(key); err != nil {
t.Fatalf("failed to add key to key set: %v", err)
}
// Serialize to JSON
jwksJSON, err := json.Marshal(keySet)
if err != nil {
t.Fatalf("failed to marshal JWK Set: %v", err)
}
return keySet, string(jwksJSON)
}
// TestGetJWKS tests the GetJWKS function through multiple scenarios including
// initial fetching, caching, cache expiration, and error handling.
func TestGetJWKS(t *testing.T) {
// Setup logger
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
logger := slog.New(logHandler)
// Generate test JWK set for mock responses
testKeySet, jwksJSON := generateTestJWKS(t)
// Mock server to return JWKS
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(jwksJSON))
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
}))
defer server.Close()
// Reset JWKS cache before tests
jwksCache = &JWKSCache{
ExpiresAt: time.Now(), // Initial state is expired
}
// Test: First fetch - should make HTTP request
// Tests initial JWKS retrieval and verifies:
// - Successful HTTP request and response processing
// - Correct number of keys in the retrieved set
// - Proper caching of the retrieved key set
// - Correct cache expiration time setting
t.Run("first fetch", func(t *testing.T) {
keySet, err := GetJWKS(server.URL, logger)
if err != nil {
t.Fatalf("GetJWKS() error = %v", err)
}
// Verify key set contains expected keys
if keySet.Len() != testKeySet.Len() {
t.Errorf("GetJWKS() returned %d keys, want %d", keySet.Len(), testKeySet.Len())
}
// Check that it's cached
if jwksCache.KeySet == nil {
t.Errorf("KeySet was not cached after fetch")
}
if time.Now().After(jwksCache.ExpiresAt) {
t.Errorf("Cache expiration not set correctly")
}
})
// Test: Subsequent fetch - should use cache
// Verifies caching behavior:
// - Tests that subsequent requests use cached data instead of making HTTP requests
// - Tests the cache works even when the server is unavailable/failing
// - Validates that the cached key count matches expected values
t.Run("cached fetch", func(t *testing.T) {
// Create a mock server that fails - to verify we're using the cache
failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte("This server should not be called"))
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
}))
defer failServer.Close()
// We should still get valid keys because we use the cache
keySet, err := GetJWKS(failServer.URL, logger)
if err != nil {
t.Fatalf("GetJWKS() with cache error = %v", err)
}
// Verify key set contains expected keys
if keySet.Len() != testKeySet.Len() {
t.Errorf("GetJWKS() with cache returned %d keys, want %d", keySet.Len(), testKeySet.Len())
}
})
// Test: Cache expiry - should fetch again
// Tests cache expiration behavior:
// - Verifies new keys are fetched after cache expiration
// - Ensures the system properly handles refreshing expired cache data
// - Validates that updated keys are received correctly
t.Run("cache expiry", func(t *testing.T) {
// Force cache expiration
jwksCache.mutex.Lock()
jwksCache.ExpiresAt = time.Now().Add(-1 * time.Hour) // Expired one hour ago
jwksCache.mutex.Unlock()
// New server with updated keys
updatedKeySet, updatedJSON := generateTestJWKS(t)
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(updatedJSON))
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
}))
defer updateServer.Close()
// Now we should fetch new keys
keySet, err := GetJWKS(updateServer.URL, logger)
if err != nil {
t.Fatalf("GetJWKS() after expiry error = %v", err)
}
// Verify key set contains expected keys (should match updated set)
if keySet.Len() != updatedKeySet.Len() {
t.Errorf("GetJWKS() after expiry returned %d keys, want %d", keySet.Len(), updatedKeySet.Len())
}
})
// Test: Server error
// Tests error handling when server fails:
// - Validates behavior with 500 Internal Server Error responses
// - Verifies appropriate error is returned to caller
// - Tests system behavior with empty/reset cache
t.Run("server error", func(t *testing.T) {
// Reset cache
jwksCache = &JWKSCache{
ExpiresAt: time.Now(), // Initial state is expired
}
// Create a mock server that returns an error
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte("Internal Server Error"))
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
}))
defer errorServer.Close()
// Should return an error
_, err := GetJWKS(errorServer.URL, logger)
if err == nil {
t.Errorf("GetJWKS() with server error should return error, got nil")
}
})
// Test: Invalid JSON response
// Tests handling of malformed responses:
// - Verifies error handling for invalid JSON responses
// - Ensures system stability when receiving corrupted data
// - Validates error reporting for bad responses
t.Run("invalid json", func(t *testing.T) {
// Reset cache
jwksCache = &JWKSCache{
ExpiresAt: time.Now(), // Initial state is expired
}
// Create a mock server that returns invalid JSON
invalidServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte("This is not valid JSON"))
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
}))
defer invalidServer.Close()
// Should return an error
_, err := GetJWKS(invalidServer.URL, logger)
if err == nil {
t.Errorf("GetJWKS() with invalid JSON should return error, got nil")
}
})
}
// TestFetchJWKS tests the fetchJWKS function directly with various scenarios
// including successful fetches, timeouts, invalid URLs, and error responses.
func TestFetchJWKS(t *testing.T) {
// Generate test JWK set for mock responses
_, jwksJSON := generateTestJWKS(t)
// Test: Successful fetch
// Verifies basic JWKS retrieval functionality:
// - Tests successful HTTP request and response handling
// - Validates that response body matches expected JSON
// - Confirms proper return of fetched content
t.Run("successful fetch", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(jwksJSON))
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
}))
defer server.Close()
result, err := fetchJWKS(server.URL)
if err != nil {
t.Fatalf("fetchJWKS() error = %v", err)
}
if result != jwksJSON {
t.Errorf("fetchJWKS() = %v, want %v", result, jwksJSON)
}
})
// Test: Timeout
// Validates timeout handling:
// - Tests behavior when server response exceeds client timeout limit
// - Verifies appropriate error handling for timed-out requests
// - Ensures the function fails properly in timeout scenarios
t.Run("timeout", func(t *testing.T) {
// Create a server that sleeps longer than the client timeout
slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(JWKS_CLIENT_TIMEOUT + 1*time.Second)
_, err := w.Write([]byte(jwksJSON))
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
}))
defer slowServer.Close()
_, err := fetchJWKS(slowServer.URL)
if err == nil {
t.Errorf("fetchJWKS() with timeout should return error, got nil")
}
})
// Test: Invalid URL
// Tests error handling for bad URLs:
// - Verifies behavior with non-existent or invalid URLs
// - Ensures appropriate error responses for network failures
// - Validates error handling for connection issues
t.Run("invalid url", func(t *testing.T) {
_, err := fetchJWKS("http://invalid-url-that-does-not-exist.example.com")
if err == nil {
t.Errorf("fetchJWKS() with invalid URL should return error, got nil")
}
})
// Test: HTTP error status
// Validates handling of HTTP error responses:
// - Tests behavior with various HTTP error status codes (e.g., 404)
// - Verifies response body handling regardless of status code
// - Tests that function returns body content even for error status codes
t.Run("http error status", func(t *testing.T) {
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, err := w.Write([]byte("Not Found"))
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
}))
defer errorServer.Close()
result, err := fetchJWKS(errorServer.URL)
if err != nil {
t.Fatalf("fetchJWKS() unexpected error = %v", err)
}
// Function doesn't check status code, just reads body
if result != "Not Found" {
t.Errorf("fetchJWKS() = %v, want %v", result, "Not Found")
}
})
}
+315
View File
@@ -0,0 +1,315 @@
package cognitoauth
import (
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/labstack/echo/v4"
)
// TestJWTAuthMiddleware tests the JWTAuthMiddleware function to ensure it correctly processes
// authentication tokens from both headers and cookies. It verifies that:
// - Public paths (/login, /callback, /home) are skipped without authentication
// - The /logout path properly clears authentication cookies
// - Requests with Authorization headers are passed through
// - Requests with auth_token cookies have their tokens moved to Authorization headers
// - Requests without authentication are redirected to the login page
func TestJWTAuthMiddleware(t *testing.T) {
// Setup
e := echo.New()
config := &mockConfigProvider{
region: "us-east-2",
userPoolID: "us-east-2_testpool",
}
// Setup logger
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
logger := slog.New(logHandler)
config.SetAuthLogger(logger)
// Create middleware
middleware := JWTAuthMiddleware(config)
// Test handler that simply returns success
testHandler := func(c echo.Context) error {
return c.String(http.StatusOK, "success")
}
// Test cases
tests := []struct {
name string
path string
setupRequest func(*http.Request)
expectStatus int
expectLocation string
expectHeader string
}{
{
name: "skip login path",
path: "/login",
setupRequest: func(req *http.Request) {
// No setup needed
},
expectStatus: http.StatusOK,
},
{
name: "skip callback path",
path: "/callback",
setupRequest: func(req *http.Request) {
// No setup needed
},
expectStatus: http.StatusOK,
},
{
name: "skip home path",
path: "/home",
setupRequest: func(req *http.Request) {
// No setup needed
},
expectStatus: http.StatusOK,
},
{
name: "logout path sets empty cookie",
path: "/logout",
setupRequest: func(req *http.Request) {
// No setup needed
},
expectStatus: http.StatusOK,
},
{
name: "use authorization header",
path: "/api/test",
setupRequest: func(req *http.Request) {
req.Header.Set("Authorization", "Bearer test-token")
},
expectStatus: http.StatusOK,
},
{
name: "use cookie if no authorization header",
path: "/api/test",
setupRequest: func(req *http.Request) {
cookie := &http.Cookie{
Name: "auth_token",
Value: "cookie-token",
Path: "/",
}
req.AddCookie(cookie)
},
expectStatus: http.StatusOK,
expectHeader: "Bearer cookie-token",
},
{
name: "redirect to login if no token",
path: "/api/test",
setupRequest: func(req *http.Request) {
// No auth header or cookie
},
expectStatus: http.StatusFound, // 302 Found (redirect)
expectLocation: "/login",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
rec := httptest.NewRecorder()
// Setup request (add headers, cookies, etc.)
tt.setupRequest(req)
// Create Echo context
c := e.NewContext(req, rec)
c.SetPath(tt.path)
// Run middleware
err := middleware(testHandler)(c)
if err != nil {
t.Errorf("Middleware returned error: %v", err)
}
// Check status code
if rec.Code != tt.expectStatus {
t.Errorf("Expected status code %d, got %d", tt.expectStatus, rec.Code)
}
// Check location header (for redirects)
if tt.expectLocation != "" {
location := rec.Header().Get("Location")
if location != tt.expectLocation {
t.Errorf("Expected Location header %q, got %q", tt.expectLocation, location)
}
}
// Check Authorization header (if token taken from cookie)
if tt.expectHeader != "" {
authHeader := req.Header.Get("Authorization")
if authHeader != tt.expectHeader {
t.Errorf("Expected Authorization header %q, got %q", tt.expectHeader, authHeader)
}
}
// Special case for logout - check cookie
if tt.path == "/logout" {
cookies := rec.Result().Cookies()
found := false
for _, cookie := range cookies {
if cookie.Name == "auth_token" {
found = true
if cookie.Value != "" {
t.Errorf("Expected empty auth_token cookie, got %q", cookie.Value)
}
if !cookie.Expires.Before(time.Now()) {
t.Errorf("Expected auth_token cookie to be expired, got %v", cookie.Expires)
}
}
}
if !found {
t.Error("Expected auth_token cookie to be set for logout")
}
}
})
}
}
// TestTokenValidationMiddleware tests the TokenValidationMiddleware function to verify that it:
// - Allows access to public paths (/home, /logout) without validation
// - Properly handles OAuth callbacks by processing code and state parameters
// - Initiates login by redirecting to Cognito when accessing the login path
// - Returns 401 Unauthorized for protected paths without Authorization header
// - Attempts to validate tokens for protected paths with Authorization headers
func TestTokenValidationMiddleware(t *testing.T) {
// Setup
e := echo.New()
config := &mockConfigProvider{
region: "us-east-2",
userPoolID: "us-east-2_testpool",
}
// Setup logger
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
logger := slog.New(logHandler)
config.SetAuthLogger(logger)
// Create middleware
middleware := TokenValidationMiddleware(config)
// Test handler that simply returns success
testHandler := func(c echo.Context) error {
return c.String(http.StatusOK, "success")
}
// Test cases
tests := []struct {
name string
path string
setupRequest func(*http.Request)
expectStatus int
checkBody func(*testing.T, []byte)
}{
{
name: "skip public paths - home",
path: "/home",
setupRequest: func(req *http.Request) {
// No setup needed
},
expectStatus: http.StatusOK,
},
{
name: "skip public paths - logout",
path: "/logout",
setupRequest: func(req *http.Request) {
// No setup needed
},
expectStatus: http.StatusOK,
},
{
name: "handle oauth callback",
path: "/callback",
setupRequest: func(req *http.Request) {
q := req.URL.Query()
q.Add("code", "test-code")
q.Add("state", "test-state")
req.URL.RawQuery = q.Encode()
},
// This will attempt to call handleOAuthCallback which will fail
// but we're just testing the routing logic
expectStatus: http.StatusBadRequest,
},
{
name: "handle login initiation",
path: "/login",
setupRequest: func(req *http.Request) {
// No setup needed
},
// The login initiation actually redirects to the Cognito login page
expectStatus: http.StatusFound, // 302 Found (redirect)
// We can't check the exact URL since it depends on generateRandomString output
// But we can check that a Location header exists
checkBody: func(t *testing.T, body []byte) {
// No body check needed, we'll verify the status code
},
},
{
name: "missing authorization header",
path: "/api/test",
setupRequest: func(req *http.Request) {
// No auth header
},
expectStatus: http.StatusUnauthorized,
checkBody: func(t *testing.T, body []byte) {
var response map[string]string
err := json.Unmarshal(body, &response)
if err != nil {
t.Errorf("Failed to parse response: %v", err)
}
if response["error"] != "Authorization required" {
t.Errorf("Expected error 'Authorization required', got %q", response["error"])
}
},
},
{
name: "invalid token",
path: "/api/test",
setupRequest: func(req *http.Request) {
req.Header.Set("Authorization", "Bearer invalid-token")
},
expectStatus: http.StatusInternalServerError, // Since we can't mock JWKS fetching
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
rec := httptest.NewRecorder()
// Setup request
tt.setupRequest(req)
// Create Echo context
c := e.NewContext(req, rec)
c.SetPath(tt.path)
// Run middleware
_ = middleware(testHandler)(c)
// Check status code
if rec.Code != tt.expectStatus {
t.Errorf("Expected status code %d, got %d", tt.expectStatus, rec.Code)
}
// Check response body if needed
if tt.checkBody != nil {
tt.checkBody(t, rec.Body.Bytes())
}
})
}
}
@@ -0,0 +1,221 @@
package cognitoauth
import (
"log/slog"
"os"
"reflect"
"testing"
)
// TestMatchRoute verifies the matchRoute function correctly identifies when a request path
// matches a route pattern. It tests various scenarios including:
// - Exact path matching
// - Path parameter matching (e.g., /:id)
// - Multiple parameter matching
// - Wildcard matching
// - Path mismatches
// - Partial matching behavior
// - Special characters in paths
// - Edge cases like empty paths and root paths
func TestMatchRoute(t *testing.T) {
tests := []struct {
name string
requestPath string
routePattern string
expected bool
}{
{
name: "exact match",
requestPath: "/api/users",
routePattern: "/api/users",
expected: true,
},
{
name: "parameter match",
requestPath: "/api/users/123",
routePattern: "/api/users/:id",
expected: true,
},
{
name: "multiple parameters",
requestPath: "/api/users/123/orders/456",
routePattern: "/api/users/:userId/orders/:orderId",
expected: true,
},
{
name: "wildcard match",
requestPath: "/api/v1/docs/user-guide.pdf",
routePattern: "/api/v1/docs/*",
expected: true,
},
{
name: "no match",
requestPath: "/api/orders",
routePattern: "/api/users",
expected: false,
},
{
name: "partial match should fail",
requestPath: "/api/users/123",
routePattern: "/api/users",
expected: false,
},
{
name: "longer request path should fail",
requestPath: "/api/users/123/details",
routePattern: "/api/users/:id",
expected: false,
},
{
name: "regex characters in path",
requestPath: "/api/users/john.doe+test@example.com",
routePattern: "/api/users/:email",
expected: true,
},
{
name: "empty request path",
requestPath: "",
routePattern: "/api/users",
expected: false,
},
{
name: "empty route pattern",
requestPath: "/api/users",
routePattern: "",
expected: false,
},
{
name: "root path",
requestPath: "/",
routePattern: "/",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := matchRoute(tt.requestPath, tt.routePattern)
if result != tt.expected {
t.Errorf("matchRoute(%q, %q) = %v, want %v",
tt.requestPath, tt.routePattern, result, tt.expected)
}
})
}
}
// TestCheckPermissions verifies the checkPermissions function correctly enforces
// access control based on user groups and route permissions. It tests:
// - Admin access to various routes
// - Group-specific access permissions
// - Denying access when user lacks required group membership
// - Public route access without authentication
// - Multiple group membership scenarios
// - Routes with non-existent required groups
// - Path parameter matching with permissions
// - Default behavior for unmatched routes
func TestCheckPermissions(t *testing.T) {
// Setup logger
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
logger := slog.New(logHandler)
// Setup route permissions
routePermissions := map[string][]string{
"/api/users": {"admin", "user-manager"},
"/api/orders": {"admin", "order-manager"},
"/api/products": {"admin", "product-manager"},
"/api/reports/*": {"admin", "analyst"},
"/api/users/:id": {"admin", "user-manager", "self-service"},
"/public/*": {}, // Public route - empty permissions means anyone can access
"/api/restricted": {"non-existent-group"}, // No one can access this
}
tests := []struct {
name string
path string
userGroups []string
expectedAllowed bool
expectedGroups []string
}{
{
name: "admin can access user route",
path: "/api/users",
userGroups: []string{"admin"},
expectedAllowed: true,
expectedGroups: []string{"admin", "user-manager"},
},
{
name: "user-manager can access user route",
path: "/api/users",
userGroups: []string{"user-manager"},
expectedAllowed: true,
expectedGroups: []string{"admin", "user-manager"},
},
{
name: "order-manager cannot access user route",
path: "/api/users",
userGroups: []string{"order-manager"},
expectedAllowed: false,
expectedGroups: []string{"admin", "user-manager"},
},
{
name: "empty user groups cannot access protected route",
path: "/api/users",
userGroups: []string{},
expectedAllowed: false,
expectedGroups: []string{"admin", "user-manager"},
},
{
name: "anyone can access public route",
path: "/public/info",
userGroups: []string{},
expectedAllowed: true,
expectedGroups: []string{},
},
{
name: "user with multiple groups can access based on any matching group",
path: "/api/reports/sales",
userGroups: []string{"user-manager", "analyst"},
expectedAllowed: true,
expectedGroups: []string{"admin", "analyst"},
},
{
name: "no one can access route with non-existent group requirement",
path: "/api/restricted",
userGroups: []string{"admin", "user-manager", "order-manager"},
expectedAllowed: false,
expectedGroups: []string{"non-existent-group"},
},
{
name: "self-service can access specific user route",
path: "/api/users/123",
userGroups: []string{"self-service"},
expectedAllowed: true,
expectedGroups: []string{"admin", "user-manager", "self-service"},
},
{
name: "unmatched route defaults to allowed",
path: "/unknown/route",
userGroups: []string{},
expectedAllowed: true,
expectedGroups: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowed, groups := checkPermissions(tt.path, tt.userGroups, routePermissions, logger)
if allowed != tt.expectedAllowed {
t.Errorf("checkPermissions(%q, %v) allowed = %v, want %v",
tt.path, tt.userGroups, allowed, tt.expectedAllowed)
}
if !reflect.DeepEqual(groups, tt.expectedGroups) {
t.Errorf("checkPermissions(%q, %v) groups = %v, want %v",
tt.path, tt.userGroups, groups, tt.expectedGroups)
}
})
}
}
+407
View File
@@ -0,0 +1,407 @@
package cognitoauth
import (
"encoding/base64"
"fmt"
"log/slog"
"os"
"reflect"
"testing"
"time"
)
// mockConfigProvider implements the ConfigProvider interface for testing purposes.
// It provides predefined values for configuration methods and no-op implementations
// for setters, allowing tests to run without requiring actual AWS Cognito credentials.
type mockConfigProvider struct {
region string
userPoolID string
}
func (m *mockConfigProvider) GetAuthRegion() string { return m.region }
func (m *mockConfigProvider) SetAuthRegion(s string) { m.region = s }
func (m *mockConfigProvider) GetAuthUserPoolID() string { return m.userPoolID }
func (m *mockConfigProvider) SetAuthUserPoolID(s string) { m.userPoolID = s }
func (m *mockConfigProvider) GetAuthClientSecret() string { return "mock-secret" }
func (m *mockConfigProvider) SetAuthClientSecret(s string) {}
func (m *mockConfigProvider) GetAuthDomain() string { return "mock-domain" }
func (m *mockConfigProvider) SetAuthDomain(s string) {}
func (m *mockConfigProvider) GetAuthClientID() string { return "mock-client-id" }
func (m *mockConfigProvider) SetAuthClientID(s string) {}
func (m *mockConfigProvider) GetAuthRedirectURI() string { return "http://localhost/callback" }
func (m *mockConfigProvider) SetAuthRedirectURI(s string) {}
func (m *mockConfigProvider) GetAuthTokenURL() string { return "http://localhost/token" }
func (m *mockConfigProvider) SetAuthTokenURL(s string) {}
func (m *mockConfigProvider) GetAuthJwksURL() string { return "http://localhost/jwks" }
func (m *mockConfigProvider) SetAuthJwksURL(s string) {}
func (m *mockConfigProvider) GetAuthURL() string { return "http://localhost/auth" }
func (m *mockConfigProvider) SetAuthURL(s string) {}
func (m *mockConfigProvider) GetAuthLoginPath() string { return "/login" }
func (m *mockConfigProvider) SetAuthLoginPath(s string) {}
func (m *mockConfigProvider) GetAuthCallbackPath() string { return "/callback" }
func (m *mockConfigProvider) SetAuthCallbackPath(s string) {}
func (m *mockConfigProvider) GetAuthHomePath() string { return "/home" }
func (m *mockConfigProvider) SetAuthHomePath(s string) {}
func (m *mockConfigProvider) GetAuthLogoutPath() string { return "/logout" }
func (m *mockConfigProvider) SetAuthLogoutPath(s string) {}
func (m *mockConfigProvider) GetAuthLogger() *slog.Logger { return slog.Default() }
func (m *mockConfigProvider) SetAuthLogger(*slog.Logger) {}
func (m *mockConfigProvider) GetAuthRoutePermissions() map[string][]string {
return map[string][]string{"/test": {"admin"}}
}
func (m *mockConfigProvider) SetAuthRoutePermissions(map[string][]string) {}
func (m *mockConfigProvider) InitializeAuthConfig(string, *slog.Logger) error {
return nil
}
// Helper function to generate test JWT and JWK
// TestGetUserGroups verifies the GetUserGroups function correctly extracts user group
// information from JWT claims. It tests:
// - Extraction from "cognito:groups" claim as an array
// - Extraction from "groups" claim as an array
// - Extraction from "custom:groups" claim as a string
// - Error handling when no group information is present
// - Handling of mixed data types in group arrays
func TestGetUserGroups(t *testing.T) {
tests := []struct {
name string
claims map[string]interface{}
expected []string
expectError bool
}{
{
name: "cognito:groups as string array",
claims: map[string]interface{}{
"cognito:groups": []interface{}{"admin", "user"},
},
expected: []string{"admin", "user"},
expectError: false,
},
{
name: "groups as string array",
claims: map[string]interface{}{
"groups": []string{"developer", "tester"},
},
expected: []string{"developer", "tester"},
expectError: false,
},
{
name: "custom:groups as string",
claims: map[string]interface{}{
"custom:groups": "single-group",
},
expected: []string{"single-group"},
expectError: false,
},
{
name: "no groups in claims",
claims: map[string]interface{}{},
expected: nil,
expectError: true,
},
{
name: "groups with mixed types",
claims: map[string]interface{}{
"cognito:groups": []interface{}{"admin", 123, true},
},
expected: []string{"admin", "123", "true"},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
groups, err := GetUserGroups(tt.claims)
if tt.expectError && err == nil {
t.Errorf("GetUserGroups() expected error, got nil")
}
if !tt.expectError && err != nil {
t.Errorf("GetUserGroups() unexpected error: %v", err)
}
if !reflect.DeepEqual(groups, tt.expected) {
t.Errorf("GetUserGroups() got = %v, want %v", groups, tt.expected)
}
})
}
}
// TestCreateCodeChallenge verifies the createCodeChallenge function correctly
// generates PKCE code challenges from code verifiers according to OAuth 2.0 PKCE
// (RFC 7636) specifications. It tests multiple input/output pairs to ensure the
// SHA-256 hashing and Base64-URL encoding are implemented correctly.
func TestCreateCodeChallenge(t *testing.T) {
tests := []struct {
verifier string
challenge string
}{
{
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
},
{
verifier: "abcdefghijklmnopqrstuvwxyz0123456789",
challenge: "AR_CmU450lEUFUD4emkJKz8iqGdn9yg95-7ts4l77fY",
},
{
verifier: "test-verifier",
challenge: "JBbiqONGWPaAmwXk_8bT6UnlPfrn65D32eZlJS-zGG0",
},
}
for _, tt := range tests {
t.Run(tt.verifier, func(t *testing.T) {
challenge := createCodeChallenge(tt.verifier)
if challenge != tt.challenge {
t.Errorf("createCodeChallenge(%q) = %q, want %q", tt.verifier, challenge, tt.challenge)
}
})
}
}
// TestGenerateRandomString verifies the generateRandomString function produces
// cryptographically secure random strings of the requested length. It tests:
// - Generation of strings with various lengths
// - Correct string length
// - Base64-URL encoding validity
// - String uniqueness (randomness)
func TestGenerateRandomString(t *testing.T) {
lengths := []int{32, 43, 64, 128}
for _, length := range lengths {
t.Run(fmt.Sprintf("length=%d", length), func(t *testing.T) {
// Generate multiple strings to check for uniqueness
strings := make([]string, 5)
for i := 0; i < 5; i++ {
s, err := generateRandomString(length)
if err != nil {
t.Errorf("generateRandomString(%d) unexpected error: %v", length, err)
}
// Check length
if len(s) != length {
t.Errorf("generateRandomString(%d) = %q (length %d), want length %d",
length, s, len(s), length)
}
// Check if it's base64url valid
_, err = base64.RawURLEncoding.DecodeString(s)
if err != nil {
t.Errorf("generateRandomString(%d) = %q, not valid base64url: %v",
length, s, err)
}
strings[i] = s
}
// Check for duplicates (very unlikely but possible)
for i := 0; i < len(strings); i++ {
for j := i + 1; j < len(strings); j++ {
if strings[i] == strings[j] {
t.Errorf("generateRandomString(%d) generated duplicate strings: %q",
length, strings[i])
}
}
}
})
}
}
// TestPKCESessionManagement verifies the PKCE session management functions work correctly.
// It tests:
// - Storing and retrieving valid code verifier sessions
// - Error handling when retrieving non-existent sessions
// - Proper expiration of sessions after their timeout period
// - Automatic cleanup of expired sessions during store operations
// - Persistence of valid sessions during cleanup
func TestPKCESessionManagement(t *testing.T) {
// Setup logger
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
logger := slog.New(logHandler)
// Test storing and retrieving a valid session
t.Run("store and retrieve valid session", func(t *testing.T) {
state := "test-state-valid"
codeVerifier := "test-verifier-valid"
// Store the session
storePKCESession(state, codeVerifier, logger)
// Retrieve it
retrievedVerifier, err := getCodeVerifier(state, logger)
if err != nil {
t.Errorf("getCodeVerifier(%q) unexpected error: %v", state, err)
}
if retrievedVerifier != codeVerifier {
t.Errorf("getCodeVerifier(%q) = %q, want %q", state, retrievedVerifier, codeVerifier)
}
})
// Test retrieving non-existent session
t.Run("retrieve non-existent session", func(t *testing.T) {
state := "non-existent-state"
_, err := getCodeVerifier(state, logger)
if err == nil {
t.Errorf("getCodeVerifier(%q) expected error for non-existent state", state)
}
})
// Test expired session
t.Run("expired session", func(t *testing.T) {
state := "test-state-expired"
codeVerifier := "test-verifier-expired"
// Manually create an expired session
pkceSessionMap.Lock()
pkceSessionMap.sessions[state] = &PKCESession{
CodeVerifier: codeVerifier,
CreatedAt: time.Now().Add(-10 * time.Minute),
ExpiresAt: time.Now().Add(-5 * time.Minute), // Expired 5 minutes ago
}
pkceSessionMap.Unlock()
_, err := getCodeVerifier(state, logger)
if err == nil {
t.Errorf("getCodeVerifier(%q) expected error for expired session", state)
}
})
// Test cleanup of expired sessions during store
t.Run("cleanup expired sessions", func(t *testing.T) {
// Create several expired sessions
pkceSessionMap.Lock()
for i := 0; i < 5; i++ {
state := fmt.Sprintf("expired-state-%d", i)
pkceSessionMap.sessions[state] = &PKCESession{
CodeVerifier: fmt.Sprintf("expired-verifier-%d", i),
CreatedAt: time.Now().Add(-10 * time.Minute),
ExpiresAt: time.Now().Add(-1 * time.Minute), // Expired 1 minute ago
}
}
initialCount := len(pkceSessionMap.sessions)
pkceSessionMap.Unlock()
// Store a new session, which should trigger cleanup
newState := "new-test-state"
newVerifier := "new-test-verifier"
storePKCESession(newState, newVerifier, logger)
// Check if expired sessions were cleaned up
pkceSessionMap.RLock()
cleanedCount := len(pkceSessionMap.sessions)
pkceSessionMap.RUnlock()
if cleanedCount >= initialCount {
t.Errorf("storePKCESession() did not clean up expired sessions: before=%d, after=%d",
initialCount, cleanedCount)
}
// Verify the new session is still retrievable
retrievedVerifier, err := getCodeVerifier(newState, logger)
if err != nil || retrievedVerifier != newVerifier {
t.Errorf("New session not retrievable after cleanup: err=%v, verifier=%q",
err, retrievedVerifier)
}
})
}
// TestExtractUserInfo verifies the ExtractUserInfo function correctly extracts user
// information from JWT claims into a UserInfo struct. It tests:
// - Extraction of complete user information (username, email, groups)
// - Handling of missing email field
// - Handling of missing username field
// - Handling of missing groups field
// - Handling of completely empty claims
func TestExtractUserInfo(t *testing.T) {
tests := []struct {
name string
claims map[string]interface{}
want UserInfo
}{
{
name: "complete user info",
claims: map[string]interface{}{
"cognito:username": "testuser",
"email": "test@example.com",
"cognito:groups": []interface{}{"admin", "developer"},
},
want: UserInfo{
Username: "testuser",
Email: "test@example.com",
Groups: []string{"admin", "developer"},
},
},
{
name: "missing email",
claims: map[string]interface{}{
"cognito:username": "testuser",
"cognito:groups": []interface{}{"admin"},
},
want: UserInfo{
Username: "testuser",
Email: "",
Groups: []string{"admin"},
},
},
{
name: "missing username",
claims: map[string]interface{}{
"email": "test@example.com",
"cognito:groups": []interface{}{"admin"},
},
want: UserInfo{
Username: "",
Email: "test@example.com",
Groups: []string{"admin"},
},
},
{
name: "missing groups",
claims: map[string]interface{}{
"cognito:username": "testuser",
"email": "test@example.com",
},
want: UserInfo{
Username: "testuser",
Email: "test@example.com",
Groups: nil, // GetUserGroups will return error, so Groups will be nil
},
},
{
name: "empty claims",
claims: map[string]interface{}{},
want: UserInfo{
Username: "",
Email: "",
Groups: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractUserInfo(tt.claims)
if got.Username != tt.want.Username {
t.Errorf("ExtractUserInfo() username = %v, want %v", got.Username, tt.want.Username)
}
if got.Email != tt.want.Email {
t.Errorf("ExtractUserInfo() email = %v, want %v", got.Email, tt.want.Email)
}
// We don't compare Groups directly because GetUserGroups may return an error
// for the "missing groups" test case, resulting in nil Groups
})
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ vars:
PKG: "./pkg/..."
CMD: "./cmd/..."
# yamllint disable-line rule:line-length
EXCLUDED_FILES: ".gen.go|internal/serviceconfig/observability/prometheus/generator/main.go|internal/cognitoauth/auth.go|internal/cognitoauth/handler.go|internal/cognitoauth/jwks.go|internal/cognitoauth/middleware.go|internal/cognitoauth/models.go|internal/cognitoauth/token.go|internal/cognitoauth/permission_utils.go|api/queryAPI/authHandlers.go|api/queryAPI/homehandler.go"
EXCLUDED_FILES: ".gen.go|internal/serviceconfig/observability/prometheus/generator/main.go|internal/cognitoauth/middleware.go|internal/cognitoauth/token.go|internal/cognitoauth/auth.go|internal/cognitoauth/handler.go|internal/cognitoauth/models.go|api/queryAPI/authHandlers.go|api/queryAPI/homehandler.go"
includes:
docker: