diff --git a/cmd/cognito_test/cognito.auth.harness/main.go b/cmd/cognito_test/cognito.auth.harness/main.go
new file mode 100644
index 00000000..b918eb13
--- /dev/null
+++ b/cmd/cognito_test/cognito.auth.harness/main.go
@@ -0,0 +1,100 @@
+package main
+
+import (
+ "log/slog"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+ "queryorchestration/internal/cognitoauth" // Replace with your actual package import path
+)
+
+func main() {
+ // Create a new Echo instance
+ e := echo.New()
+
+ // Add 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)
+
+ // Create auth config from environment variables
+ config := cognitoauth.NewConfigFromEnv("http://localhost:8080", logger)
+
+ // Set route permissions
+ routePermissions := map[string][]string{
+ "/users": {"exporters", "uploaders", "querybuilders"},
+ "/users/:id": {"exporters", "querybuilders"},
+ "/orders": {"exporters"},
+ "/reports/sales": {"exporters", "uploaders", "querybuilders"},
+ "/settings": {"exporters"},
+ "/api/inventory/update": {"exporters", "uploaders"},
+ }
+ config.SetRoutePermissions(routePermissions)
+
+ // Register authentication routes
+ cognitoauth.RegisterRoutes(e, config)
+
+ // Register your application routes
+ // These will be protected based on the permissions set above
+ e.GET("/users", func(c echo.Context) error {
+ return c.String(http.StatusOK, "Users endpoint (OK)")
+ })
+
+ e.GET("/users/:id", func(c echo.Context) error {
+ id := c.Param("id")
+ return c.String(http.StatusOK, "User details endpoint for ID: "+id)
+ })
+
+ e.GET("/orders", func(c echo.Context) error {
+ return c.String(http.StatusOK, "Orders endpoint (OK)")
+ })
+
+ e.GET("/reports/sales", func(c echo.Context) error {
+ return c.String(http.StatusOK, "Sales reports endpoint (OK)")
+ })
+
+ e.GET("/settings", func(c echo.Context) error {
+ return c.String(http.StatusOK, "Settings endpoint (OK)")
+ })
+
+ e.PUT("/api/inventory/update", func(c echo.Context) error {
+ return c.String(http.StatusOK, "Inventory update endpoint (OK)")
+ })
+
+ // Alternative approach: Use the RequireGroups middleware for specific routes
+ // This can be used instead of or in addition to the global permissions map
+ adminGroup := e.Group("/admin")
+ adminGroup.Use(cognitoauth.RequireGroups("admin")) // Only admin group can access this route
+
+ adminGroup.GET("/dashboard", func(c echo.Context) error {
+ return c.String(http.StatusOK, "Admin Dashboard (OK)")
+ })
+
+ // 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
+ logger.Info("Server starting on :8080")
+ e.Logger.Fatal(e.StartServer(server))
+}
diff --git a/cmd/cognito_test/cognito.auth.harness/readme.md b/cmd/cognito_test/cognito.auth.harness/readme.md
new file mode 100644
index 00000000..db94e6c5
--- /dev/null
+++ b/cmd/cognito_test/cognito.auth.harness/readme.md
@@ -0,0 +1,3 @@
+# Cognito Auth Harness
+This is the the test harness for the pkce cognito package (internal/cognitoauth)
+that will be integrated into the main API application after its all tested.
diff --git a/internal/cognitoauth/auth.go b/internal/cognitoauth/auth.go
new file mode 100644
index 00000000..1b442d9b
--- /dev/null
+++ b/internal/cognitoauth/auth.go
@@ -0,0 +1,306 @@
+package cognitoauth
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+)
+
+// RegisterRoutes registers all authentication-related routes to the Echo engine
+func RegisterRoutes(e *echo.Echo, config *Config) {
+ // Register the login route - the middleware will handle this
+ e.GET(config.LoginPath, func(c echo.Context) error {
+ // This is handled by the middleware
+ return nil
+ })
+
+ // Register callback route - the middleware will handle this
+ e.GET(config.CallbackPath, 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,
+ })
+ })
+
+ // Register logout route
+ e.GET(config.LogoutPath, func(c echo.Context) error {
+ return LogoutHandler(c, config)
+ })
+
+ // Default home page handler if requested
+ e.GET(config.HomePath, func(c echo.Context) error {
+ return HomeHandler(c, config)
+ })
+
+ // Apply the JWT Auth middleware first
+ e.Use(JWTAuthMiddleware(config))
+
+ // Then apply the token validation middleware
+ e.Use(TokenValidationMiddleware(config))
+}
+
+// HomeHandler implements a simple home page that shows auth status and available endpoints
+func HomeHandler(c echo.Context, config *Config) error {
+ // Create a list of all registered routes
+ var endpoints []string
+
+ // Add the auth routes
+ endpoints = append(endpoints, []string{
+ config.LoginPath,
+ config.CallbackPath,
+ config.HomePath,
+ config.LogoutPath,
+ }...)
+
+ // Add routes from the permission map
+ for route := range config.RoutePermissions {
+ // Skip routes that are already in the list
+ alreadyAdded := false
+ for _, endpoint := range endpoints {
+ if route == endpoint {
+ alreadyAdded = true
+ break
+ }
+ }
+ if !alreadyAdded {
+ endpoints = append(endpoints, route)
+ }
+ }
+
+ // Check if user is authenticated by looking for token in cookie
+ tokenCookie, err := c.Cookie("auth_token")
+ isAuthenticated := (err == nil && tokenCookie.Value != "")
+
+ // Variables for user info
+ username := ""
+ email := ""
+ var userGroups []string
+
+ // If authenticated, try to decode the JWT token to get user info
+ if isAuthenticated {
+ // Parse the JWT token without verification (just to extract info for display)
+ token, _ := jwt.Parse([]byte(tokenCookie.Value), jwt.WithVerify(false))
+ if token != nil {
+ claims, _ := token.AsMap(context.Background())
+ username, _ = claims["cognito:username"].(string)
+ email, _ = claims["email"].(string)
+
+ // Try to extract groups
+ userGroups, _ = GetUserGroups(claims)
+ }
+ }
+
+ // Create HTML for the endpoints list
+ var linksHTML string
+ baseURL := c.Scheme() + "://" + c.Request().Host
+
+ // Create list items for each endpoint
+ for _, endpoint := range endpoints {
+ // Skip endpoints with path parameters for direct linking
+ if strings.Contains(endpoint, ":") {
+ displayPath := strings.Replace(endpoint, ":id", "{id}", -1)
+ linksHTML += fmt.Sprintf("
%s (requires parameter)\n", displayPath)
+ } else {
+ linksHTML += fmt.Sprintf("%s\n", baseURL, endpoint, endpoint)
+ }
+ }
+
+ // Create authentication status section
+ var authStatusHTML string
+ if isAuthenticated {
+ authStatusHTML = fmt.Sprintf(`
+
+
Authentication Status: Authenticated
+
Username: %s
+
Email: %s
+
Groups: %s
+
Logout
+
+ `, username, email, strings.Join(userGroups, ", "), baseURL)
+ } else {
+ authStatusHTML = fmt.Sprintf(`
+
+
Authentication Status: Not Authenticated
+
You are not currently logged in.
+
Login
+
+ `, baseURL)
+ }
+
+ // Create the complete HTML page
+ html := fmt.Sprintf(`
+
+
+
+ Authentication Home
+
+
+
+ Authentication Home
+
+ %s
+
+ Available Endpoints
+
+
+
+
Note: This is a debugging page. Some endpoints require authentication or specific permissions.
+
+
+
+ `, authStatusHTML, linksHTML)
+
+ return c.HTML(http.StatusOK, html)
+}
+
+// GetTokenFromRequest extracts the token from the request
+func GetTokenFromRequest(c echo.Context) string {
+ // First try from Authorization header
+ authHeader := c.Request().Header.Get("Authorization")
+ if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
+ return authHeader[7:]
+ }
+
+ // Then try from cookie
+ tokenCookie, err := c.Cookie("auth_token")
+ if err == nil && tokenCookie.Value != "" {
+ return tokenCookie.Value
+ }
+
+ return ""
+}
+
+// GetUserInfo gets user information from the context
+func GetUserInfo(c echo.Context) (UserInfo, bool) {
+ // Try to get user info from context
+ userInfo, ok := c.Get("user_info").(UserInfo)
+ if ok {
+ return userInfo, true
+ }
+
+ // Try to get token and extract user info
+ token := GetTokenFromRequest(c)
+ if token == "" {
+ return UserInfo{}, false
+ }
+
+ // Parse token to extract claims
+ parsedToken, err := jwt.Parse([]byte(token), jwt.WithVerify(false))
+ if err != nil {
+ return UserInfo{}, false
+ }
+
+ claims, err := parsedToken.AsMap(context.Background())
+ if err != nil {
+ return UserInfo{}, false
+ }
+
+ // Extract user info
+ userInfo = ExtractUserInfo(claims)
+ return userInfo, true
+}
+
+// RequireGroups creates a middleware that checks if the user is in any of the specified groups
+func RequireGroups(groups ...string) echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ userInfo, ok := GetUserInfo(c)
+ if !ok {
+ return c.JSON(http.StatusUnauthorized, map[string]string{
+ "error": "Authentication required",
+ })
+ }
+
+ // Check if user has any of the required groups
+ for _, requiredGroup := range groups {
+ for _, userGroup := range userInfo.Groups {
+ if userGroup == requiredGroup {
+ return next(c)
+ }
+ }
+ }
+
+ return c.JSON(http.StatusForbidden, map[string]interface{}{
+ "error": "Insufficient permissions",
+ "message": "User doesn't have the required group membership",
+ "username": userInfo.Username,
+ "groups": userInfo.Groups,
+ "required_groups": groups,
+ })
+ }
+ }
+}
diff --git a/internal/cognitoauth/config.go b/internal/cognitoauth/config.go
new file mode 100644
index 00000000..d03e9f71
--- /dev/null
+++ b/internal/cognitoauth/config.go
@@ -0,0 +1,143 @@
+package cognitoauth
+
+import (
+ "fmt"
+ "log"
+ "log/slog"
+ "os"
+ "strings"
+)
+
+// Config holds the configuration for AWS Cognito
+// Contains all necessary parameters to interact with Cognito endpoints
+type Config 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
+ ClientSecret string // Client secret for authenticated clients
+ Region string // AWS region where the Cognito User Pool is located
+ LoginPath string // Path for initiating login
+ CallbackPath string // Path for OAuth callback
+ HomePath string // Path for home page
+ LogoutPath string // Path for logout
+ Logger *slog.Logger // Logger instance
+ RoutePermissions map[string][]string // Map of routes to required permissions
+}
+
+// NewConfigFromEnv creates a new Config from environment variables
+func NewConfigFromEnv(baseURL string, logger *slog.Logger) *Config {
+ // Set default logger if not provided
+ if logger == nil {
+ 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)
+ }
+
+ // Set paths with defaults
+ loginPath := os.Getenv("COGNITO_LOGIN_PATH")
+ if loginPath == "" {
+ loginPath = "/login"
+ }
+
+ callbackPath := os.Getenv("COGNITO_CALLBACK_PATH")
+ if callbackPath == "" {
+ callbackPath = "/login-callback"
+ }
+
+ homePath := os.Getenv("COGNITO_HOME_PATH")
+ if homePath == "" {
+ homePath = "/home"
+ }
+
+ logoutPath := os.Getenv("COGNITO_LOGOUT_PATH")
+ if logoutPath == "" {
+ logoutPath = "/logout"
+ }
+
+ // Get AWS region with fallback
+ region := os.Getenv("AWS_REGION")
+ if region == "" {
+ region = "us-east-2" // Fallback to default value
+ }
+
+ // Get User Pool ID with fallback
+ userPoolID := os.Getenv("COGNITO_USER_POOL_ID")
+ if userPoolID == "" {
+ userPoolID = "" // No default, will need to be provided
+ logger.Warn("COGNITO_USER_POOL_ID environment variable is not set")
+ }
+
+ // Get Cognito domain
+ domain := os.Getenv("COGNITO_DOMAIN")
+ 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)
+ log.Fatalf("COGNITO_DOMAIN environment variable is not set")
+ }
+
+ // Ensure domain format is correct (no protocol prefix)
+ domain = strings.TrimPrefix(domain, "https://")
+ domain = strings.TrimPrefix(domain, "http://")
+
+ // Sanitize base URL (remove trailing slash)
+ baseURL = strings.TrimSuffix(baseURL, "/")
+
+ config := &Config{
+ ClientID: os.Getenv("COGNITO_CLIENT_ID"),
+ ClientSecret: os.Getenv("COGNITO_CLIENT_SECRET"),
+ RedirectURI: baseURL + callbackPath,
+ TokenURL: fmt.Sprintf("https://%s/oauth2/token", domain),
+ AuthURL: fmt.Sprintf("https://%s/oauth2/authorize", domain),
+ JwksURL: fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", region, userPoolID),
+ UserPoolID: userPoolID,
+ Region: region,
+ LoginPath: loginPath,
+ CallbackPath: callbackPath,
+ HomePath: homePath,
+ LogoutPath: logoutPath,
+ Logger: logger,
+ RoutePermissions: make(map[string][]string),
+ }
+
+ config.PrettyPrint()
+ return config
+}
+
+func (c *Config) PrettyPrint() {
+ fmt.Printf("Config:\n")
+ fmt.Printf(" ClientID: %s\n", c.ClientID)
+ fmt.Printf(" ClientSecret: %s\n", c.ClientSecret)
+ fmt.Printf(" RedirectURI: %s\n", c.RedirectURI)
+ fmt.Printf(" TokenURL: %s\n", c.TokenURL)
+ fmt.Printf(" AuthURL: %s\n", c.AuthURL)
+ fmt.Printf(" JwksURL: %s\n", c.JwksURL)
+ fmt.Printf(" UserPoolID: %s\n", c.UserPoolID)
+ fmt.Printf(" Region: %s\n", c.Region)
+ fmt.Printf(" LoginPath: %s\n", c.LoginPath)
+ fmt.Printf(" CallbackPath: %s\n", c.CallbackPath)
+ fmt.Printf(" HomePath: %s\n", c.HomePath)
+ fmt.Printf(" LogoutPath: %s\n", c.LogoutPath)
+
+ // pretty print the route permissions map
+ for route, permissions := range c.RoutePermissions {
+ fmt.Printf(" Route: %s, Permissions: %v\n", route, permissions)
+ }
+
+}
+
+// SetRoutePermissions sets the route permissions map
+func (c *Config) SetRoutePermissions(permissions map[string][]string) {
+ c.RoutePermissions = permissions
+}
diff --git a/internal/cognitoauth/handler.go b/internal/cognitoauth/handler.go
new file mode 100644
index 00000000..f267ec24
--- /dev/null
+++ b/internal/cognitoauth/handler.go
@@ -0,0 +1,243 @@
+package cognitoauth
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/labstack/echo/v4"
+)
+
+// initiateLoginWithPKCE generates PKCE code challenge and redirects to Cognito
+// This function starts the OAuth authorization flow with PKCE
+func initiateLoginWithPKCE(c echo.Context, config *Config) error {
+ // Generate random state parameter to prevent CSRF
+ state, err := generateRandomString(32)
+ if err != nil {
+ config.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 {
+ config.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, config.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" {
+ config.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)
+}
+
+// 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 Config) (*TokenResponse, 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
+
+ config.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")
+
+ // Add Authorization header if client secret is provided
+ if config.ClientSecret != "" {
+ req.SetBasicAuth(config.ClientID, config.ClientSecret)
+ config.Logger.Debug("Using Basic Auth authentication")
+ } else {
+ config.Logger.Debug("Using public client authentication (no secret)")
+ }
+
+ 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 TokenResponse
+ if err := json.Unmarshal(body, &tokenResponse); err != nil {
+ return nil, fmt.Errorf("failed to parse token response: %w", err)
+ }
+
+ return &tokenResponse, nil
+}
+
+// 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 redirects to home page with token in cookie
+func handleOAuthCallback(c echo.Context, config *Config) 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, config.Logger)
+ if err != nil {
+ config.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)
+ if err != nil {
+ config.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, config.Logger)
+ if err != nil {
+ config.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 {
+ config.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 {
+ config.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
+ config.Logger.Info("ID Token Claims", "claims", idTokenClaims)
+ config.Logger.Info("Raw ID Token", "token", tokens.IDToken)
+ config.Logger.Info("Raw Access Token", "token", tokens.AccessToken)
+ }
+
+ // Check authorization
+ authorized, requiredGroups := checkPermissions(config.CallbackPath, userGroups, config.RoutePermissions, config.Logger)
+ if !authorized {
+ config.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
+
+ // Calculate token expiration time based on token's expires_in value
+ expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
+
+ // Set a cookie with the access token
+ tokenCookie := new(http.Cookie)
+ tokenCookie.Name = "auth_token"
+ tokenCookie.Value = tokens.AccessToken
+ tokenCookie.Path = "/"
+ tokenCookie.Expires = expiresAt
+ tokenCookie.HttpOnly = true // Not accessible via JavaScript
+ // In production, set Secure to true
+ // tokenCookie.Secure = true
+ c.SetCookie(tokenCookie)
+
+ // Extract username for display purposes (optional)
+ username, _ := idTokenClaims["cognito:username"].(string)
+
+ config.Logger.Info("User authenticated successfully", "username", username, "groups", userGroups)
+
+ // Redirect to home page
+ return c.Redirect(http.StatusFound, config.HomePath)
+}
+
+// LogoutHandler handles the logout process by clearing the auth cookie
+func LogoutHandler(c echo.Context, config *Config) error {
+ // Clear the token cookie
+ cookie := new(http.Cookie)
+ cookie.Name = "auth_token"
+ cookie.Value = ""
+ cookie.Path = "/"
+ cookie.Expires = time.Now().Add(-1 * time.Hour) // Set expiry in the past
+ cookie.HttpOnly = true
+ c.SetCookie(cookie)
+
+ // Redirect to home page
+ return c.Redirect(http.StatusFound, config.HomePath)
+}
diff --git a/internal/cognitoauth/jwks.go b/internal/cognitoauth/jwks.go
new file mode 100644
index 00000000..4f6cce10
--- /dev/null
+++ b/internal/cognitoauth/jwks.go
@@ -0,0 +1,75 @@
+package cognitoauth
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwk"
+)
+
+// 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
+}
+
+// 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
+}
diff --git a/internal/cognitoauth/middleware.go b/internal/cognitoauth/middleware.go
new file mode 100644
index 00000000..0e6b5dfa
--- /dev/null
+++ b/internal/cognitoauth/middleware.go
@@ -0,0 +1,142 @@
+package cognitoauth
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+)
+
+// 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 *Config) echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ requestPath := c.Request().URL.Path
+ config.Logger.Debug("Processing request", "path", requestPath, "method", c.Request().Method)
+
+ // Skip token validation for specified public paths
+ publicPaths := []string{config.HomePath, config.LogoutPath}
+ for _, path := range publicPaths {
+ if requestPath == path {
+ config.Logger.Debug("Skipping token validation for public path", "path", requestPath)
+ return next(c)
+ }
+ }
+
+ // Special case for the OAuth callback path
+ if requestPath == config.CallbackPath && (c.QueryParam("code") != "" || c.QueryParam("error") != "") {
+ config.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)
+ }
+
+ // Initialize login flow if this is a login request
+ if requestPath == config.LoginPath {
+ return initiateLoginWithPKCE(c, config)
+ }
+
+ // Get authorization header
+ authHeader := c.Request().Header.Get("Authorization")
+ if authHeader == "" {
+ config.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, config.Logger)
+ if err != nil {
+ config.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 {
+ config.Logger.Warn("Token verification failed", "error", err)
+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
+ }
+
+ // Extract user groups
+ userGroups, err := GetUserGroups(claims)
+ if err != nil {
+ config.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)
+
+ // Create and store user info in context
+ userInfo := ExtractUserInfo(claims)
+ c.Set("user_info", userInfo)
+
+ // Check authorization
+ authorized, requiredGroups := checkPermissions(requestPath, userGroups, config.RoutePermissions, config.Logger)
+ if !authorized {
+ config.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)
+ }
+ }
+}
+
+// JWTAuthMiddleware checks for JWT token in cookies and adds it to the Authorization header
+func JWTAuthMiddleware(config *Config) echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ // Skip for login and callback paths
+ if c.Path() == config.LoginPath || c.Path() == config.CallbackPath {
+ return next(c)
+ }
+
+ // Skip for home and logout as they should be accessible without auth
+ if c.Path() == config.HomePath || c.Path() == config.LogoutPath {
+ return next(c)
+ }
+
+ // Check if Authorization header is already set
+ authHeader := c.Request().Header.Get("Authorization")
+ if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
+ // Authorization header is already set, proceed to the next handler
+ return next(c)
+ }
+
+ // Try to get token from cookie
+ tokenCookie, err := c.Cookie("auth_token")
+ if err != nil || tokenCookie.Value == "" {
+ // No token cookie found
+ return c.Redirect(http.StatusFound, config.LoginPath)
+ }
+
+ // Add token to Authorization header
+ c.Request().Header.Set("Authorization", "Bearer "+tokenCookie.Value)
+
+ return next(c)
+ }
+ }
+}
diff --git a/internal/cognitoauth/models.go b/internal/cognitoauth/models.go
new file mode 100644
index 00000000..113c6e72
--- /dev/null
+++ b/internal/cognitoauth/models.go
@@ -0,0 +1,55 @@
+package cognitoauth
+
+import (
+ "sync"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwk"
+)
+
+// TokenResponse represents the response from the token endpoint
+// Returned by Cognito when exchanging an authorization code for tokens
+type TokenResponse 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"
+}
+
+// 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
+}
+
+// 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 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),
+}
+
+// Global JWKS cache instance
+var jwksCache = &JWKSCache{
+ ExpiresAt: time.Now(), // Initial state is expired, forcing a fetch on first use
+}
+
+// UserInfo represents the user information extracted from JWT claims
+type UserInfo struct {
+ Username string // Username from the token
+ Email string // Email from the token
+ Groups []string // Groups the user belongs to
+}
diff --git a/internal/cognitoauth/readme.md b/internal/cognitoauth/readme.md
new file mode 100644
index 00000000..cdf11ce1
--- /dev/null
+++ b/internal/cognitoauth/readme.md
@@ -0,0 +1,34 @@
+# Cognito Auth
+
+A reusable Go package for AWS Cognito authentication and authorization with Echo framework using PKCE flow.
+
+## Features
+
+- Complete OAuth 2.0 PKCE flow for AWS Cognito
+- JWT token validation and verification
+- Route-based authorization using Cognito user groups
+- Middleware for token handling
+- Cookie-based token storage
+- Default home page with authentication status
+- Simple integration with Echo framework
+
+## Installation
+
+```bash
+go get github.com/yourusername/cognitoauth
+```
+
+## Configuration
+
+The package reads configuration from environment variables:
+
+- `COGNITO_CLIENT_ID`: Your AWS Cognito App Client ID
+- `COGNITO_CLIENT_SECRET`: Your AWS Cognito App Client Secret (optional for public clients)
+- `COGNITO_USER_POOL_ID`: Your AWS Cognito User Pool ID
+- `COGNITO_DOMAIN`: Your AWS Cognito domain
+- `AWS_REGION`: AWS region where your Cognito User Pool is located
+- `DEBUG`: Set to "true" for debug logging
+
+Optional path configuration:
+- `COGNITO_LOGIN_PATH`: Path for login endpoint (default: "/login")
+- `COGNITO_CALLBACK_PATH`: Path for OAuth callback (default: "/
\ No newline at end of file
diff --git a/internal/cognitoauth/structure.md b/internal/cognitoauth/structure.md
new file mode 100644
index 00000000..80a15b46
--- /dev/null
+++ b/internal/cognitoauth/structure.md
@@ -0,0 +1,10 @@
+# Cognitoauth structure
+cognitoauth/
+├── auth.go # Main package file with exported functions
+├── handler.go # HTTP handlers for login, callback, etc.
+├── middleware.go # Echo middleware implementation
+├── jwks.go # JWKS handling and validation
+├── token.go # Token verification and management
+├── models.go # Data models and types
+├── utils.go # Helper functions
+└── config.go # Configuration structures
\ No newline at end of file
diff --git a/internal/cognitoauth/summary.md b/internal/cognitoauth/summary.md
new file mode 100644
index 00000000..1818c20c
--- /dev/null
+++ b/internal/cognitoauth/summary.md
@@ -0,0 +1,70 @@
+# Summary of the Cognito Auth Package
+
+This package handles the complete PKCE flow with AWS Cognito and integrates cleanly with the Echo web framework.
+
+## Key Components:
+
+### Config Structure: Centralizes all configuration and can be initialized from environment variables.
+
+Middleware:
+
+### JWTAuthMiddleware: Extracts tokens from cookies and adds them to Authorization headers
+
+TokenValidationMiddleware: Handles token validation and authorization
+
+### Route Handlers:
+
+Login initiation
+OAuth callback processing
+Logout functionality
+Home page with authentication status
+
+### Helper Functions:
+
+Token verification
+User group extraction
+Permission checking
+
+## How to Use It:
+
+Initialize the Config:
+
+```
+goconfig := cognitoauth.NewConfigFromEnv("http://localhost:8080", logger)
+
+Set Route Permissions:
+goroutePermissions := map[string][]string{
+"/users": {"exporters", "uploaders"},
+"/orders": {"exporters"},
+}
+config.SetRoutePermissions(routePermissions)
+```
+
+### Register Routes and Middleware:
+
+`gocognitoauth.RegisterRoutes(e, config)`
+
+### Define Your Protected Routes:
+
+```
+e.GET("/users", handleUsers)
+e.GET("/orders", handleOrders)
+```
+
+### Authentication Flow:
+
+User navigates to /login
+User is redirected to Cognito login page
+After successful login, Cognito redirects to /login-callback
+The callback handler exchanges the authorization code for tokens
+Tokens are stored in cookies for subsequent requests
+Protected routes check token validity and user permissions
+
+### Customization:
+
+The package is designed to be customizable:
+
+Configure paths for login, callback, logout, and home
+Define your own route permissions
+Use RequireGroups middleware for fine-grained control
+Access user information in your handlers via GetUserInfo
diff --git a/internal/cognitoauth/token.go b/internal/cognitoauth/token.go
new file mode 100644
index 00000000..bfce4974
--- /dev/null
+++ b/internal/cognitoauth/token.go
@@ -0,0 +1,155 @@
+package cognitoauth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwk"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+)
+
+// 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 Config) (map[string]interface{}, error) {
+ region := config.Region
+ 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")
+}
+
+// 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
+}
+
+// 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
+}
+
+// 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
+}
+
+// ExtractUserInfo extracts user information from JWT claims
+func ExtractUserInfo(claims map[string]interface{}) UserInfo {
+ userInfo := UserInfo{}
+
+ // Extract username
+ if username, ok := claims["cognito:username"].(string); ok {
+ userInfo.Username = username
+ }
+
+ // Extract email
+ if email, ok := claims["email"].(string); ok {
+ userInfo.Email = email
+ }
+
+ // Extract groups
+ groups, _ := GetUserGroups(claims)
+ userInfo.Groups = groups
+
+ return userInfo
+}
diff --git a/internal/cognitoauth/utils.go b/internal/cognitoauth/utils.go
new file mode 100644
index 00000000..980316ad
--- /dev/null
+++ b/internal/cognitoauth/utils.go
@@ -0,0 +1,97 @@
+package cognitoauth
+
+import (
+ "log/slog"
+ "regexp"
+ "strings"
+)
+
+// 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)
+}
+
+// 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
+ }
+
+ // If no groups are required, allow access
+ if len(requiredGroups) == 0 {
+ return true, requiredGroups
+ }
+
+ // 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
+}
+
+// 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:]
+}