working pkce poc
This commit is contained in:
@@ -0,0 +1,784 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"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
|
||||
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
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
|
||||
// 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") != "" || 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
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("/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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
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")
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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"),
|
||||
RedirectURI: "http://localhost:8080/query",
|
||||
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: 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
|
||||
"/api/inventory/update": {"exporters", "uploaders"},
|
||||
"/login": {}, // No permissions required for login
|
||||
}
|
||||
|
||||
// Apply token validation middleware to all routes except the login route
|
||||
e.Use(TokenValidationMiddleware(config, routePermissions, logger))
|
||||
|
||||
// Login route
|
||||
e.GET("/login", func(c echo.Context) error {
|
||||
// The TokenValidationMiddleware will handle this
|
||||
return nil
|
||||
})
|
||||
|
||||
// Callback endpoint - handled by 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",
|
||||
"auth_method", "PKCE")
|
||||
|
||||
// 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:]
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
# 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
|
||||
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" \
|
||||
--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/query \
|
||||
--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
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant AppServer as App Server
|
||||
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 /query?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
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# Cognito Auth Test Harness
|
||||
|
||||
This repository 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).
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user