pr fixes part 1

This commit is contained in:
jay brown
2025-04-17 11:53:26 -07:00
parent 7d874fb1ab
commit c229fb5a13
14 changed files with 1 additions and 2493 deletions
-4
View File
@@ -17,10 +17,6 @@ func (s *Controllers) Login(ctx echo.Context) error {
} }
func (s *Controllers) LoginCallback(ctx echo.Context, params LoginCallbackParams) error { func (s *Controllers) LoginCallback(ctx echo.Context, params LoginCallbackParams) error {
// Handle OAuth2 callback
// This is a placeholder implementation - you'll need to implement the actual token exchange logic
//redirectURL := "https://doczy.com" // Replace with your actual redirect URL
//return ctx.Redirect(http.StatusFound, redirectURL)
return ctx.JSON(http.StatusOK, map[string]string{"message": "Login callback is a no op since its handled by the middleware."}) return ctx.JSON(http.StatusOK, map[string]string{"message": "Login callback is a no op since its handled by the middleware."})
} }
@@ -1,328 +0,0 @@
package main
// PKCE specific types and functions
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/labstack/echo/v4"
)
// CognitoTokenResponse represents the response from the token endpoint
// Returned by Cognito when exchanging an authorization code for tokens
type CognitoTokenResponse struct {
IDToken string `json:"id_token"` // OpenID Connect ID token containing user claims
AccessToken string `json:"access_token"` // OAuth2 access token for accessing resources
RefreshToken string `json:"refresh_token"` // Token used to get new access tokens without re-authentication
ExpiresIn int `json:"expires_in"` // Token validity period in seconds
TokenType string `json:"token_type"` // Type of token, typically "Bearer"
}
// PKCESession stores PKCE code verifier for verification
// We need to store the code verifier temporarily to use during token exchange
type PKCESession struct {
CodeVerifier string
CreatedAt time.Time
ExpiresAt time.Time
}
// Global map to store PKCE sessions by state parameter
// In a production environment, this should be replaced with a proper session store
var pkceSessionMap = struct {
sync.RWMutex
sessions map[string]*PKCESession
}{
sessions: make(map[string]*PKCESession),
}
// initiateLoginWithPKCE generates PKCE code challenge and redirects to Cognito
// This function starts the OAuth authorization flow with PKCE
func initiateLoginWithPKCE(c echo.Context, config *CognitoConfig, logger *slog.Logger) error {
// Generate random state parameter to prevent CSRF
state, err := generateRandomString(32)
if err != nil {
logger.Error("Failed to generate state parameter", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authentication initialization failed"})
}
fmt.Println("random state:", state)
// Generate code verifier (random string between 43-128 chars)
codeVerifier, err := generateRandomString(64)
if err != nil {
logger.Error("Failed to generate code verifier", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authentication initialization failed"})
}
// Create code challenge from verifier (SHA256 + Base64URL without padding)
codeChallenge := createCodeChallenge(codeVerifier)
// Store in session for later verification (with expiration time)
storePKCESession(state, codeVerifier, logger)
// Build authorization URL with PKCE parameters
params := url.Values{}
params.Set("client_id", config.ClientID)
params.Set("response_type", "code")
params.Set("redirect_uri", config.RedirectURI)
params.Set("scope", "openid email profile")
params.Set("state", state)
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
authURL := fmt.Sprintf("%s?%s", config.AuthURL, params.Encode())
if os.Getenv("DEBUG") == "true" {
logger.Debug("Initiating login with PKCE",
"code_verifier", codeVerifier,
"code_challenge", codeChallenge,
"state", state,
"redirect_uri", config.RedirectURI)
}
// Redirect user to Cognito login page
return c.Redirect(http.StatusFound, authURL)
}
// 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
}
// exchangeCodeForTokensWithPKCE exchanges the authorization code for tokens using PKCE
// Called during the OAuth callback flow to get tokens from the authorization code
// Makes an HTTP request to Cognito's token endpoint to perform this exchange
func exchangeCodeForTokensWithPKCE(authCode, codeVerifier string, config CognitoConfig, logger *slog.Logger) (*CognitoTokenResponse, error) {
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("client_id", config.ClientID)
data.Set("code", authCode)
data.Set("redirect_uri", config.RedirectURI)
data.Set("code_verifier", codeVerifier) // Include code verifier for PKCE
logger.Debug("Token request details",
"url", config.TokenURL,
"client_id", config.ClientID,
"redirect_uri", config.RedirectURI)
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
// Add Authorization header if client secret is provided
if config.ClientSecret != "" {
req.SetBasicAuth(config.ClientID, config.ClientSecret)
logger.Debug("Using Basic Auth authentication")
} else {
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 CognitoTokenResponse
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 *CognitoConfig, routePermissions map[string][]string, logger *slog.Logger) error {
// Extract the authorization code and state
code := c.QueryParam("code")
if code == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing code parameter"})
}
state := c.QueryParam("state")
if state == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing state parameter"})
}
// Check for OAuth errors
errorMsg := c.QueryParam("error")
if errorMsg != "" {
errorDesc := c.QueryParam("error_description")
return c.JSON(http.StatusBadRequest, map[string]string{
"error": errorMsg,
"error_description": errorDesc,
})
}
// Retrieve stored code verifier
codeVerifier, err := getCodeVerifier(state, logger)
if err != nil {
logger.Error("Failed to retrieve code verifier", "error", err, "state", state)
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid or expired session"})
}
// Exchange the code for tokens using PKCE
tokens, err := exchangeCodeForTokensWithPKCE(code, codeVerifier, *config, logger)
if err != nil {
logger.Error("Failed to exchange code for tokens", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": err.Error()})
}
// Get the JWKS for token verification
keySet, err := GetJWKS(config.JwksURL, logger)
if err != nil {
logger.Error("Failed to fetch JWKS", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
}
// Verify ID token
idTokenClaims, err := verifyToken(tokens.IDToken, keySet, *config)
if err != nil {
logger.Error("Failed to verify ID token", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
}
// Extract user groups
userGroups, err := GetUserGroups(idTokenClaims)
if err != nil {
logger.Warn("Failed to extract user groups", "error", err)
userGroups = []string{} // Initialize as empty array
}
// Debug logging for tokens
if os.Getenv("DEBUG") == "true" {
// Log decoded token for debugging
logger.Info("ID Token Claims", "claims", idTokenClaims)
logger.Info("Raw ID Token", "token", tokens.IDToken)
logger.Info("Raw Access Token", "token", tokens.AccessToken)
}
// Check authorization
authorized, requiredGroups := checkPermissions(LOGIN_CALLBACK_PATH, userGroups, routePermissions, logger)
if !authorized {
logger.Warn("OAuth callback - access denied",
"user", idTokenClaims["cognito:username"],
"groups", userGroups,
"required", requiredGroups)
return c.JSON(http.StatusForbidden, map[string]interface{}{
"error": "Insufficient permissions",
"message": "User authenticated successfully but doesn't have the required group membership",
"authenticated": true,
"username": idTokenClaims["cognito:username"],
"email": idTokenClaims["email"],
"groups": userGroups,
"required_groups": requiredGroups,
})
}
// User is authenticated and authorized
// 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")
}
@@ -1,59 +0,0 @@
# PKCE Session Management
`PKCESession` and `pkceSessionMap` are uses as part of implementing the Proof Key for Code Exchange (PKCE) flow for OAuth 2.0 authorization with Amazon Cognito.
## What PKCE is solving
PKCE extends the OAuth 2.0 authorization code flow to protect against authorization code interception attacks, which is particularly important for public clients (like browser-based apps) that can't securely store a client secret.
## Role of PKCESession and pkceSessionMap
The `PKCESession` and `pkceSessionMap` together implement a temporary storage mechanism that's critical for the PKCE workflow. Here's why they're necessary:
1. **Maintaining state across HTTP requests**: OAuth requires multiple HTTP redirects between your application and the identity provider (Cognito in this case). The code needs to remember information from the initial request when the callback happens later.
2. **Storing the code verifier**: In PKCE, the code verifier is a cryptographically random string generated at the start of the flow, but needed later when exchanging the authorization code for tokens.
## The exact flow and how they're used
Let's walk through how they're used in the code:
1. **Initial authentication request** (`initiateLoginWithPKCE` function):
- Generates a random `state` parameter to prevent CSRF attacks
- Generates a random `codeVerifier` string
- Creates a `codeChallenge` by hashing the verifier using SHA-256 and encoding it
- Stores the `codeVerifier` in the `pkceSessionMap` with the `state` as the key
- Sends the user to Cognito with the `codeChallenge` but NOT the `codeVerifier`
2. **Storage phase** (`storePKCESession` function):
- Creates a new `PKCESession` with the `codeVerifier`
- Sets an expiration time (5 minutes) to clean up if the flow isn't completed
- Stores it in the in-memory `pkceSessionMap` using the `state` as a key
- Also performs cleanup of expired sessions
3. **Callback handling** (`handleOAuthCallback` function):
- When Cognito redirects back with the authorization code and state
- Retrieves the previously stored `codeVerifier` using the `state` parameter from the query string
- Uses `getCodeVerifier` function to look up the session in the `pkceSessionMap`
- Verifies the session hasn't expired
4. **Token exchange** (`exchangeCodeForTokensWithPKCE` function):
- Sends the authorization code AND the original code verifier to Cognito
- Cognito verifies that the code verifier, when hashed, matches the code challenge it received earlier
- If valid, Cognito issues the tokens
- The app then validates these tokens and sets them as cookies
Without the `PKCESession` and `pkceSessionMap`, the application would have no way to remember the code verifier between the initial request and the callback, making it impossible to complete the PKCE flow.
## Security benefits
This approach:
1. Prevents authorization code interception attacks: Even if an attacker intercepts the authorization code, they can't exchange it for tokens without knowing the code verifier, which never leaves your server.
2. Protects against CSRF: The state parameter ensures the callback is responding to a request that your application initiated.
3. Ensures secure token issuance: Cognito will only issue tokens if the code verifier matches the challenge it received earlier.
The implementation in this code uses an in-memory map with locking for thread safety, though as noted in the comments, a production application would likely use a more robust session store (like Redis) especially in a distributed environment.
@@ -1,182 +0,0 @@
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("<li>%s (requires parameter)</li>\n", displayPath)
} else {
linksHTML += fmt.Sprintf("<li><a href=\"%s%s\">%s</a></li>\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(`
<div class="auth-status authenticated">
<h2>Authentication Status: Authenticated</h2>
<p><strong>Username:</strong> %s</p>
<p><strong>Email:</strong> %s</p>
<p><strong>Groups:</strong> %s</p>
<p><a href="/logout" class="logout-btn">Logout</a></p>
</div>
`, username, email, strings.Join(userGroups, ", "))
} else {
authStatusHTML = `
<div class="auth-status unauthenticated">
<h2>Authentication Status: Not Authenticated</h2>
<p>You are not currently logged in.</p>
<p><a href="/login" class="login-btn">Login</a></p>
</div>
`
}
// Create the complete HTML page
html := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<title>Doczy Home</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 20px;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h1, h2 {
color: #333;
border-bottom: 1px solid #ddd;
padding-bottom: 10px;
}
ul {
margin-top: 20px;
}
li {
margin-bottom: 8px;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.note {
background-color: #f8f9fa;
border-left: 4px solid #5bc0de;
padding: 10px 15px;
margin-top: 20px;
font-size: 0.9em;
}
.auth-status {
margin: 20px 0;
padding: 15px;
border-radius: 5px;
}
.authenticated {
background-color: #dff0d8;
border: 1px solid #d6e9c6;
}
.unauthenticated {
background-color: #f2dede;
border: 1px solid #ebccd1;
}
.login-btn, .logout-btn {
display: inline-block;
padding: 8px 16px;
background-color: #0066cc;
color: white;
border-radius: 4px;
text-decoration: none;
margin-top: 10px;
}
.logout-btn {
background-color: #d9534f;
}
</style>
</head>
<body>
<h1>Doczy Home</h1>
%s
<h2>Available Endpoints</h2>
<ul>
%s
</ul>
<div class="note">
<p><strong>Note:</strong> This is a debugging page. Some endpoints require authentication or specific permissions.</p>
</div>
</body>
</html>
`, authStatusHTML, linksHTML)
return c.HTML(http.StatusOK, html)
}
@@ -1,83 +0,0 @@
# JWKSCache
This cache is needed for efficient token validation in this OAuth 2.0 implementation with Amazon Cognito.
Below is how it's used and why its needed.
## Why the JWKSCache is needed
1. **Performance optimization**: Without caching, the application would need to fetch the JSON Web Key Set (JWKS) from Cognito's servers on every token validation, which would be inefficient and could lead to rate limiting.
2. **Reduced network dependency**: Caching reduces the application's dependency on network availability to the JWKS endpoint, making token validation more reliable.
3. **Lower latency**: Token validation happens faster with cached keys, improving overall application response time.
## Where and how JWKSCache is used
The JWKS cache is primarily used during JWT token validation. Here's the flow:
1. **Initialization**:
- A global `jwksCache` instance is created at the application level with an initially expired state.
```go
var jwksCache = &JWKSCache{
ExpiresAt: time.Now(), // Initial state is expired, forcing a fetch on first use
}
```
2. **Key retrieval** through the `GetJWKS` function:
- This function is called during token validation to get the public keys needed to verify token signatures.
- It's called from two main places:
- The `TokenValidationMiddleware` for API requests
- The `handleOAuthCallback` function during the OAuth callback
3. **Caching logic** in the `GetJWKS` function:
- First tries to read from cache using a read lock
- If keys are valid (not expired), returns them immediately
- If expired, acquires a write lock and fetches new keys
- Sets a 24-hour expiration time for the newly fetched keys
```go
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
}
// Fetch and store new key set...
}
```
4. **Actual token verification** in the `verifyToken` function:
- Uses the JWKS from the cache to verify the token's signature
- Validates claims like issuer and expiration
- Returns the token's claims if verification is successful
## Thread safety aspects
The implementation uses a read-write mutex (`sync.RWMutex`) to allow multiple readers but exclusive writers, following a common pattern for high-performance caching:
1. **Read lock** for cache hits: Many requests can read the cache simultaneously
2. **Write lock** for cache misses: Only one request updates the cache at a time
3. **Double-check pattern**: Rechecks expiration after acquiring the write lock to prevent multiple fetches
## Benefits in the application
In the application flow:
1. First request fetches the JWKS from Cognito and caches it
2. Subsequent requests use the cached keys
3. After 24 hours, a new request will trigger a refresh
4. If the network is temporarily unavailable, the application can still validate tokens using the cached keys
This caching is particularly important because the application validates tokens on every protected API request, making efficiency crucial for good performance.
-576
View File
@@ -1,576 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// CognitoConfig holds the configuration for AWS Cognito
// Contains all necessary parameters to interact with Cognito endpoints
type CognitoConfig struct {
ClientID string // OAuth2 client ID registered with Cognito
RedirectURI string // URL where Cognito redirects after authentication
TokenURL string // Cognito endpoint for token operations
JwksURL string // URL for JSON Web Key Set (for token verification)
UserPoolID string // Cognito User Pool ID
AuthURL string // Cognito authorization endpoint for initiating login
ClientSecret string // Client secret for authenticated clients
}
// JWKSCache caches the JWKS to avoid frequent fetches
// Implements a thread-safe caching mechanism with expiration
type JWKSCache struct {
KeySet jwk.Set // The cached JSON Web Key Set
ExpiresAt time.Time // Expiration time for the cache
mutex sync.RWMutex // Mutex for thread-safe access
}
// Global JWKS cache instance
var jwksCache = &JWKSCache{
ExpiresAt: time.Now(), // Initial state is expired, forcing a fetch on first use
}
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
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
}
// PkceTokenValidationMiddleware verifies JWT tokens efficiently
// This is the primary middleware that handles both authentication and authorization
// Applied to all routes to enforce security requirements
func PkceTokenValidationMiddleware(config *CognitoConfig, routePermissions map[string][]string, logger *slog.Logger) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
requestPath := c.Request().URL.Path
logger.Debug("Processing request", "path", requestPath, "method", c.Request().Method)
// 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 == LOGIN_CALLBACK_PATH && (c.QueryParam("code") != "" || c.QueryParam("error") != "") {
logger.Debug("Handling OAuth callback",
"has_code", c.QueryParam("code") != "",
"has_error", c.QueryParam("error") != "")
// Skip token validation for OAuth callback - it will be handled by the callback handler
return handleOAuthCallback(c, config, routePermissions, logger)
}
// Initialize login flow if this is a login request - first thing after we arrive at /login
if requestPath == "/login" {
return initiateLoginWithPKCE(c, config, logger)
}
// Get authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
logger.Warn("No authorization header provided")
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization required"})
}
// Extract token
tokenStr := authHeader
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr = authHeader[7:]
}
// Get JWKS (cached if possible)
keySet, err := GetJWKS(config.JwksURL, logger)
if err != nil {
logger.Error("Failed to get JWKS", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
}
// Verify token
claims, err := verifyToken(tokenStr, keySet, *config)
if err != nil {
logger.Warn("Token verification failed", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
}
// Debug logging if enabled
if os.Getenv("DEBUG") == "true" {
logger.Info("Token verified successfully", "claims", claims)
}
// Extract user groups
userGroups, err := GetUserGroups(claims)
if err != nil {
logger.Warn("Failed to extract user groups", "error", err)
userGroups = []string{} // Empty array if no groups found
}
// Store in context
c.Set("user_claims", claims)
c.Set("user_groups", userGroups)
// Check authorization
authorized, requiredGroups := checkPermissions(requestPath, userGroups, routePermissions, logger)
if !authorized {
logger.Warn("Access denied",
"path", requestPath,
"user", claims["cognito:username"],
"groups", userGroups,
"required", requiredGroups)
return c.JSON(http.StatusForbidden, map[string]interface{}{
"error": "Insufficient permissions",
"message": "User doesn't have the required group membership",
"username": claims["cognito:username"],
"groups": userGroups,
"required_groups": requiredGroups,
})
}
// User is authenticated and authorized
return next(c)
}
}
}
// JWTAuthMiddleware checks for JWT token in cookies and adds it to the Authorization header
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
func checkPermissions(path string, userGroups []string, routePermissions map[string][]string, logger *slog.Logger) (bool, []string) {
// Find matching route pattern
var requiredGroups []string
var matched bool
for pattern, groups := range routePermissions {
if matchRoute(path, pattern) {
requiredGroups = groups
matched = true
logger.Debug("Found matching route pattern", "pattern", pattern, "requiredGroups", requiredGroups)
break
}
}
if !matched {
// No matching pattern found - could either allow or deny by default
logger.Info("No matching route pattern found for path", "path", path)
return true, nil // Allowing by default
}
// Check if user has any of the required groups
for _, requiredGroup := range requiredGroups {
for _, userGroup := range userGroups {
if userGroup == requiredGroup {
logger.Debug("User has required group", "group", requiredGroup)
return true, requiredGroups
}
}
}
return false, requiredGroups
}
// verifyToken verifies the JWT token and returns its claims
// Used during both OAuth callback and subsequent API requests with bearer token
// Verifies signature, expiration, issuer, and other JWT claims
func verifyToken(tokenString string, keySet jwk.Set, config CognitoConfig) (map[string]interface{}, error) {
region := "us-east-2"
if strings.Contains(config.JwksURL, "amazonaws.com/") {
parts := strings.Split(config.JwksURL, "amazonaws.com/")
if len(parts) > 1 {
regionParts := strings.Split(parts[0], ".")
if len(regionParts) > 0 {
lastPart := regionParts[len(regionParts)-1]
if lastPart != "" {
region = lastPart
}
}
}
}
issuer := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", region, config.UserPoolID)
// Verify the token with the keySet
verifiedToken, err := jwt.Parse(
[]byte(tokenString),
jwt.WithKeySet(keySet),
jwt.WithValidate(true),
jwt.WithIssuer(issuer),
// Add other validation options as needed
)
if err != nil {
return nil, err
}
// Extract claims to a map
claims, err := verifiedToken.AsMap(context.Background())
if err != nil {
return nil, err
}
return claims, nil
}
// GetUserGroups extracts user groups from the token claims
// Attempts to find groups in different claim names depending on Cognito setup
// Returns an array of group names or an error if no groups found
func GetUserGroups(claims map[string]interface{}) ([]string, error) {
// The claim containing the groups might have different names depending on your Cognito setup
// Common names are "cognito:groups", "groups", or a custom attribute
possibleGroupFields := []string{"cognito:groups", "groups", "custom:groups"}
for _, field := range possibleGroupFields {
if rawGroups, ok := claims[field]; ok {
switch groups := rawGroups.(type) {
case []interface{}:
result := make([]string, len(groups))
for i, g := range groups {
result[i] = fmt.Sprintf("%v", g)
}
return result, nil
case []string:
return groups, nil
case string:
return []string{groups}, nil
}
}
}
return nil, errors.New("no groups found in token claims")
}
// fetchJWKS retrieves the JSON Web Key Set from Cognito
// Used to get the public keys needed to verify token signatures
// Makes an HTTP request to the JWKS URL and returns the raw JSON
func fetchJWKS(jwksURL string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", jwksURL, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
jwksData, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(jwksData), nil
}
// matchRoute checks if a request path matches a route pattern
// Converts Echo route patterns to regex for matching
// Supports parameter patterns like '/users/:id' and wildcard patterns
func matchRoute(requestPath, routePattern string) bool {
// Convert Echo route pattern to regex pattern
// e.g., "/users/:id" to "/users/([^/]+)"
regexPattern := "^"
patternParts := strings.Split(routePattern, "/")
for i, part := range patternParts {
if i > 0 {
regexPattern += "/"
}
if strings.HasPrefix(part, ":") {
// Parameter part (e.g., :id)
regexPattern += "([^/]+)"
} else if part == "*" {
// Wildcard - match anything
regexPattern += ".*"
} else {
// Literal part - escape any regex metacharacters
regexPattern += regexp.QuoteMeta(part)
}
}
regexPattern += "$"
// Compile and match
regex, err := regexp.Compile(regexPattern)
if err != nil {
return false
}
return regex.MatchString(requestPath)
}
// Helper function to mask sensitive values
// Used for logging sensitive information like client IDs
func maskString(s string) string {
if s == "" {
return "<not set>"
}
if len(s) <= 8 {
return "****"
}
return s[:4] + "..." + s[len(s)-4:]
}
func getJwksURL(region, userPoolID string) string {
// original logic
// : fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", region, userPoolID),
return fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", region, userPoolID)
}
// main is the entry point of the application
// Sets up the Echo server, middleware, routes, and starts listening
func main() {
// Create a new Echo instance
e := echo.New()
// Basic middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Initialize logger with appropriate level based on DEBUG env var
var logLevel slog.Level
if os.Getenv("DEBUG") == "true" {
logLevel = slog.LevelDebug
} else {
logLevel = slog.LevelInfo
}
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
logger := slog.New(logHandler)
// Get Cognito configuration
region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-2" // Fallback to hardcoded value
}
userPoolID := os.Getenv("COGNITO_USER_POOL_ID")
if userPoolID == "" {
userPoolID = "us-east-2_1y6po8rR8" // Fallback to hardcoded value
}
domain := os.Getenv("COGNITO_DOMAIN")
// In main function
if domain == "" {
logger.Warn("COGNITO_DOMAIN environment variable is not set, using default")
// Use the user pool ID directly as part of the domain
domain = fmt.Sprintf("%s.auth.%s.amazoncognito.com", userPoolID, region)
}
fmt.Printf("Using domain %s\n", domain)
// Ensure domain format is correct (no protocol prefix)
domain = strings.TrimPrefix(domain, "https://")
domain = strings.TrimPrefix(domain, "http://")
config := &CognitoConfig{
ClientID: os.Getenv("COGNITO_CLIENT_ID"),
ClientSecret: os.Getenv("COGNITO_CLIENT_SECRET"),
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
JwksURL: getJwksURL(region, userPoolID),
UserPoolID: userPoolID,
}
// Route permissions - matching the provided example
routePermissions := map[string][]string{
"/users": {"exporters", "uploaders", "querybuilders"},
"/users/:id": {"exporters", "querybuilders"},
"/orders": {"exporters", "exporters"},
"/reports/sales": {"exporters", "uploaders", "querybuilders"},
"/settings": {"exporters"},
LOGIN_CALLBACK_PATH: {"exporters", "uploaders", "querybuilders"}, // Only exporters can access
"/api/inventory/update": {"exporters", "uploaders"},
"/login": {}, // No permissions required for login
"/home": {}, // No permissions required for home page
"/logout": {}, // No permissions required for logout
}
// 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(PkceTokenValidationMiddleware(config, routePermissions, logger))
// Login route entirely handled by middleware - nothing to do here.
e.GET("/login", func(c echo.Context) error {
// The PkceTokenValidationMiddleware will handle this
return nil
})
// Logout route
e.GET("/logout", logoutHandler)
// Callback endpoint - handled by middleware
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)
// Get user info from context
userClaims, _ := c.Get("user_claims").(map[string]interface{})
userGroups, _ := c.Get("user_groups").([]string)
return c.JSON(http.StatusOK, map[string]interface{}{
"authenticated": true,
"username": userClaims["cognito:username"],
"email": userClaims["email"],
"groups": userGroups,
})
})
// Other endpoint handlers - just for demonstration
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, fmt.Sprintf("User details endpoint for ID (OK): %s", 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)")
})
// Home endpoint for debugging - shows all available endpoints
e.GET("/home", HomeHandler)
// Print startup information
logger.Info("Server configuration",
"client_id", maskString(config.ClientID),
"domain", domain,
"user_pool_id", userPoolID,
"region", region,
"debug_mode", os.Getenv("DEBUG") == "true",
"auth_method", "PKCE with JWT cookie")
// Configure server
server := &http.Server{
Addr: ":8080",
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Start server with custom server config
logger.Info("Server starting on :8080")
e.Logger.Fatal(e.StartServer(server))
}
@@ -1,74 +0,0 @@
# sequence diagram
```mermaid
sequenceDiagram
participant Client
participant Echo as Echo Server
participant JWTAuth as JWTAuthMiddleware
participant TokenVal as TokenValidationMiddleware
participant Login as initiateLoginWithPKCE
participant Cognito as AWS Cognito
participant Callback as handleOAuthCallback
participant Verify as verifyToken
participant JWKS as GetJWKS
participant Home as HomeHandler
Client->>Echo: GET /login
Echo->>JWTAuth: JWTAuthMiddleware
Note over JWTAuth: Skips auth check for /login path
JWTAuth-->>Echo: Proceed to next handler
Echo->>TokenVal: TokenValidationMiddleware
Note over TokenVal: Detects login request
TokenVal->>Login: initiateLoginWithPKCE()
Login->>Login: generateRandomString() for state
Login->>Login: generateRandomString() for codeVerifier
Login->>Login: createCodeChallenge(codeVerifier)
Login->>Login: storePKCESession(state, codeVerifier)
Login-->>Client: HTTP 302 Redirect to Cognito auth URL
Client->>Cognito: GET Cognito Auth URL
Note over Cognito: User authenticates with Cognito
Cognito-->>Client: HTTP 302 Redirect to /login-callback with code
Client->>Echo: GET /login-callback?code=xxx&state=yyy
Echo->>JWTAuth: JWTAuthMiddleware
Note over JWTAuth: Skips auth check for callback path
JWTAuth-->>Echo: Proceed to next handler
Echo->>TokenVal: TokenValidationMiddleware
Note over TokenVal: Detects OAuth callback
TokenVal->>Callback: handleOAuthCallback()
Callback->>Callback: getCodeVerifier(state)
Callback->>Callback: exchangeCodeForTokensWithPKCE(code, codeVerifier)
Callback->>Cognito: POST /oauth2/token
Cognito-->>Callback: Return tokens (IDToken, AccessToken)
Callback->>JWKS: GetJWKS(config.JwksURL)
JWKS-->>Callback: Return JWKS
Callback->>Verify: verifyToken(tokens.IDToken, keySet)
Verify-->>Callback: Return verified token claims
Callback->>Callback: GetUserGroups(idTokenClaims)
Callback->>Callback: checkPermissions(path, userGroups, routePermissions)
Note over Callback: Sets auth_token cookie with AccessToken
Callback-->>Client: HTTP 302 Redirect to /home
Client->>Echo: GET /home
Echo->>JWTAuth: JWTAuthMiddleware
Note over JWTAuth: Skips auth check for /home path
JWTAuth-->>Echo: Proceed to next handler
Echo->>TokenVal: TokenValidationMiddleware
Note over TokenVal: Skips token validation for /home path
TokenVal-->>Echo: Proceed to next handler
Echo->>Home: HomeHandler()
Home-->>Client: Return HTML with auth status and links
```
-227
View File
@@ -1,227 +0,0 @@
# AWS Cognito PKCE Authentication Example
This directory contains a test harness for validating AWS Cognito PKCE (Proof Key for Code Exchange) authentication
and authorization with Go and the Echo web framework. It demonstrates a complete OAuth 2.0 flow with
role-based access control (RBAC).
It is temporary and will go away once the final middleware is in place.
## What is PKCE?
PKCE (Proof Key for Code Exchange) is an extension to the OAuth 2.0 authorization code flow designed to prevent authorization code interception attacks. It's particularly important for:
- Public clients (like SPAs and mobile apps)
- Applications where securely storing a client secret is difficult
- Applications that run in potentially untrusted environments
PKCE works by using a dynamically created cryptographic challenge derived from a secret code verifier. This prevents attackers from exchanging an intercepted authorization code for tokens without possessing the original code verifier.
## How PKCE Works in This Application
1. **Code Verifier Generation**: When a user initiates login, the application generates a random string (code verifier).
2. **Code Challenge Creation**: The application derives a code challenge from the verifier using SHA-256 hashing and Base64URL encoding.
3. **Authorization Request**: The application redirects to Cognito with the code challenge.
4. **User Authentication**: The user authenticates with Cognito.
5. **Authorization Code**: Cognito redirects back to the application with an authorization code.
6. **Token Exchange**: The application exchanges the code and original code verifier for access and ID tokens.
## Prerequisites
- Go 1.16+
- AWS account with Cognito User Pool
- Properly configured Cognito App Client (see setup section)
## Dependencies
- [Echo](https://github.com/labstack/echo) (v4) - Web framework
- [JWKS](https://github.com/lestrrat-go/jwx) - JSON Web Key Set handling for token verification
## Cognito App Client Requirements
Your Cognito App Client must be configured as follows:
1. **No Client Secret**: The app client should be configured as a public client without a client secret.
2. **Allowed OAuth Flows**: Authorization code grant
3. **Allowed OAuth Scopes**: `openid`, `email`, `profile` (and optionally `phone`)
4. **Callback URL**: Must include `http://localhost:8080/query` (or your custom endpoint)
## Environment Variables
| Variable | Description | Example | Required |
|----------|-------------|---------|----------|
| `COGNITO_CLIENT_ID` | Your Cognito App Client ID | `6u3ppajoksbkdlteacg0ukbnfi` | Yes |
| `COGNITO_USER_POOL_ID` | Your Cognito User Pool ID | `us-east-2_1y6po8rR8` | Yes |
| `AWS_REGION` | AWS Region of your Cognito User Pool | `us-east-2` | Yes |
| `COGNITO_DOMAIN` | Your Cognito domain | `your-domain.auth.us-east-2.amazoncognito.com` | No (falls back to default) |
| `DEBUG` | Enable debug logging | `true` | No (defaults to false) |
## Creating a Compatible Cognito App Client
You can create a compatible app client with the AWS CLI:
# Creating a cognito app client for cognito pkce /login
Note that both of the examples below create clients that work except they have broken login pages.
When we cretae them from the AWS console however they work fine. (TODO research)
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-2" \
--no-generate-secret \
--refresh-token-validity 5 \
--access-token-validity 60 \
--id-token-validity 60 \
--token-validity-units "RefreshToken=days,IdToken=minutes,AccessToken=minutes" \
--explicit-auth-flows ALLOW_REFRESH_TOKEN_AUTH ALLOW_USER_AUTH ALLOW_USER_SRP_AUTH \
--supported-identity-providers COGNITO \
--callback-urls http://localhost:8080/login-callback \
--allowed-o-auth-flows code \
--allowed-o-auth-scopes email openid phone profile \
--allowed-o-auth-flows-user-pool-client \
--prevent-user-existence-errors ENABLED
```
Creating a client with a secret
```
aws --profile aarete --region us-east-2 cognito-idp create-user-pool-client \
--user-pool-id us-east-2_1y6po8rR8 \
--client-name "public-pkce-client-with-secret-1" \
--refresh-token-validity 5 \
--access-token-validity 60 \
--id-token-validity 60 \
--token-validity-units "RefreshToken=days,IdToken=minutes,AccessToken=minutes" \
--explicit-auth-flows ALLOW_REFRESH_TOKEN_AUTH ALLOW_USER_AUTH ALLOW_USER_SRP_AUTH \
--supported-identity-providers COGNITO \
--callback-urls http://localhost:8080/login-callback \
--allowed-o-auth-flows code \
--allowed-o-auth-scopes email openid phone profile \
--allowed-o-auth-flows-user-pool-client \
--prevent-user-existence-errors ENABLED
```
## Setup and Running
1.2 omitted
3. Set your environment variables:
```bash
export COGNITO_CLIENT_ID=<your-client-id>
export COGNITO_USER_POOL_ID=<your-user-pool-id>
export AWS_REGION=<your-region>
export DEBUG=true # Optional for debug logging
```
4. Run the application:
```bash
go run main.go
```
5. Visit `http://localhost:8080/login` in your browser to start the authentication flow
## Key Features
- **PKCE Implementation**: Secure authentication flow without client secrets
- **Token Validation**: JWT verification using Cognito's JWKS
- **Authorization**: Role-based access control using Cognito groups
- **Caching**: JWKS caching to minimize API calls
- **Token Handling**: Access and ID token management
- **Middleware Architecture**: Authentication and authorization via Echo middleware
## Authorization & Groups
This application implements role-based access control using Cognito user groups. Routes can be protected based on group membership:
```go
routePermissions := map[string][]string{
"/users": {"exporters"},
"/users/:id": {"exporters", "querybuilders"},
// Other routes and permissions
}
```
Users must belong to the required groups to access protected routes.
## Security Considerations
- The application uses in-memory state management for PKCE sessions. In production, use a persistent and scalable storage solution.
- The application has a 5-minute expiration for PKCE sessions.
- Proper error handling prevents information leakage.
- Token verification checks issuer, signature, and expiration.
## Flow Diagram
```
┌─────────┐ ┌───────────┐ ┌─────────┐
│ Browser │ │ App Server│ │ Cognito │
└────┬────┘ └─────┬─────┘ └────┬────┘
│ │ │
│ 1. GET /login │ │
│ ─────────────────────────────► │
│ │ │
│ │ 2. Generate code_verifier │
│ │ Generate code_challenge │
│ │ │
│ 3. Redirect to Cognito │ │
│ ◄───────────────────────────── │
│ │ │
│ 4. GET /oauth2/authorize?...code_challenge │
│ ────────────────────────────────────────────────────────────►
│ │ │
│ │ │ 5. Process auth
│ │ │ request
│ 6. Cognito Login Page │ │
│ ◄────────────────────────────────────────────────────────────
│ │ │
│ 7. User credentials │ │
│ ────────────────────────────────────────────────────────────►
│ │ │
│ │ │ 8. Validate
│ │ │ credentials
│ 9. Redirect with code │ │
│ ◄────────────────────────────────────────────────────────────
│ │ │
│ 10. GET /query?code=... │ │
│ ─────────────────────────────► │
│ │ │
│ │ 11. Retrieve code_verifier │
│ │ │
│ │ 12. POST /oauth2/token │
│ │ code=...&code_verifier=...
│ │ ─────────────────────────────►
│ │ │
│ │ │ 13. Validate
│ │ │ code and
│ │ │ verifier
│ │ 14. Tokens response │
│ │ ◄─────────────────────────────
│ │ │
│ │ 15. Verify tokens │
│ │ Check permissions │
│ │ │
│ 16. Authentication successful│ │
│ ◄───────────────────────────── │
│ │ │
```
## Mermaid Sequence Diagram
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 DoczyApiBE
participant Cognito
Browser->>AppServer: 1. GET /login
Note over AppServer: 2. Generate code_verifier<br/>Generate code_challenge
AppServer->>Browser: 3. Redirect to Cognito
Browser->>Cognito: 4. GET /oauth2/authorize?...code_challenge
Note over Cognito: 5. Process auth request
Cognito->>Browser: 6. Cognito Login Page
Browser->>Cognito: 7. User credentials
Note over Cognito: 8. Validate credentials
Cognito->>Browser: 9. Redirect with code
Browser->>AppServer: 10. GET /login-callback?code=...
Note over AppServer: 11. Retrieve code_verifier
AppServer->>Cognito: 12. POST /oauth2/token<br/>code=...&code_verifier=...
Note over Cognito: 13. Validate code and verifier
Cognito->>AppServer: 14. Tokens response
Note over AppServer: 15. Verify tokens<br/>Check permissions
AppServer->>Browser: 16. Authentication successful
```
-638
View File
@@ -1,638 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// CognitoTokenResponse represents the response from the token endpoint
// Returned by Cognito when exchanging an authorization code for tokens
type CognitoTokenResponse struct {
IDToken string `json:"id_token"` // OpenID Connect ID token containing user claims
AccessToken string `json:"access_token"` // OAuth2 access token for accessing resources
RefreshToken string `json:"refresh_token"` // Token used to get new access tokens without re-authentication
ExpiresIn int `json:"expires_in"` // Token validity period in seconds
TokenType string `json:"token_type"` // Type of token, typically "Bearer"
}
// CognitoConfig holds the configuration for AWS Cognito
// Contains all necessary parameters to interact with Cognito endpoints
type CognitoConfig struct {
ClientID string // OAuth2 client ID registered with Cognito
ClientSecret string // Client secret for authenticated clients
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
}
// JWKSCache caches the JWKS to avoid frequent fetches
// Implements a thread-safe caching mechanism with expiration
type JWKSCache struct {
KeySet jwk.Set // The cached JSON Web Key Set
ExpiresAt time.Time // Expiration time for the cache
mutex sync.RWMutex // Mutex for thread-safe access
}
// Global JWKS cache instance
var jwksCache = &JWKSCache{
ExpiresAt: time.Now(), // Initial state is expired, forcing a fetch on first use
}
// GetJWKS returns the cached JWKS or fetches a new one if needed
// This function implements caching logic to minimize external requests
// Used during token validation to get the key set for signature verification
func GetJWKS(jwksURL string, logger *slog.Logger) (jwk.Set, error) {
// Try to use cached JWKS first (read lock)
jwksCache.mutex.RLock()
if jwksCache.KeySet != nil && time.Now().Before(jwksCache.ExpiresAt) {
defer jwksCache.mutex.RUnlock()
logger.Debug("Using cached JWKS")
return jwksCache.KeySet, nil
}
jwksCache.mutex.RUnlock()
// Need to fetch new JWKS (write lock)
jwksCache.mutex.Lock()
defer jwksCache.mutex.Unlock()
// Double-check expiration after acquiring the write lock
if jwksCache.KeySet != nil && time.Now().Before(jwksCache.ExpiresAt) {
return jwksCache.KeySet, nil
}
logger.Info("Fetching new JWKS", "url", jwksURL)
jwksJSON, err := fetchJWKS(jwksURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
}
keySet, err := jwk.ParseString(jwksJSON)
if err != nil {
return nil, fmt.Errorf("failed to parse JWKS: %w", err)
}
jwksCache.KeySet = keySet
// Cache for 24 hours - you can adjust this based on your needs
jwksCache.ExpiresAt = time.Now().Add(24 * time.Hour)
return keySet, nil
}
// TokenValidationMiddleware verifies JWT tokens efficiently
// This is the primary middleware that handles both authentication and authorization
// Applied to all routes to enforce security requirements
func TokenValidationMiddleware(config *CognitoConfig, routePermissions map[string][]string, logger *slog.Logger) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
requestPath := c.Request().URL.Path
logger.Debug("Processing request", "path", requestPath, "method", c.Request().Method)
// Special case for the OAuth callback path
if requestPath == "/query" && c.QueryParam("code") != "" {
// Skip token validation for OAuth callback - it will be handled by the callback handler
return handleOAuthCallback(c, config, routePermissions, logger)
}
// Get authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
logger.Warn("No authorization header provided")
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization required"})
}
// Extract token
tokenStr := authHeader
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr = authHeader[7:]
}
// Get JWKS (cached if possible)
keySet, err := GetJWKS(config.JwksURL, logger)
if err != nil {
logger.Error("Failed to get JWKS", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
}
// Verify token
claims, err := verifyToken(tokenStr, keySet, *config)
if err != nil {
logger.Warn("Token verification failed", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
}
// Debug logging if enabled
if os.Getenv("DEBUG") == "true" {
logger.Info("Token verified successfully", "claims", claims)
}
// Extract user groups
userGroups, err := GetUserGroups(claims)
if err != nil {
logger.Warn("Failed to extract user groups", "error", err)
userGroups = []string{} // Empty array if no groups found
}
// Store in context
c.Set("user_claims", claims)
c.Set("user_groups", userGroups)
// Check authorization
authorized, requiredGroups := checkPermissions(requestPath, userGroups, routePermissions, logger)
if !authorized {
logger.Warn("Access denied",
"path", requestPath,
"user", claims["cognito:username"],
"groups", userGroups,
"required", requiredGroups)
return c.JSON(http.StatusForbidden, map[string]interface{}{
"error": "Insufficient permissions",
"message": "User doesn't have the required group membership",
"username": claims["cognito:username"],
"groups": userGroups,
"required_groups": requiredGroups,
})
}
// User is authenticated and authorized
return next(c)
}
}
}
// checkPermissions checks if the user has the required permissions
// Used by both the main token validation middleware and the OAuth callback handler
// Returns true if authorized, false if not, along with the required groups
func checkPermissions(path string, userGroups []string, routePermissions map[string][]string, logger *slog.Logger) (bool, []string) {
// Find matching route pattern
var requiredGroups []string
var matched bool
for pattern, groups := range routePermissions {
if matchRoute(path, pattern) {
requiredGroups = groups
matched = true
logger.Debug("Found matching route pattern", "pattern", pattern, "requiredGroups", requiredGroups)
break
}
}
if !matched {
// No matching pattern found - could either allow or deny by default
logger.Info("No matching route pattern found for path", "path", path)
return true, nil // Allowing by default
}
// Check if user has any of the required groups
for _, requiredGroup := range requiredGroups {
for _, userGroup := range userGroups {
if userGroup == requiredGroup {
logger.Debug("User has required group", "group", requiredGroup)
return true, requiredGroups
}
}
}
return false, requiredGroups
}
// handleOAuthCallback handles the OAuth 2.0 authorization code flow callback
// Called when Cognito redirects back to our application with an authorization code
// Exchanges the code for tokens, verifies them, and checks authorization
func handleOAuthCallback(c echo.Context, config *CognitoConfig, routePermissions map[string][]string, logger *slog.Logger) error {
// Extract the authorization code
code := c.QueryParam("code")
if code == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing code 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,
})
}
// Exchange the code for tokens
tokens, err := exchangeCodeForTokens(code, *config, logger)
if err != nil {
logger.Error("Failed to exchange code for tokens", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": err.Error()})
}
// Get the JWKS for token verification
jwksJSON, err := fetchJWKS(config.JwksURL)
if err != nil {
logger.Error("Failed to fetch JWKS", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
}
// Parse the JWKS
keySet, err := jwk.ParseString(jwksJSON)
if err != nil {
logger.Error("Failed to parse JWKS", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
}
// Verify ID token
idTokenClaims, err := verifyToken(tokens.IDToken, keySet, *config)
if err != nil {
logger.Error("Failed to verify ID token", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
}
// Extract user groups
userGroups, err := GetUserGroups(idTokenClaims)
if err != nil {
logger.Warn("Failed to extract user groups", "error", err)
userGroups = []string{} // Initialize as empty array
}
// Debug logging for tokens
if os.Getenv("DEBUG") == "true" {
// Log decoded token for debugging
logger.Info("ID Token Claims", "claims", idTokenClaims)
logger.Info("Raw ID Token", "token", tokens.IDToken)
logger.Info("Raw Access Token", "token", tokens.AccessToken)
}
// CHECK AUTHORIZATION - this was missing and caused the bug
authorized, requiredGroups := checkPermissions("/query", userGroups, routePermissions, logger)
if !authorized {
logger.Warn("OAuth callback - access denied",
"user", idTokenClaims["cognito:username"],
"groups", userGroups,
"required", requiredGroups)
return c.JSON(http.StatusForbidden, map[string]interface{}{
"error": "Insufficient permissions",
"message": "User authenticated successfully but doesn't have the required group membership",
"authenticated": true,
"username": idTokenClaims["cognito:username"],
"email": idTokenClaims["email"],
"groups": userGroups,
"required_groups": requiredGroups,
})
}
// User is authenticated and authorized, return tokens and info
return c.JSON(http.StatusOK, map[string]interface{}{
"authenticated": true,
"username": idTokenClaims["cognito:username"],
"email": idTokenClaims["email"],
"groups": userGroups,
"id_token": tokens.IDToken,
"access_token": tokens.AccessToken,
"token_type": "Bearer",
"expires_in": tokens.ExpiresIn,
})
}
// exchangeCodeForTokens exchanges the authorization code for tokens
// 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 exchangeCodeForTokens(authCode string, config CognitoConfig, logger *slog.Logger) (*CognitoTokenResponse, error) {
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("client_id", config.ClientID)
data.Set("code", authCode)
data.Set("redirect_uri", config.RedirectURI)
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)
logger.Debug("Using Basic Auth authentication")
} else {
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 CognitoTokenResponse
if err := json.Unmarshal(body, &tokenResponse); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
return &tokenResponse, nil
}
// verifyToken verifies the JWT token and returns its claims
// Used during both OAuth callback and subsequent API requests with bearer token
// Verifies signature, expiration, issuer, and other JWT claims
func verifyToken(tokenString string, keySet jwk.Set, config CognitoConfig) (map[string]interface{}, error) {
region := "us-east-2"
if strings.Contains(config.JwksURL, "amazonaws.com/") {
parts := strings.Split(config.JwksURL, "amazonaws.com/")
if len(parts) > 1 {
regionParts := strings.Split(parts[0], ".")
if len(regionParts) > 0 {
lastPart := regionParts[len(regionParts)-1]
if lastPart != "" {
region = lastPart
}
}
}
}
issuer := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", region, config.UserPoolID)
// Verify the token with the keySet
verifiedToken, err := jwt.Parse(
[]byte(tokenString),
jwt.WithKeySet(keySet),
jwt.WithValidate(true),
jwt.WithIssuer(issuer),
// Add other validation options as needed
)
if err != nil {
return nil, err
}
// Extract claims to a map
claims, err := verifiedToken.AsMap(context.Background())
if err != nil {
return nil, err
}
return claims, nil
}
// GetUserGroups extracts user groups from the token claims
// Attempts to find groups in different claim names depending on Cognito setup
// Returns an array of group names or an error if no groups found
func GetUserGroups(claims map[string]interface{}) ([]string, error) {
// The claim containing the groups might have different names depending on your Cognito setup
// Common names are "cognito:groups", "groups", or a custom attribute
possibleGroupFields := []string{"cognito:groups", "groups", "custom:groups"}
for _, field := range possibleGroupFields {
if rawGroups, ok := claims[field]; ok {
switch groups := rawGroups.(type) {
case []interface{}:
result := make([]string, len(groups))
for i, g := range groups {
result[i] = fmt.Sprintf("%v", g)
}
return result, nil
case []string:
return groups, nil
case string:
return []string{groups}, nil
}
}
}
return nil, errors.New("no groups found in token claims")
}
// fetchJWKS retrieves the JSON Web Key Set from Cognito
// Used to get the public keys needed to verify token signatures
// Makes an HTTP request to the JWKS URL and returns the raw JSON
func fetchJWKS(jwksURL string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", jwksURL, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
jwksData, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(jwksData), nil
}
// matchRoute checks if a request path matches a route pattern
// Converts Echo route patterns to regex for matching
// Supports parameter patterns like '/users/:id' and wildcard patterns
func matchRoute(requestPath, routePattern string) bool {
// Convert Echo route pattern to regex pattern
// e.g., "/users/:id" to "/users/([^/]+)"
regexPattern := "^"
patternParts := strings.Split(routePattern, "/")
for i, part := range patternParts {
if i > 0 {
regexPattern += "/"
}
if strings.HasPrefix(part, ":") {
// Parameter part (e.g., :id)
regexPattern += "([^/]+)"
} else if part == "*" {
// Wildcard - match anything
regexPattern += ".*"
} else {
// Literal part - escape any regex metacharacters
regexPattern += regexp.QuoteMeta(part)
}
}
regexPattern += "$"
// Compile and match
regex, err := regexp.Compile(regexPattern)
if err != nil {
return false
}
return regex.MatchString(requestPath)
}
// main is the entry point of the application
// Sets up the Echo server, middleware, routes, and starts listening
func main() {
// Create a new Echo instance
e := echo.New()
// Basic middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Initialize logger with appropriate level based on DEBUG env var
var logLevel slog.Level
if os.Getenv("DEBUG") == "true" {
logLevel = slog.LevelDebug
} else {
logLevel = slog.LevelInfo
}
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
logger := slog.New(logHandler)
// Get Cognito configuration
region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-2" // Fallback to hardcoded value
}
userPoolID := os.Getenv("COGNITO_USER_POOL_ID")
if userPoolID == "" {
userPoolID = "us-east-2_1y6po8rR8" // Fallback to hardcoded value
}
domain := os.Getenv("COGNITO_DOMAIN")
if domain == "" {
logger.Warn("COGNITO_DOMAIN environment variable is not set, using default")
domain = fmt.Sprintf("%s.auth.%s.amazoncognito.com", userPoolID, region)
}
// Ensure domain format is correct (no protocol prefix)
domain = strings.TrimPrefix(domain, "https://")
domain = strings.TrimPrefix(domain, "http://")
config := &CognitoConfig{
ClientID: os.Getenv("COGNITO_CLIENT_ID"),
ClientSecret: os.Getenv("COGNITO_CLIENT_SECRET"),
RedirectURI: "http://localhost:8080/query",
TokenURL: fmt.Sprintf("https://%s/oauth2/token", domain),
JwksURL: fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", region, userPoolID),
UserPoolID: userPoolID,
}
// Route permissions - matching the provided example
routePermissions := map[string][]string{
"/users": {"exporters"},
"/users/:id": {"exporters", "querybuilders"},
"/orders": {"exporters", "exporters"},
"/reports/sales": {"exporters", "uploaders", "querybuilders"},
"/settings": {"exporters"},
//"/query": {"exporters", "querybuilders"}, // Only exporters can access /query
"/query": {"exporters"}, // Only exporters can access /query
"/api/inventory/update": {"exporters", "uploaders"},
}
// Apply token validation middleware
e.Use(TokenValidationMiddleware(config, routePermissions, logger))
// Endpoint handlers - much simpler now that auth is fully handled in middleware
e.GET("/query", func(c echo.Context) error {
// This handler will only be called for requests that don't have a code parameter
// (those are handled directly in the middleware)
// Get user info from context
userClaims, _ := c.Get("user_claims").(map[string]interface{})
userGroups, _ := c.Get("user_groups").([]string)
return c.JSON(http.StatusOK, map[string]interface{}{
"authenticated": true,
"username": userClaims["cognito:username"],
"email": userClaims["email"],
"groups": userGroups,
})
})
// Other endpoint handlers
e.GET("/users", func(c echo.Context) error {
return c.String(http.StatusOK, "Users endpoint")
})
e.GET("/users/:id", func(c echo.Context) error {
id := c.Param("id")
return c.String(http.StatusOK, fmt.Sprintf("User details endpoint for ID: %s", id))
})
e.GET("/orders", func(c echo.Context) error {
return c.String(http.StatusOK, "Orders endpoint")
})
e.GET("/reports/sales", func(c echo.Context) error {
return c.String(http.StatusOK, "Sales reports endpoint")
})
e.GET("/settings", func(c echo.Context) error {
return c.String(http.StatusOK, "Settings endpoint")
})
e.PUT("/api/inventory/update", func(c echo.Context) error {
return c.String(http.StatusOK, "Inventory update endpoint")
})
// Print startup information
logger.Info("Server configuration",
"client_id", maskString(config.ClientID),
"domain", domain,
"user_pool_id", userPoolID,
"region", region,
"debug_mode", os.Getenv("DEBUG") == "true")
// Configure server
server := &http.Server{
Addr: ":8080",
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Start server with custom server config
logger.Info("Server starting on :8080")
e.Logger.Fatal(e.StartServer(server))
}
// Helper function to mask sensitive values
// Used for logging sensitive information like client IDs
func maskString(s string) string {
if s == "" {
return "<not set>"
}
if len(s) <= 8 {
return "****"
}
return s[:4] + "..." + s[len(s)-4:]
}
-172
View File
@@ -1,172 +0,0 @@
# Cognito Auth Test Harness
This directory contains a test harness for validating AWS Cognito authentication and authorization with Go and the Echo web framework. It demonstrates a complete OAuth 2.0 flow with role-based access control (RBAC).
It is temporary and will go away once the final middleware is in place.
## Features
- Complete OAuth 2.0 authentication flow with AWS Cognito
- Role-based access control (RBAC) for API endpoints
- JWT token validation with signature verification
- Efficient JWKS caching to minimize external requests
- Debug mode for token inspection
## Setup
### Prerequisites
- Go 1.18 or higher
- AWS Cognito User Pool with configured app client
- Test user(s) in the Cognito User Pool with assigned groups
### Environment Variables
Set the following environment variables before running the application:
```bash
export COGNITO_CLIENT_ID="552cqkf3640t39ncehkmgpce31"
export COGNITO_CLIENT_SECRET="your-client-secret"
export COGNITO_DOMAIN="us-east-21y6po8rr8.auth.us-east-2.amazoncognito.com"
export COGNITO_USER_POOL_ID="us-east-2_1y6po8rR8"
export AWS_REGION="us-east-2"
# Optional: Enable debug mode to see token details
export DEBUG="true"
```
## Running the Application
1. Build and run the server:
```bash
go run main.go
```
2. The server will start on `http://localhost:8080`
## Testing the Authentication Flow
### Step 1: Initial Login
1. Open the following URL in your browser (update domain/client_id if different):
```
https://us-east-21y6po8rr8.auth.us-east-2.amazoncognito.com/login?client_id=552cqkf3640t39ncehkmgpce31&response_type=code&scope=email+openid+phone&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fquery
```
2. Log in with your Cognito user credentials
3. After successful authentication, Cognito will redirect to your local server (`/query` endpoint)
4. You'll see a JSON response with:
- Authentication status
- User information (username, email)
- User groups
- JWT tokens (id_token and access_token)
Example response:
```json
{
"authenticated": true,
"username": "testuser",
"email": "user@example.com",
"groups": ["uploaders", "querybuilders"],
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}
```
### Step 2: Testing Protected Endpoints
Use the obtained token to access protected endpoints:
1. Copy the `id_token` value from the response
2. Use it as a Bearer token in subsequent requests:
```bash
# Using curl to access a protected endpoint
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." http://localhost:8080/users
```
3. Test authorization with different endpoints:
| Endpoint | Required Groups |
|----------|----------------|
| /users | exporters |
| /users/:id | exporters, querybuilders |
| /orders | exporters |
| /reports/sales | exporters, uploaders, querybuilders |
| /settings | exporters |
| /query | exporters |
| /api/inventory/update | exporters, uploaders |
Note. If you want to change the RBAC requirements for the server they are completely table driven.
The code where they are defined (in main()) looks like this. Feel free to change them and rerun.
```go
routePermissions := map[string][]string{
"/users": {"exporters"},
"/users/:id": {"exporters", "querybuilders"},
"/orders": {"exporters", "exporters"},
"/reports/sales": {"exporters", "uploaders", "querybuilders"},
"/settings": {"exporters"},
"/query": {"exporters", "querybuilders"}, // Only exporters can access /query
"/api/inventory/update": {"exporters", "uploaders"},
}
```
4. If your user doesn't have the required group for an endpoint, you'll receive a 403 Forbidden response:
```json
{
"error": "Insufficient permissions",
"message": "User doesn't have the required group membership",
"username": "testuser",
"groups": ["uploaders", "querybuilders"],
"required_groups": ["exporters"]
}
```
## Authentication Flow Explained
### Initial Login (OAuth 2.0 Flow)
1. **User Authentication**: User authenticates with Cognito's hosted UI
2. **Authorization Code**: Cognito redirects to `/query?code=...` with an authorization code
3. **Token Exchange**: The server exchanges this code for OAuth tokens by calling Cognito's token endpoint
4. **Token Verification**: The server verifies the JWT token signature using Cognito's JWKS
5. **Group Verification**: The server checks if the user belongs to the required groups for the `/query` endpoint
6. Note that /query is just a placeholder and will be changes to something like /login or /login/callback in the api server. This setting is set in cognito on the user pool.
6. **Response**: If authorized, the server returns the tokens and user information; if not, it returns a 403 error
### Subsequent API Requests
1. **Token Submission**: Client includes the ID token as a Bearer token in the Authorization header
2. **Token Validation**: Server validates the token's signature, expiration, and other claims
3. **Group Extraction**: Server extracts the user's groups from the token
4. **Permission Check**: Server checks if the user has any of the required groups for the requested endpoint
5. **Access Control**: If authorized, the request proceeds to the handler; if not, a 403 error is returned
## Advanced Features
### JWKS Caching
The server caches the JSON Web Key Set (JWKS) used to verify token signatures, reducing the number of requests to AWS.
### Debug Mode
Enable debug mode by setting the `DEBUG` environment variable to `true`. This will:
- Log decoded token claims
- Log raw tokens
- Provide more verbose logging throughout the authentication process
## Integration into Your Own Application
This test harness is for proving the design of the echo middleware before attempting integration:
1. The `TokenValidationMiddleware` handles both authentication and authorization
2. The route permissions map can be configured to match our application's security requirements
3. The JWKS caching mechanism ensures efficient token validation
-109
View File
@@ -1,109 +0,0 @@
package main
import (
"fmt"
"log"
"queryorchestration/internal/rbac"
"queryorchestration/internal/serviceconfig"
"github.com/golang-jwt/jwt/v5"
)
func setupConfig() (*serviceconfig.BaseConfig, error) {
cfg := &serviceconfig.BaseConfig{}
if err := serviceconfig.InitializeConfig(cfg); err != nil {
return nil, fmt.Errorf("failed to initialize config: %w", err)
}
if err := cfg.InitializeAuthProvider(); err != nil {
return nil, fmt.Errorf("failed to initialize auth provider: %w", err)
}
cfg.PrintConfig("secret")
if cfg.AuthProvider == nil {
return nil, fmt.Errorf("auth provider is nil")
}
return cfg, nil
}
func generateAndValidateToken(localProvider *rbac.LocalKeyProvider) (*rbac.TokenDetails, error) {
groups := []string{"admins", "developers", "querybuilders", "uploaders", "exporters"}
tokenString, err := rbac.GenerateTestJWT(localProvider, "test-user-id", "test@example.com", groups)
if err != nil {
return nil, fmt.Errorf("error generating token: %w", err)
}
fmt.Printf("raw token: %s\n", tokenString)
token, claims, err := rbac.ValidateJWT(tokenString, localProvider)
if err != nil {
return nil, fmt.Errorf("token validation failed: %w", err)
}
if !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return rbac.ExtractTokenDetails(token, claims), nil
}
func checkUserGroups(claims jwt.MapClaims) {
groupsInterface, ok := claims["cognito:groups"]
if !ok {
fmt.Println("No groups found in token.")
return
}
groups, ok := groupsInterface.([]interface{})
if !ok {
fmt.Println("Invalid groups format in token.")
return
}
if len(groups) > 0 {
fmt.Println("User Groups:")
for _, group := range groups {
if groupStr, ok := group.(string); ok {
fmt.Println("-", groupStr)
}
}
} else {
fmt.Println("No groups found in token.")
}
if rbac.HasGroup(claims, "admins") {
fmt.Println("User is an admin.")
}
if rbac.HasAnyGroup(claims, []string{"developers", "querybuilders"}) {
fmt.Println("User can modify or query data.")
}
if rbac.HasAllGroups(claims, []string{"uploaders", "exporters"}) {
fmt.Println("User can both upload and export data.")
}
}
// for testing run like `LOG_LEVEL=INFO go run main.go`
func main() {
_, err := setupConfig()
if err != nil {
log.Fatal(err)
}
localProvider := rbac.NewLocalKeyProvider("private_key.pem", "public_key.pem")
details, err := generateAndValidateToken(localProvider)
if err != nil {
log.Fatal(err)
}
fmt.Println("Token is valid.")
fmt.Println("User ID:", details.UserID)
fmt.Println("Email:", details.Email)
fmt.Println("Expires:", details.Expiration)
checkUserGroups(details.Claims)
}
-28
View File
@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDX5vunPVZ3fVNY
dwJzOvTsgoHWEQlkXs/FSjHz90HzLCloZ8/g7yqIEPwmxmHHkHVjMJryyhzoOhnU
6OYuDDFiTlWL6UFX74+EC/bVDVFWOU016R6WZX+paeIn7JV7pvovoG+WCRgaypUy
rJ4FGAMNel8uuq1KCrhV5UsQ6e6IGJ3bry+mRp9+Pqk91viWj1JVc1t+t2IfyJMY
/Hka0FiobnIAVSQPrI6ULoZul80ndB26mK2WRsr6A+S/DVHZLJ2qvtxrtdqXsK+m
tr+MKrBWFhcnMrNkYCCTEFWC4TiyYIXWGWhHSNj87CIaboggQ2ybLvAiulFvrgf8
ES6LyRj3AgMBAAECggEAZ7MkMGG/xEjH3XfcD2jD901/+0fXkQQRG5vVfm7GmHwf
r2wdZta5QP2XfzBOCsKR/4B7DB6T3974RVFQLdHhbmxdnoP8xLXl4vC0MATjilyf
f0NnU6mQtdiLrc1uxyOei32t2wynLUccfmh2xc+Qt8qNKS60yRl5DJjDg245CdiW
ZhN40svLKfeVPallv2zrIZRbySEWXsCZRzBE+CYNrdRTWXSp45xCrRn+lseUxQXH
n+VoaP2KzHGgE4Lv5476EtVAq9G1yVN6Z0+8xWOiG9s+X3BNKPXWP4BGvg9tjBi0
77U/hYrytMEPu8RV0ZR0zvlFVItqjujdKOJ9zJWTIQKBgQD/shNiXwCG/Ywrvs/g
lWNkvM+8iYyALtCuOR7rsuR0QP0OnC9HsZbttOwMwiC8yJiMZ7DuBWp7iqfjmPs7
90dTxn4iQNkdoUE1DrobViPp+7pWIHqDH82r/Gy3nwMXLt/UFxi85lYd1Oo3us9C
ICvhFmzGAzIfiTKTXIJqJF9EqQKBgQDYKMe2ACP4rGVmJTSTkMn9c22gDTeCMEgI
dvlZOS7PJXAsznlnpTH+KLEyJhRenj08VxO6LcXI1urTe5G5aPStutBzods2X/iM
i1CmtOzeRGOkDVmqdYGgk0SXvTQg/z9MnwYCv+a904LQdMQ7TGgaoCYMJc3qWLO7
p+/qGSZUnwKBgBvZZ2cVddc+EmBJXhbV7odwUSf1y0nCz5PKQOXnDB7lXSqUNEoY
u5mUVQlms24cYxEX0ht6l4hxJ6wQY3y6iBhFzEMq0Pr7L0D6I6cKkMrRUhBDZVSW
yC3tRmIRfaKuxk4xXc5lQAfrwr7jJ+PJ4T2Y1awTeQgaR1npf4LUB1RRAoGBAL+t
GbrX0Q33wUqcf0zDPXoT2wfr8GbvbVCkP2PRAyMIrbnttVYk9HnNl6NChRmJ8/8H
sCSN5i679StnDcd9vEo5uBJxWjOTUpE+EFxjXw+RUVHtzK8M18+OB2sOiaUg8f59
nRTfGjsFzaAPitqSXFYP4O0wsLG3ylkDCAlsF8M9AoGALYTvOBdGJn/31XazCSeS
v2/iYnn41aw2DzxEYIn+QfkKH7Ctc39dfYV1jrhoI7m2T90GJKYfiZidd27fzmib
aPGQhCRFS7NPj2wI4kAFftRRtowIzHwEvVi1VvKWbyZII04FLU6aNVWrZE/E+2f5
/4AmcI3dqS5PhGQEystcvhY=
-----END PRIVATE KEY-----
-9
View File
@@ -1,9 +0,0 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1+b7pz1Wd31TWHcCczr0
7IKB1hEJZF7PxUox8/dB8ywpaGfP4O8qiBD8JsZhx5B1YzCa8soc6DoZ1OjmLgwx
Yk5Vi+lBV++PhAv21Q1RVjlNNekelmV/qWniJ+yVe6b6L6BvlgkYGsqVMqyeBRgD
DXpfLrqtSgq4VeVLEOnuiBid268vpkaffj6pPdb4lo9SVXNbfrdiH8iTGPx5GtBY
qG5yAFUkD6yOlC6GbpfNJ3QdupitlkbK+gPkvw1R2Sydqr7ca7Xal7Cvpra/jCqw
VhYXJzKzZGAgkxBVguE4smCF1hloR0jY/OwiGm6IIENsmy7wIrpRb64H/BEui8kY
9wIDAQAB
-----END PUBLIC KEY-----
+1 -4
View File
@@ -11,7 +11,7 @@
"BUCKET_IN": "documentin", "BUCKET_IN": "documentin",
"CLIENT_SYNC_URL": "http://localstack:4566/queue/us-east-1/000000000000/client_sync", "CLIENT_SYNC_URL": "http://localstack:4566/queue/us-east-1/000000000000/client_sync",
"COGNITO_CLIENT_ID": "552cqkf3640t39ncehkmgpce31", "COGNITO_CLIENT_ID": "552cqkf3640t39ncehkmgpce31",
"COGNITO_CLIENT_SECRET": "aak...", "COGNITO_CLIENT_SECRET": "aaknqeq9ajr07qjch4tkq38ulghvn2i8v7tn3d2fcv44uevfemf",
"COGNITO_DOMAIN": "https://us-east-21y6po8rr8.auth.us-east-2.amazoncognito.com", "COGNITO_DOMAIN": "https://us-east-21y6po8rr8.auth.us-east-2.amazoncognito.com",
"COGNITO_USER_POOL_ID": "us-east-2_1y6po8rR8", "COGNITO_USER_POOL_ID": "us-east-2_1y6po8rR8",
"DB_NOSSL": "true", "DB_NOSSL": "true",
@@ -39,9 +39,6 @@
"QUERY_SYNC_URL": "http://localstack:4566/queue/us-east-1/000000000000/query_sync", "QUERY_SYNC_URL": "http://localstack:4566/queue/us-east-1/000000000000/query_sync",
"QUERY_URL": "http://localstack:4566/queue/us-east-1/000000000000/query_runner", "QUERY_URL": "http://localstack:4566/queue/us-east-1/000000000000/query_runner",
"QUERY_VERSION_SYNC_URL": "http://localstack:4566/queue/us-east-1/000000000000/query_version_sync", "QUERY_VERSION_SYNC_URL": "http://localstack:4566/queue/us-east-1/000000000000/query_version_sync",
"RBAC_PRIVATE_KEY_PATH": "./private_key.pem",
"RBAC_PROVIDER": "local",
"RBAC_PUBLIC_KEY_PATH": "./public_key.pem",
"STORE_EVENT_URL": "http://localstack:4566/queue/us-east-1/000000000000/store_event" "STORE_EVENT_URL": "http://localstack:4566/queue/us-east-1/000000000000/store_event"
}, },
"env_from": ".env", "env_from": ".env",