From dd9c53df04329667ea9e8fa9937dd89934d609a8 Mon Sep 17 00:00:00 2001 From: jay brown Date: Tue, 8 Apr 2025 16:27:06 -0700 Subject: [PATCH] auth package WIP --- cmd/cognito_test/cognito.auth.harness/main.go | 100 ++++++ .../cognito.auth.harness/readme.md | 3 + internal/cognitoauth/auth.go | 306 ++++++++++++++++++ internal/cognitoauth/config.go | 143 ++++++++ internal/cognitoauth/handler.go | 243 ++++++++++++++ internal/cognitoauth/jwks.go | 75 +++++ internal/cognitoauth/middleware.go | 142 ++++++++ internal/cognitoauth/models.go | 55 ++++ internal/cognitoauth/readme.md | 34 ++ internal/cognitoauth/structure.md | 10 + internal/cognitoauth/summary.md | 70 ++++ internal/cognitoauth/token.go | 155 +++++++++ internal/cognitoauth/utils.go | 97 ++++++ 13 files changed, 1433 insertions(+) create mode 100644 cmd/cognito_test/cognito.auth.harness/main.go create mode 100644 cmd/cognito_test/cognito.auth.harness/readme.md create mode 100644 internal/cognitoauth/auth.go create mode 100644 internal/cognitoauth/config.go create mode 100644 internal/cognitoauth/handler.go create mode 100644 internal/cognitoauth/jwks.go create mode 100644 internal/cognitoauth/middleware.go create mode 100644 internal/cognitoauth/models.go create mode 100644 internal/cognitoauth/readme.md create mode 100644 internal/cognitoauth/structure.md create mode 100644 internal/cognitoauth/summary.md create mode 100644 internal/cognitoauth/token.go create mode 100644 internal/cognitoauth/utils.go 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.

    +

    +
    + `, 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:] +}