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.
+
Login
+
+ `
+ }
+
+ // 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