diff --git a/cmd/cognito_test/cognito.pkce/main.go b/cmd/cognito_test/cognito.pkce/main.go new file mode 100644 index 00000000..fc4e8d32 --- /dev/null +++ b/cmd/cognito_test/cognito.pkce/main.go @@ -0,0 +1,784 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +// CognitoTokenResponse represents the response from the token endpoint +// Returned by Cognito when exchanging an authorization code for tokens +type CognitoTokenResponse struct { + IDToken string `json:"id_token"` // OpenID Connect ID token containing user claims + AccessToken string `json:"access_token"` // OAuth2 access token for accessing resources + RefreshToken string `json:"refresh_token"` // Token used to get new access tokens without re-authentication + ExpiresIn int `json:"expires_in"` // Token validity period in seconds + TokenType string `json:"token_type"` // Type of token, typically "Bearer" +} + +// CognitoConfig holds the configuration for AWS Cognito +// Contains all necessary parameters to interact with Cognito endpoints +type CognitoConfig struct { + ClientID string // OAuth2 client ID registered with Cognito + RedirectURI string // URL where Cognito redirects after authentication + TokenURL string // Cognito endpoint for token operations + JwksURL string // URL for JSON Web Key Set (for token verification) + UserPoolID string // Cognito User Pool ID + AuthURL string // Cognito authorization endpoint for initiating login +} + +// PKCESession stores PKCE code verifier for verification +// We need to store the code verifier temporarily to use during token exchange +type PKCESession struct { + CodeVerifier string + CreatedAt time.Time + ExpiresAt time.Time +} + +// Global map to store PKCE sessions by state parameter +// In a production environment, this should be replaced with a proper session store +var pkceSessionMap = struct { + sync.RWMutex + sessions map[string]*PKCESession +}{ + sessions: make(map[string]*PKCESession), +} + +// JWKSCache caches the JWKS to avoid frequent fetches +// Implements a thread-safe caching mechanism with expiration +type JWKSCache struct { + KeySet jwk.Set // The cached JSON Web Key Set + ExpiresAt time.Time // Expiration time for the cache + mutex sync.RWMutex // Mutex for thread-safe access +} + +// Global JWKS cache instance +var jwksCache = &JWKSCache{ + ExpiresAt: time.Now(), // Initial state is expired, forcing a fetch on first use +} + +// GetJWKS returns the cached JWKS or fetches a new one if needed +// This function implements caching logic to minimize external requests +// Used during token validation to get the key set for signature verification +func GetJWKS(jwksURL string, logger *slog.Logger) (jwk.Set, error) { + // Try to use cached JWKS first (read lock) + jwksCache.mutex.RLock() + if jwksCache.KeySet != nil && time.Now().Before(jwksCache.ExpiresAt) { + defer jwksCache.mutex.RUnlock() + logger.Debug("Using cached JWKS") + return jwksCache.KeySet, nil + } + jwksCache.mutex.RUnlock() + + // Need to fetch new JWKS (write lock) + jwksCache.mutex.Lock() + defer jwksCache.mutex.Unlock() + + // Double-check expiration after acquiring the write lock + if jwksCache.KeySet != nil && time.Now().Before(jwksCache.ExpiresAt) { + return jwksCache.KeySet, nil + } + + logger.Info("Fetching new JWKS", "url", jwksURL) + jwksJSON, err := fetchJWKS(jwksURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch JWKS: %w", err) + } + + keySet, err := jwk.ParseString(jwksJSON) + if err != nil { + return nil, fmt.Errorf("failed to parse JWKS: %w", err) + } + + jwksCache.KeySet = keySet + // Cache for 24 hours - you can adjust this based on your needs + jwksCache.ExpiresAt = time.Now().Add(24 * time.Hour) + + return keySet, nil +} + +// TokenValidationMiddleware verifies JWT tokens efficiently +// This is the primary middleware that handles both authentication and authorization +// Applied to all routes to enforce security requirements +func TokenValidationMiddleware(config *CognitoConfig, routePermissions map[string][]string, logger *slog.Logger) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + requestPath := c.Request().URL.Path + logger.Debug("Processing request", "path", requestPath, "method", c.Request().Method) + + // Special case for the OAuth callback path + if requestPath == "/query" && (c.QueryParam("code") != "" || c.QueryParam("error") != "") { + logger.Debug("Handling OAuth callback", + "has_code", c.QueryParam("code") != "", + "has_error", c.QueryParam("error") != "") + // Skip token validation for OAuth callback - it will be handled by the callback handler + return handleOAuthCallback(c, config, routePermissions, logger) + } + + // Initialize login flow if this is a login request + if requestPath == "/login" { + return initiateLoginWithPKCE(c, config, logger) + } + + // Get authorization header + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + logger.Warn("No authorization header provided") + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization required"}) + } + + // Extract token + tokenStr := authHeader + if strings.HasPrefix(authHeader, "Bearer ") { + tokenStr = authHeader[7:] + } + + // Get JWKS (cached if possible) + keySet, err := GetJWKS(config.JwksURL, logger) + if err != nil { + logger.Error("Failed to get JWKS", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"}) + } + + // Verify token + claims, err := verifyToken(tokenStr, keySet, *config) + if err != nil { + logger.Warn("Token verification failed", "error", err) + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"}) + } + + // Debug logging if enabled + if os.Getenv("DEBUG") == "true" { + logger.Info("Token verified successfully", "claims", claims) + } + + // Extract user groups + userGroups, err := GetUserGroups(claims) + if err != nil { + logger.Warn("Failed to extract user groups", "error", err) + userGroups = []string{} // Empty array if no groups found + } + + // Store in context + c.Set("user_claims", claims) + c.Set("user_groups", userGroups) + + // Check authorization + authorized, requiredGroups := checkPermissions(requestPath, userGroups, routePermissions, logger) + if !authorized { + logger.Warn("Access denied", + "path", requestPath, + "user", claims["cognito:username"], + "groups", userGroups, + "required", requiredGroups) + + return c.JSON(http.StatusForbidden, map[string]interface{}{ + "error": "Insufficient permissions", + "message": "User doesn't have the required group membership", + "username": claims["cognito:username"], + "groups": userGroups, + "required_groups": requiredGroups, + }) + } + + // User is authenticated and authorized + return next(c) + } + } +} + +// checkPermissions checks if the user has the required permissions +// Used by both the main token validation middleware and the OAuth callback handler +// Returns true if authorized, false if not, along with the required groups +func checkPermissions(path string, userGroups []string, routePermissions map[string][]string, logger *slog.Logger) (bool, []string) { + // Find matching route pattern + var requiredGroups []string + var matched bool + + for pattern, groups := range routePermissions { + if matchRoute(path, pattern) { + requiredGroups = groups + matched = true + logger.Debug("Found matching route pattern", "pattern", pattern, "requiredGroups", requiredGroups) + break + } + } + + if !matched { + // No matching pattern found - could either allow or deny by default + logger.Info("No matching route pattern found for path", "path", path) + return true, nil // Allowing by default + } + + // Check if user has any of the required groups + for _, requiredGroup := range requiredGroups { + for _, userGroup := range userGroups { + if userGroup == requiredGroup { + logger.Debug("User has required group", "group", requiredGroup) + return true, requiredGroups + } + } + } + + return false, requiredGroups +} + +// initiateLoginWithPKCE generates PKCE code challenge and redirects to Cognito +// This function starts the OAuth authorization flow with PKCE +func initiateLoginWithPKCE(c echo.Context, config *CognitoConfig, logger *slog.Logger) error { + // Generate random state parameter to prevent CSRF + state, err := generateRandomString(32) + if err != nil { + logger.Error("Failed to generate state parameter", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authentication initialization failed"}) + } + + // Generate code verifier (random string between 43-128 chars) + codeVerifier, err := generateRandomString(64) + if err != nil { + logger.Error("Failed to generate code verifier", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authentication initialization failed"}) + } + + // Create code challenge from verifier (SHA256 + Base64URL without padding) + codeChallenge := createCodeChallenge(codeVerifier) + + // Store in session for later verification (with expiration time) + storePKCESession(state, codeVerifier, logger) + + // Build authorization URL with PKCE parameters + params := url.Values{} + params.Set("client_id", config.ClientID) + params.Set("response_type", "code") + params.Set("redirect_uri", config.RedirectURI) + params.Set("scope", "openid email profile") + params.Set("state", state) + params.Set("code_challenge", codeChallenge) + params.Set("code_challenge_method", "S256") + + authURL := fmt.Sprintf("%s?%s", config.AuthURL, params.Encode()) + + if os.Getenv("DEBUG") == "true" { + logger.Debug("Initiating login with PKCE", + "code_verifier", codeVerifier, + "code_challenge", codeChallenge, + "state", state, + "redirect_uri", config.RedirectURI) + } + + // Redirect user to Cognito login page + return c.Redirect(http.StatusFound, authURL) +} + +// handleOAuthCallback handles the OAuth 2.0 authorization code flow callback with PKCE +// Called when Cognito redirects back to our application with an authorization code +// Exchanges the code for tokens, verifies them, and checks authorization +func handleOAuthCallback(c echo.Context, config *CognitoConfig, routePermissions map[string][]string, logger *slog.Logger) error { + // Extract the authorization code and state + code := c.QueryParam("code") + if code == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing code parameter"}) + } + + state := c.QueryParam("state") + if state == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing state parameter"}) + } + + // Check for OAuth errors + errorMsg := c.QueryParam("error") + if errorMsg != "" { + errorDesc := c.QueryParam("error_description") + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": errorMsg, + "error_description": errorDesc, + }) + } + + // Retrieve stored code verifier + codeVerifier, err := getCodeVerifier(state, logger) + if err != nil { + logger.Error("Failed to retrieve code verifier", "error", err, "state", state) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid or expired session"}) + } + + // Exchange the code for tokens using PKCE + tokens, err := exchangeCodeForTokensWithPKCE(code, codeVerifier, *config, logger) + if err != nil { + logger.Error("Failed to exchange code for tokens", "error", err) + return c.JSON(http.StatusUnauthorized, map[string]string{"error": err.Error()}) + } + + // Get the JWKS for token verification + keySet, err := GetJWKS(config.JwksURL, logger) + if err != nil { + logger.Error("Failed to fetch JWKS", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"}) + } + + // Verify ID token + idTokenClaims, err := verifyToken(tokens.IDToken, keySet, *config) + if err != nil { + logger.Error("Failed to verify ID token", "error", err) + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"}) + } + + // Extract user groups + userGroups, err := GetUserGroups(idTokenClaims) + if err != nil { + logger.Warn("Failed to extract user groups", "error", err) + userGroups = []string{} // Initialize as empty array + } + + // Debug logging for tokens + if os.Getenv("DEBUG") == "true" { + // Log decoded token for debugging + logger.Info("ID Token Claims", "claims", idTokenClaims) + logger.Info("Raw ID Token", "token", tokens.IDToken) + logger.Info("Raw Access Token", "token", tokens.AccessToken) + } + + // Check authorization + authorized, requiredGroups := checkPermissions("/query", userGroups, routePermissions, logger) + if !authorized { + logger.Warn("OAuth callback - access denied", + "user", idTokenClaims["cognito:username"], + "groups", userGroups, + "required", requiredGroups) + + return c.JSON(http.StatusForbidden, map[string]interface{}{ + "error": "Insufficient permissions", + "message": "User authenticated successfully but doesn't have the required group membership", + "authenticated": true, + "username": idTokenClaims["cognito:username"], + "email": idTokenClaims["email"], + "groups": userGroups, + "required_groups": requiredGroups, + }) + } + + // User is authenticated and authorized, return tokens and info + return c.JSON(http.StatusOK, map[string]interface{}{ + "authenticated": true, + "username": idTokenClaims["cognito:username"], + "email": idTokenClaims["email"], + "groups": userGroups, + "id_token": tokens.IDToken, + "access_token": tokens.AccessToken, + "token_type": "Bearer", + "expires_in": tokens.ExpiresIn, + }) +} + +// generateRandomString creates a cryptographically secure random string +// Used for generating state parameter and code verifier +func generateRandomString(length int) (string, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b)[:length], nil +} + +// createCodeChallenge creates a code challenge from a code verifier +// Implements S256 code challenge method as specified in PKCE RFC 7636 +func createCodeChallenge(verifier string) string { + // Create SHA256 hash of the verifier + hash := sha256.Sum256([]byte(verifier)) + + // Base64URL encode the hash without padding + challenge := base64.RawURLEncoding.EncodeToString(hash[:]) + + return challenge +} + +// storePKCESession stores the PKCE session for later verification +// In a production environment, this should be replaced with a proper session store +func storePKCESession(state, codeVerifier string, logger *slog.Logger) { + pkceSessionMap.Lock() + defer pkceSessionMap.Unlock() + + // Create new session with expiration (5 minutes is common) + session := &PKCESession{ + CodeVerifier: codeVerifier, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(5 * time.Minute), + } + + pkceSessionMap.sessions[state] = session + + // Clean up expired sessions + for k, v := range pkceSessionMap.sessions { + if time.Now().After(v.ExpiresAt) { + logger.Debug("Cleaning up expired PKCE session", "state", k) + delete(pkceSessionMap.sessions, k) + } + } +} + +// getCodeVerifier retrieves and validates the code verifier for a state +func getCodeVerifier(state string, logger *slog.Logger) (string, error) { + pkceSessionMap.RLock() + defer pkceSessionMap.RUnlock() + + session, ok := pkceSessionMap.sessions[state] + if !ok { + return "", errors.New("no session found for state") + } + + if time.Now().After(session.ExpiresAt) { + return "", errors.New("session expired") + } + + return session.CodeVerifier, nil +} + +// exchangeCodeForTokensWithPKCE exchanges the authorization code for tokens using PKCE +// Called during the OAuth callback flow to get tokens from the authorization code +// Makes an HTTP request to Cognito's token endpoint to perform this exchange +func exchangeCodeForTokensWithPKCE(authCode, codeVerifier string, config CognitoConfig, logger *slog.Logger) (*CognitoTokenResponse, error) { + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", config.ClientID) + data.Set("code", authCode) + data.Set("redirect_uri", config.RedirectURI) + data.Set("code_verifier", codeVerifier) // Include code verifier for PKCE + + logger.Debug("Token request details", + "url", config.TokenURL, + "client_id", config.ClientID, + "redirect_uri", config.RedirectURI) + + req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("token request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResponse CognitoTokenResponse + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + + return &tokenResponse, nil +} + +// verifyToken verifies the JWT token and returns its claims +// Used during both OAuth callback and subsequent API requests with bearer token +// Verifies signature, expiration, issuer, and other JWT claims +func verifyToken(tokenString string, keySet jwk.Set, config CognitoConfig) (map[string]interface{}, error) { + region := "us-east-2" + if strings.Contains(config.JwksURL, "amazonaws.com/") { + parts := strings.Split(config.JwksURL, "amazonaws.com/") + if len(parts) > 1 { + regionParts := strings.Split(parts[0], ".") + if len(regionParts) > 0 { + lastPart := regionParts[len(regionParts)-1] + if lastPart != "" { + region = lastPart + } + } + } + } + + issuer := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", region, config.UserPoolID) + + // Verify the token with the keySet + verifiedToken, err := jwt.Parse( + []byte(tokenString), + jwt.WithKeySet(keySet), + jwt.WithValidate(true), + jwt.WithIssuer(issuer), + // Add other validation options as needed + ) + if err != nil { + return nil, err + } + + // Extract claims to a map + claims, err := verifiedToken.AsMap(context.Background()) + if err != nil { + return nil, err + } + + return claims, nil +} + +// GetUserGroups extracts user groups from the token claims +// Attempts to find groups in different claim names depending on Cognito setup +// Returns an array of group names or an error if no groups found +func GetUserGroups(claims map[string]interface{}) ([]string, error) { + // The claim containing the groups might have different names depending on your Cognito setup + // Common names are "cognito:groups", "groups", or a custom attribute + possibleGroupFields := []string{"cognito:groups", "groups", "custom:groups"} + + for _, field := range possibleGroupFields { + if rawGroups, ok := claims[field]; ok { + switch groups := rawGroups.(type) { + case []interface{}: + result := make([]string, len(groups)) + for i, g := range groups { + result[i] = fmt.Sprintf("%v", g) + } + return result, nil + case []string: + return groups, nil + case string: + return []string{groups}, nil + } + } + } + + return nil, errors.New("no groups found in token claims") +} + +// fetchJWKS retrieves the JSON Web Key Set from Cognito +// Used to get the public keys needed to verify token signatures +// Makes an HTTP request to the JWKS URL and returns the raw JSON +func fetchJWKS(jwksURL string) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", jwksURL, nil) + if err != nil { + return "", err + } + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + jwksData, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(jwksData), nil +} + +// matchRoute checks if a request path matches a route pattern +// Converts Echo route patterns to regex for matching +// Supports parameter patterns like '/users/:id' and wildcard patterns +func matchRoute(requestPath, routePattern string) bool { + // Convert Echo route pattern to regex pattern + // e.g., "/users/:id" to "/users/([^/]+)" + regexPattern := "^" + patternParts := strings.Split(routePattern, "/") + + for i, part := range patternParts { + if i > 0 { + regexPattern += "/" + } + + if strings.HasPrefix(part, ":") { + // Parameter part (e.g., :id) + regexPattern += "([^/]+)" + } else if part == "*" { + // Wildcard - match anything + regexPattern += ".*" + } else { + // Literal part - escape any regex metacharacters + regexPattern += regexp.QuoteMeta(part) + } + } + + regexPattern += "$" + + // Compile and match + regex, err := regexp.Compile(regexPattern) + if err != nil { + return false + } + + return regex.MatchString(requestPath) +} + +// main is the entry point of the application +// Sets up the Echo server, middleware, routes, and starts listening +func main() { + // Create a new Echo instance + e := echo.New() + + // Basic middleware + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // Initialize logger with appropriate level based on DEBUG env var + var logLevel slog.Level + if os.Getenv("DEBUG") == "true" { + logLevel = slog.LevelDebug + } else { + logLevel = slog.LevelInfo + } + + logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: logLevel, + }) + logger := slog.New(logHandler) + + // Get Cognito configuration + region := os.Getenv("AWS_REGION") + if region == "" { + region = "us-east-2" // Fallback to hardcoded value + } + + userPoolID := os.Getenv("COGNITO_USER_POOL_ID") + if userPoolID == "" { + userPoolID = "us-east-2_1y6po8rR8" // Fallback to hardcoded value + } + + domain := os.Getenv("COGNITO_DOMAIN") + // In main function + if domain == "" { + logger.Warn("COGNITO_DOMAIN environment variable is not set, using default") + // Use the user pool ID directly as part of the domain + domain = fmt.Sprintf("%s.auth.%s.amazoncognito.com", userPoolID, region) + } + + // Ensure domain format is correct (no protocol prefix) + domain = strings.TrimPrefix(domain, "https://") + domain = strings.TrimPrefix(domain, "http://") + + config := &CognitoConfig{ + ClientID: os.Getenv("COGNITO_CLIENT_ID"), + RedirectURI: "http://localhost:8080/query", + TokenURL: fmt.Sprintf("https://%s/oauth2/token", domain), + AuthURL: fmt.Sprintf("https://%s/oauth2/authorize", domain), + // actual https://cognito-idp.us-east-2.amazonaws.com/us-east-2_1y6po8rR8/.well-known/jwks.json + JwksURL: fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", region, userPoolID), + UserPoolID: userPoolID, + } + + // Route permissions - matching the provided example + routePermissions := map[string][]string{ + "/users": {"exporters"}, + "/users/:id": {"exporters", "querybuilders"}, + "/orders": {"exporters", "exporters"}, + "/reports/sales": {"exporters", "uploaders", "querybuilders"}, + "/settings": {"exporters"}, + "/query": {"exporters", "querybuilders"}, // Only exporters can access /query + "/api/inventory/update": {"exporters", "uploaders"}, + "/login": {}, // No permissions required for login + } + + // Apply token validation middleware to all routes except the login route + e.Use(TokenValidationMiddleware(config, routePermissions, logger)) + + // Login route + e.GET("/login", func(c echo.Context) error { + // The TokenValidationMiddleware will handle this + return nil + }) + + // Callback endpoint - handled by middleware + e.GET("/query", func(c echo.Context) error { + // This handler will only be called for requests that don't have a code parameter + // (those are handled directly in the middleware) + + // Get user info from context + userClaims, _ := c.Get("user_claims").(map[string]interface{}) + userGroups, _ := c.Get("user_groups").([]string) + + return c.JSON(http.StatusOK, map[string]interface{}{ + "authenticated": true, + "username": userClaims["cognito:username"], + "email": userClaims["email"], + "groups": userGroups, + }) + }) + + // Other endpoint handlers + e.GET("/users", func(c echo.Context) error { + return c.String(http.StatusOK, "Users endpoint") + }) + + e.GET("/users/:id", func(c echo.Context) error { + id := c.Param("id") + return c.String(http.StatusOK, fmt.Sprintf("User details endpoint for ID: %s", id)) + }) + + e.GET("/orders", func(c echo.Context) error { + return c.String(http.StatusOK, "Orders endpoint") + }) + + e.GET("/reports/sales", func(c echo.Context) error { + return c.String(http.StatusOK, "Sales reports endpoint") + }) + + e.GET("/settings", func(c echo.Context) error { + return c.String(http.StatusOK, "Settings endpoint") + }) + + e.PUT("/api/inventory/update", func(c echo.Context) error { + return c.String(http.StatusOK, "Inventory update endpoint") + }) + + // Print startup information + logger.Info("Server configuration", + "client_id", maskString(config.ClientID), + "domain", domain, + "user_pool_id", userPoolID, + "region", region, + "debug_mode", os.Getenv("DEBUG") == "true", + "auth_method", "PKCE") + + // Configure server + server := &http.Server{ + Addr: ":8080", + ReadHeaderTimeout: 3 * time.Second, + ReadTimeout: 20 * time.Second, + WriteTimeout: 20 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // Start server with custom server config + logger.Info("Server starting on :8080") + e.Logger.Fatal(e.StartServer(server)) +} + +// Helper function to mask sensitive values +// Used for logging sensitive information like client IDs +func maskString(s string) string { + if s == "" { + return "" + } + if len(s) <= 8 { + return "****" + } + return s[:4] + "..." + s[len(s)-4:] +} diff --git a/cmd/cognito_test/cognito.pkce/readme.md b/cmd/cognito_test/cognito.pkce/readme.md new file mode 100644 index 00000000..0d325f83 --- /dev/null +++ b/cmd/cognito_test/cognito.pkce/readme.md @@ -0,0 +1,206 @@ +# AWS Cognito PKCE Authentication Example +This directory contains a test harness for validating AWS Cognito PKCE (Proof Key for Code Exchange) authentication +and authorization with Go and the Echo web framework. It demonstrates a complete OAuth 2.0 flow with +role-based access control (RBAC). +It is temporary and will go away once the final middleware is in place. + +## What is PKCE? + +PKCE (Proof Key for Code Exchange) is an extension to the OAuth 2.0 authorization code flow designed to prevent authorization code interception attacks. It's particularly important for: + +- Public clients (like SPAs and mobile apps) +- Applications where securely storing a client secret is difficult +- Applications that run in potentially untrusted environments + +PKCE works by using a dynamically created cryptographic challenge derived from a secret code verifier. This prevents attackers from exchanging an intercepted authorization code for tokens without possessing the original code verifier. + +## How PKCE Works in This Application + +1. **Code Verifier Generation**: When a user initiates login, the application generates a random string (code verifier). +2. **Code Challenge Creation**: The application derives a code challenge from the verifier using SHA-256 hashing and Base64URL encoding. +3. **Authorization Request**: The application redirects to Cognito with the code challenge. +4. **User Authentication**: The user authenticates with Cognito. +5. **Authorization Code**: Cognito redirects back to the application with an authorization code. +6. **Token Exchange**: The application exchanges the code and original code verifier for access and ID tokens. + +## Prerequisites + +- Go 1.16+ +- AWS account with Cognito User Pool +- Properly configured Cognito App Client (see setup section) + +## Dependencies + +- [Echo](https://github.com/labstack/echo) (v4) - Web framework +- [JWKS](https://github.com/lestrrat-go/jwx) - JSON Web Key Set handling for token verification + +## Cognito App Client Requirements + +Your Cognito App Client must be configured as follows: + +1. **No Client Secret**: The app client should be configured as a public client without a client secret. +2. **Allowed OAuth Flows**: Authorization code grant +3. **Allowed OAuth Scopes**: `openid`, `email`, `profile` (and optionally `phone`) +4. **Callback URL**: Must include `http://localhost:8080/query` (or your custom endpoint) + +## Environment Variables + +| Variable | Description | Example | Required | +|----------|-------------|---------|----------| +| `COGNITO_CLIENT_ID` | Your Cognito App Client ID | `6u3ppajoksbkdlteacg0ukbnfi` | Yes | +| `COGNITO_USER_POOL_ID` | Your Cognito User Pool ID | `us-east-2_1y6po8rR8` | Yes | +| `AWS_REGION` | AWS Region of your Cognito User Pool | `us-east-2` | Yes | +| `COGNITO_DOMAIN` | Your Cognito domain | `your-domain.auth.us-east-2.amazoncognito.com` | No (falls back to default) | +| `DEBUG` | Enable debug logging | `true` | No (defaults to false) | + +## Creating a Compatible Cognito App Client + +You can create a compatible app client with the AWS CLI: + +# Creating a cognito app client for cognito pkce /login +Assumed the cognito user pool is already created. +``` +aws --profile aarete --region us-east-2 --endpoint-url https://cognito-idp.us-east-2.amazonaws.com cognito-idp create-user-pool-client \ + --user-pool-id us-east-2_1y6po8rR8 \ + --client-name "public-pkce-client" \ + --no-generate-secret \ + --refresh-token-validity 5 \ + --access-token-validity 60 \ + --id-token-validity 60 \ + --token-validity-units "RefreshToken=days,IdToken=minutes,AccessToken=minutes" \ + --explicit-auth-flows ALLOW_REFRESH_TOKEN_AUTH ALLOW_USER_AUTH ALLOW_USER_SRP_AUTH \ + --supported-identity-providers COGNITO \ + --callback-urls http://localhost:8080/query \ + --allowed-o-auth-flows code \ + --allowed-o-auth-scopes email openid phone profile \ + --allowed-o-auth-flows-user-pool-client \ + --prevent-user-existence-errors ENABLED +``` + +## Setup and Running + +1.2 omitted +3. Set your environment variables: + ```bash + export COGNITO_CLIENT_ID= + export COGNITO_USER_POOL_ID= + export AWS_REGION= + export DEBUG=true # Optional for debug logging + ``` +4. Run the application: + ```bash + go run main.go + ``` +5. Visit `http://localhost:8080/login` in your browser to start the authentication flow + +## Key Features + +- **PKCE Implementation**: Secure authentication flow without client secrets +- **Token Validation**: JWT verification using Cognito's JWKS +- **Authorization**: Role-based access control using Cognito groups +- **Caching**: JWKS caching to minimize API calls +- **Token Handling**: Access and ID token management +- **Middleware Architecture**: Authentication and authorization via Echo middleware + +## Authorization & Groups + +This application implements role-based access control using Cognito user groups. Routes can be protected based on group membership: + +```go +routePermissions := map[string][]string{ + "/users": {"exporters"}, + "/users/:id": {"exporters", "querybuilders"}, + // Other routes and permissions +} +``` + +Users must belong to the required groups to access protected routes. + +## Security Considerations + +- The application uses in-memory state management for PKCE sessions. In production, use a persistent and scalable storage solution. +- The application has a 5-minute expiration for PKCE sessions. +- Proper error handling prevents information leakage. +- Token verification checks issuer, signature, and expiration. + +## Flow Diagram + + +``` +┌─────────┐ ┌───────────┐ ┌─────────┐ +│ Browser │ │ App Server│ │ Cognito │ +└────┬────┘ └─────┬─────┘ └────┬────┘ +│ │ │ +│ 1. GET /login │ │ +│ ─────────────────────────────► │ +│ │ │ +│ │ 2. Generate code_verifier │ +│ │ Generate code_challenge │ +│ │ │ +│ 3. Redirect to Cognito │ │ +│ ◄───────────────────────────── │ +│ │ │ +│ 4. GET /oauth2/authorize?...code_challenge │ +│ ────────────────────────────────────────────────────────────► +│ │ │ +│ │ │ 5. Process auth +│ │ │ request +│ 6. Cognito Login Page │ │ +│ ◄──────────────────────────────────────────────────────────── +│ │ │ +│ 7. User credentials │ │ +│ ────────────────────────────────────────────────────────────► +│ │ │ +│ │ │ 8. Validate +│ │ │ credentials +│ 9. Redirect with code │ │ +│ ◄──────────────────────────────────────────────────────────── +│ │ │ +│ 10. GET /query?code=... │ │ +│ ─────────────────────────────► │ +│ │ │ +│ │ 11. Retrieve code_verifier │ +│ │ │ +│ │ 12. POST /oauth2/token │ +│ │ code=...&code_verifier=... +│ │ ─────────────────────────────► +│ │ │ +│ │ │ 13. Validate +│ │ │ code and +│ │ │ verifier +│ │ 14. Tokens response │ +│ │ ◄───────────────────────────── +│ │ │ +│ │ 15. Verify tokens │ +│ │ Check permissions │ +│ │ │ +│ 16. Authentication successful│ │ +│ ◄───────────────────────────── │ +│ │ │ + +``` + +## Mermaid Sequence Diagram +```mermaid +sequenceDiagram + participant Browser + participant AppServer as App Server + participant Cognito + + Browser->>AppServer: 1. GET /login + Note over AppServer: 2. Generate code_verifier
Generate code_challenge + AppServer->>Browser: 3. Redirect to Cognito + Browser->>Cognito: 4. GET /oauth2/authorize?...code_challenge + Note over Cognito: 5. Process auth request + Cognito->>Browser: 6. Cognito Login Page + Browser->>Cognito: 7. User credentials + Note over Cognito: 8. Validate credentials + Cognito->>Browser: 9. Redirect with code + Browser->>AppServer: 10. GET /query?code=... + Note over AppServer: 11. Retrieve code_verifier + AppServer->>Cognito: 12. POST /oauth2/token
code=...&code_verifier=... + Note over Cognito: 13. Validate code and verifier + Cognito->>AppServer: 14. Tokens response + Note over AppServer: 15. Verify tokens
Check permissions + AppServer->>Browser: 16. Authentication successful +``` \ No newline at end of file diff --git a/cmd/cognito_test/cognitotest/readme.md b/cmd/cognito_test/cognitotest/readme.md index 0c37d74c..cdf27d5f 100644 --- a/cmd/cognito_test/cognitotest/readme.md +++ b/cmd/cognito_test/cognitotest/readme.md @@ -1,6 +1,6 @@ # Cognito Auth Test Harness -This repository contains a test harness for validating AWS Cognito authentication and authorization with Go and the Echo web framework. It demonstrates a complete OAuth 2.0 flow with role-based access control (RBAC). +This directory contains a test harness for validating AWS Cognito authentication and authorization with Go and the Echo web framework. It demonstrates a complete OAuth 2.0 flow with role-based access control (RBAC). It is temporary and will go away once the final middleware is in place. ## Features