From 8ff64487aa0352195e192f165504d153e3bd9615 Mon Sep 17 00:00:00 2001 From: jay brown Date: Mon, 31 Mar 2025 14:37:07 -0700 Subject: [PATCH] pkce with page --- cmd/cognito_test/cognito.pkce/homehandler.go | 182 +++++++++++++++++++ cmd/cognito_test/cognito.pkce/main.go | 147 +++++++++++---- cmd/cognito_test/cognito.pkce/readme.md | 9 +- 3 files changed, 302 insertions(+), 36 deletions(-) create mode 100644 cmd/cognito_test/cognito.pkce/homehandler.go diff --git a/cmd/cognito_test/cognito.pkce/homehandler.go b/cmd/cognito_test/cognito.pkce/homehandler.go new file mode 100644 index 00000000..f4dc949f --- /dev/null +++ b/cmd/cognito_test/cognito.pkce/homehandler.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/labstack/echo/v4" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +// endpointsList keeps track of registered endpoints +// This list should be kept in sync with actual route registrations +var endpointsList = []string{ + "/login", + LOGIN_CALLBACK_PATH, + "/users", + "/users/:id", + "/orders", + "/reports/sales", + "/settings", + "/api/inventory/update", + "/home", // Include the home endpoint itself +} + +// homeHandler renders a simple HTML page with links to all endpoints +// and displays authentication status based on JWT token +func HomeHandler(c echo.Context) error { + var linksHTML string + baseURL := "http://localhost:8080" + + // Create list items for each endpoint + for _, endpoint := range endpointsList { + // 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) + } + } + + // 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) + // In a production application, you would want to verify the token here as well + 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 + if rawGroups, ok := claims["cognito:groups"]; ok { + if groups, ok := rawGroups.([]interface{}); ok { + userGroups = make([]string, len(groups)) + for i, g := range groups { + userGroups[i] = fmt.Sprint(g) + } + } + } + } + } + + // 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, ", ")) + } else { + authStatusHTML = ` +
    +

    Authentication Status: Not Authenticated

    +

    You are not currently logged in.

    +

    +
    + ` + } + + // Create the complete HTML page + html := fmt.Sprintf(` + + + + Doczy Home + + + +

    Doczy 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) +} diff --git a/cmd/cognito_test/cognito.pkce/main.go b/cmd/cognito_test/cognito.pkce/main.go index fc4e8d32..7efd3701 100644 --- a/cmd/cognito_test/cognito.pkce/main.go +++ b/cmd/cognito_test/cognito.pkce/main.go @@ -75,6 +75,8 @@ var jwksCache = &JWKSCache{ ExpiresAt: time.Now(), // Initial state is expired, forcing a fetch on first use } +const LOGIN_CALLBACK_PATH = "/login-callback" + // 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 @@ -124,8 +126,14 @@ func TokenValidationMiddleware(config *CognitoConfig, routePermissions map[strin requestPath := c.Request().URL.Path logger.Debug("Processing request", "path", requestPath, "method", c.Request().Method) + // Skip token validation for specific public paths + if requestPath == "/home" || requestPath == "/logout" { + logger.Debug("Skipping token validation for public path", "path", requestPath) + return next(c) + } + // Special case for the OAuth callback path - if requestPath == "/query" && (c.QueryParam("code") != "" || c.QueryParam("error") != "") { + if requestPath == LOGIN_CALLBACK_PATH && (c.QueryParam("code") != "" || c.QueryParam("error") != "") { logger.Debug("Handling OAuth callback", "has_code", c.QueryParam("code") != "", "has_error", c.QueryParam("error") != "") @@ -205,6 +213,56 @@ func TokenValidationMiddleware(config *CognitoConfig, routePermissions map[strin } } +// JWTAuthMiddleware checks for JWT token in cookies and adds it to the Authorization header +// This middleware is simpler than full session management and aligns with JWT's stateless nature +func JWTAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Skip for login and callback paths + if c.Path() == "/login" || c.Path() == LOGIN_CALLBACK_PATH { + return next(c) + } + + // Skip for home and logout as they should be accessible without auth + if c.Path() == "/home" || c.Path() == "/logout" { + 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, "/login") + } + + // Add token to Authorization header + c.Request().Header.Set("Authorization", "Bearer "+tokenCookie.Value) + + return next(c) + } +} + +// logoutHandler for JWT-based authentication simply clears the token cookie +func logoutHandler(c echo.Context) 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, "/home") +} + // 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 @@ -290,7 +348,7 @@ func initiateLoginWithPKCE(c echo.Context, config *CognitoConfig, logger *slog.L // 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 +// Exchanges the code for tokens, verifies them, and redirects to home page with token in cookie 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") @@ -357,7 +415,7 @@ func handleOAuthCallback(c echo.Context, config *CognitoConfig, routePermissions } // Check authorization - authorized, requiredGroups := checkPermissions("/query", userGroups, routePermissions, logger) + authorized, requiredGroups := checkPermissions(LOGIN_CALLBACK_PATH, userGroups, routePermissions, logger) if !authorized { logger.Warn("OAuth callback - access denied", "user", idTokenClaims["cognito:username"], @@ -375,17 +433,29 @@ func handleOAuthCallback(c echo.Context, config *CognitoConfig, routePermissions }) } - // 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, - }) + // 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) + + logger.Info("User authenticated successfully", "username", username, "groups", userGroups) + + // Redirect to home page + return c.Redirect(http.StatusFound, "/home") } // generateRandomString creates a cryptographically secure random string @@ -628,6 +698,18 @@ func matchRoute(requestPath, routePattern string) bool { return regex.MatchString(requestPath) } +// 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:] +} + // main is the entry point of the application // Sets up the Echo server, middleware, routes, and starts listening func main() { @@ -676,7 +758,7 @@ func main() { config := &CognitoConfig{ ClientID: os.Getenv("COGNITO_CLIENT_ID"), - RedirectURI: "http://localhost:8080/query", + RedirectURI: "http://localhost:8080" + LOGIN_CALLBACK_PATH, 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 @@ -691,12 +773,19 @@ func main() { "/orders": {"exporters", "exporters"}, "/reports/sales": {"exporters", "uploaders", "querybuilders"}, "/settings": {"exporters"}, - "/query": {"exporters", "querybuilders"}, // Only exporters can access /query + LOGIN_CALLBACK_PATH: {"exporters", "uploaders", "querybuilders"}, // Only exporters can access "/api/inventory/update": {"exporters", "uploaders"}, - "/login": {}, // No permissions required for login + "/login": {"exporters", "uploaders", "querybuilders"}, // No permissions required for login + "/home": {"exporters", "uploaders", "querybuilders"}, // No permissions required for home page + "/logout": {"exporters", "uploaders", "querybuilders"}, // No permissions required for logout } - // Apply token validation middleware to all routes except the login route + // Apply JWT auth middleware first + // This will extract the JWT token from cookies and add it to the Authorization header + e.Use(JWTAuthMiddleware) + + // Then apply token validation middleware + // This will validate the token from the Authorization header e.Use(TokenValidationMiddleware(config, routePermissions, logger)) // Login route @@ -705,8 +794,11 @@ func main() { return nil }) + // Logout route + e.GET("/logout", logoutHandler) + // Callback endpoint - handled by middleware - e.GET("/query", func(c echo.Context) error { + e.GET(LOGIN_CALLBACK_PATH, 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) @@ -748,6 +840,9 @@ func main() { return c.String(http.StatusOK, "Inventory update endpoint") }) + // Home endpoint for debugging - shows all available endpoints + e.GET("/home", HomeHandler) + // Print startup information logger.Info("Server configuration", "client_id", maskString(config.ClientID), @@ -755,7 +850,7 @@ func main() { "user_pool_id", userPoolID, "region", region, "debug_mode", os.Getenv("DEBUG") == "true", - "auth_method", "PKCE") + "auth_method", "PKCE with JWT cookie") // Configure server server := &http.Server{ @@ -770,15 +865,3 @@ func main() { 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 index 0d325f83..02d94f7f 100644 --- a/cmd/cognito_test/cognito.pkce/readme.md +++ b/cmd/cognito_test/cognito.pkce/readme.md @@ -62,7 +62,7 @@ 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" \ + --client-name "public-pkce-client-2" \ --no-generate-secret \ --refresh-token-validity 5 \ --access-token-validity 60 \ @@ -70,7 +70,7 @@ aws --profile aarete --region us-east-2 --endpoint-url https://cognito-idp.us-ea --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 \ + --callback-urls http://localhost:8080/login-callback \ --allowed-o-auth-flows code \ --allowed-o-auth-scopes email openid phone profile \ --allowed-o-auth-flows-user-pool-client \ @@ -181,10 +181,11 @@ Users must belong to the required groups to access protected routes. ``` ## Mermaid Sequence Diagram +Web version [here:](https://mermaid.live/edit#pako:eNp9VE2P2jAQ_SsjH3pisyFAAlHLimVXvfQDle0eKqTK6wxgAXZqO7sFxH_vOISvhZJDlIyf37x5M_aaCZ0hS5nFPwUqgQ-STwxfjBTQk3PjpJA5Vw7ujX6zaM4Xenk-RPOKBriFBy1Wy14u7x_PgX09UdLp7cL2XZHedLt7lhTqAXx-fILbuZ5ItcV90w5B-xxHuIhwqNBwWvNV_KaoHEs0H1_Mbfd0SUz5fI5qglu-PQtlrjSk0AjgB2bSoHDg9Kncg9AqnEKzkql54abRrX9rI1d4FwTBpZyHGvYUrQAGRgu0Fvx2ML4J1m3xFepYYRzsovDFmwMDvmO_IDAJ4CeFQBjMUDnJ5_a_StoBPPO5zErD3uMvKOkcefUmSbqv-FpPw-Om3ghy5oWL2Z3f9okMu9Lmet3nckbi67s-n_dyX0-dhmPwfXhoj9MzVOVg7FJ-OOG6JOLA1ji2h7YBVxmcyjiYdKydhuTJZ7bUW5trZfFapTQPz550CaVcW-rtT1HMIEezkNZKorgywnWakB7V69snuCM02EL4ARsXc1ZjC2LhMqPzvvYsI0bQBY5YSp8ZN7MRG6kN4cgzPVwqwVJnCqwxo4vJlKVjGgn6K3JvRHVT7CB0xH9pvf-l4XDafN1eLuUdU2MT41NXjKgyNH1dKMfSZrPcz9I1-8vSOtndasZxmMRxsxWGUVxjSwJFQScMwySs4smmxlZlxjDoxI0wSpJ2GLXaSdSMNv8APJ-aTA) ```mermaid sequenceDiagram participant Browser - participant AppServer as App Server + participant AppServer as DoczyApiBE participant Cognito Browser->>AppServer: 1. GET /login @@ -196,7 +197,7 @@ sequenceDiagram Browser->>Cognito: 7. User credentials Note over Cognito: 8. Validate credentials Cognito->>Browser: 9. Redirect with code - Browser->>AppServer: 10. GET /query?code=... + Browser->>AppServer: 10. GET /login-callback?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