auth package WIP

This commit is contained in:
jay brown
2025-04-08 16:27:06 -07:00
parent 8eb2d36499
commit dd9c53df04
13 changed files with 1433 additions and 0 deletions
@@ -0,0 +1,100 @@
package main
import (
"log/slog"
"net/http"
"os"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"queryorchestration/internal/cognitoauth" // Replace with your actual package import path
)
func main() {
// Create a new Echo instance
e := echo.New()
// Add basic middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Initialize logger with appropriate level based on DEBUG env var
var logLevel slog.Level
if os.Getenv("DEBUG") == "true" {
logLevel = slog.LevelDebug
} else {
logLevel = slog.LevelInfo
}
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
logger := slog.New(logHandler)
// Create auth config from environment variables
config := cognitoauth.NewConfigFromEnv("http://localhost:8080", logger)
// Set route permissions
routePermissions := map[string][]string{
"/users": {"exporters", "uploaders", "querybuilders"},
"/users/:id": {"exporters", "querybuilders"},
"/orders": {"exporters"},
"/reports/sales": {"exporters", "uploaders", "querybuilders"},
"/settings": {"exporters"},
"/api/inventory/update": {"exporters", "uploaders"},
}
config.SetRoutePermissions(routePermissions)
// Register authentication routes
cognitoauth.RegisterRoutes(e, config)
// Register your application routes
// These will be protected based on the permissions set above
e.GET("/users", func(c echo.Context) error {
return c.String(http.StatusOK, "Users endpoint (OK)")
})
e.GET("/users/:id", func(c echo.Context) error {
id := c.Param("id")
return c.String(http.StatusOK, "User details endpoint for ID: "+id)
})
e.GET("/orders", func(c echo.Context) error {
return c.String(http.StatusOK, "Orders endpoint (OK)")
})
e.GET("/reports/sales", func(c echo.Context) error {
return c.String(http.StatusOK, "Sales reports endpoint (OK)")
})
e.GET("/settings", func(c echo.Context) error {
return c.String(http.StatusOK, "Settings endpoint (OK)")
})
e.PUT("/api/inventory/update", func(c echo.Context) error {
return c.String(http.StatusOK, "Inventory update endpoint (OK)")
})
// Alternative approach: Use the RequireGroups middleware for specific routes
// This can be used instead of or in addition to the global permissions map
adminGroup := e.Group("/admin")
adminGroup.Use(cognitoauth.RequireGroups("admin")) // Only admin group can access this route
adminGroup.GET("/dashboard", func(c echo.Context) error {
return c.String(http.StatusOK, "Admin Dashboard (OK)")
})
// Configure server
server := &http.Server{
Addr: ":8080",
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Start server
logger.Info("Server starting on :8080")
e.Logger.Fatal(e.StartServer(server))
}
@@ -0,0 +1,3 @@
# Cognito Auth Harness
This is the the test harness for the pkce cognito package (internal/cognitoauth)
that will be integrated into the main API application after its all tested.
+306
View File
@@ -0,0 +1,306 @@
package cognitoauth
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// RegisterRoutes registers all authentication-related routes to the Echo engine
func RegisterRoutes(e *echo.Echo, config *Config) {
// Register the login route - the middleware will handle this
e.GET(config.LoginPath, func(c echo.Context) error {
// This is handled by the middleware
return nil
})
// Register callback route - the middleware will handle this
e.GET(config.CallbackPath, func(c echo.Context) error {
// This handler will only be called for requests that don't have a code parameter
// (those are handled directly in the middleware)
// Get user info from context
userClaims, _ := c.Get("user_claims").(map[string]interface{})
userGroups, _ := c.Get("user_groups").([]string)
return c.JSON(http.StatusOK, map[string]interface{}{
"authenticated": true,
"username": userClaims["cognito:username"],
"email": userClaims["email"],
"groups": userGroups,
})
})
// Register logout route
e.GET(config.LogoutPath, func(c echo.Context) error {
return LogoutHandler(c, config)
})
// Default home page handler if requested
e.GET(config.HomePath, func(c echo.Context) error {
return HomeHandler(c, config)
})
// Apply the JWT Auth middleware first
e.Use(JWTAuthMiddleware(config))
// Then apply the token validation middleware
e.Use(TokenValidationMiddleware(config))
}
// HomeHandler implements a simple home page that shows auth status and available endpoints
func HomeHandler(c echo.Context, config *Config) error {
// Create a list of all registered routes
var endpoints []string
// Add the auth routes
endpoints = append(endpoints, []string{
config.LoginPath,
config.CallbackPath,
config.HomePath,
config.LogoutPath,
}...)
// Add routes from the permission map
for route := range config.RoutePermissions {
// Skip routes that are already in the list
alreadyAdded := false
for _, endpoint := range endpoints {
if route == endpoint {
alreadyAdded = true
break
}
}
if !alreadyAdded {
endpoints = append(endpoints, route)
}
}
// Check if user is authenticated by looking for token in cookie
tokenCookie, err := c.Cookie("auth_token")
isAuthenticated := (err == nil && tokenCookie.Value != "")
// Variables for user info
username := ""
email := ""
var userGroups []string
// If authenticated, try to decode the JWT token to get user info
if isAuthenticated {
// Parse the JWT token without verification (just to extract info for display)
token, _ := jwt.Parse([]byte(tokenCookie.Value), jwt.WithVerify(false))
if token != nil {
claims, _ := token.AsMap(context.Background())
username, _ = claims["cognito:username"].(string)
email, _ = claims["email"].(string)
// Try to extract groups
userGroups, _ = GetUserGroups(claims)
}
}
// Create HTML for the endpoints list
var linksHTML string
baseURL := c.Scheme() + "://" + c.Request().Host
// Create list items for each endpoint
for _, endpoint := range endpoints {
// Skip endpoints with path parameters for direct linking
if strings.Contains(endpoint, ":") {
displayPath := strings.Replace(endpoint, ":id", "{id}", -1)
linksHTML += fmt.Sprintf("<li>%s (requires parameter)</li>\n", displayPath)
} else {
linksHTML += fmt.Sprintf("<li><a href=\"%s%s\">%s</a></li>\n", baseURL, endpoint, endpoint)
}
}
// 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="%s/logout" class="logout-btn">Logout</a></p>
</div>
`, username, email, strings.Join(userGroups, ", "), baseURL)
} else {
authStatusHTML = fmt.Sprintf(`
<div class="auth-status unauthenticated">
<h2>Authentication Status: Not Authenticated</h2>
<p>You are not currently logged in.</p>
<p><a href="%s/login" class="login-btn">Login</a></p>
</div>
`, baseURL)
}
// Create the complete HTML page
html := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication 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>Authentication 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)
}
// GetTokenFromRequest extracts the token from the request
func GetTokenFromRequest(c echo.Context) string {
// First try from Authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
return authHeader[7:]
}
// Then try from cookie
tokenCookie, err := c.Cookie("auth_token")
if err == nil && tokenCookie.Value != "" {
return tokenCookie.Value
}
return ""
}
// GetUserInfo gets user information from the context
func GetUserInfo(c echo.Context) (UserInfo, bool) {
// Try to get user info from context
userInfo, ok := c.Get("user_info").(UserInfo)
if ok {
return userInfo, true
}
// Try to get token and extract user info
token := GetTokenFromRequest(c)
if token == "" {
return UserInfo{}, false
}
// Parse token to extract claims
parsedToken, err := jwt.Parse([]byte(token), jwt.WithVerify(false))
if err != nil {
return UserInfo{}, false
}
claims, err := parsedToken.AsMap(context.Background())
if err != nil {
return UserInfo{}, false
}
// Extract user info
userInfo = ExtractUserInfo(claims)
return userInfo, true
}
// RequireGroups creates a middleware that checks if the user is in any of the specified groups
func RequireGroups(groups ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
userInfo, ok := GetUserInfo(c)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "Authentication required",
})
}
// Check if user has any of the required groups
for _, requiredGroup := range groups {
for _, userGroup := range userInfo.Groups {
if userGroup == requiredGroup {
return next(c)
}
}
}
return c.JSON(http.StatusForbidden, map[string]interface{}{
"error": "Insufficient permissions",
"message": "User doesn't have the required group membership",
"username": userInfo.Username,
"groups": userInfo.Groups,
"required_groups": groups,
})
}
}
}
+143
View File
@@ -0,0 +1,143 @@
package cognitoauth
import (
"fmt"
"log"
"log/slog"
"os"
"strings"
)
// Config holds the configuration for AWS Cognito
// Contains all necessary parameters to interact with Cognito endpoints
type Config struct {
ClientID string // OAuth2 client ID registered with Cognito
RedirectURI string // URL where Cognito redirects after authentication
TokenURL string // Cognito endpoint for token operations
JwksURL string // URL for JSON Web Key Set (for token verification)
UserPoolID string // Cognito User Pool ID
AuthURL string // Cognito authorization endpoint for initiating login
ClientSecret string // Client secret for authenticated clients
Region string // AWS region where the Cognito User Pool is located
LoginPath string // Path for initiating login
CallbackPath string // Path for OAuth callback
HomePath string // Path for home page
LogoutPath string // Path for logout
Logger *slog.Logger // Logger instance
RoutePermissions map[string][]string // Map of routes to required permissions
}
// NewConfigFromEnv creates a new Config from environment variables
func NewConfigFromEnv(baseURL string, logger *slog.Logger) *Config {
// Set default logger if not provided
if logger == nil {
var logLevel slog.Level
if os.Getenv("DEBUG") == "true" {
logLevel = slog.LevelDebug
} else {
logLevel = slog.LevelInfo
}
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
logger = slog.New(logHandler)
}
// Set paths with defaults
loginPath := os.Getenv("COGNITO_LOGIN_PATH")
if loginPath == "" {
loginPath = "/login"
}
callbackPath := os.Getenv("COGNITO_CALLBACK_PATH")
if callbackPath == "" {
callbackPath = "/login-callback"
}
homePath := os.Getenv("COGNITO_HOME_PATH")
if homePath == "" {
homePath = "/home"
}
logoutPath := os.Getenv("COGNITO_LOGOUT_PATH")
if logoutPath == "" {
logoutPath = "/logout"
}
// Get AWS region with fallback
region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-2" // Fallback to default value
}
// Get User Pool ID with fallback
userPoolID := os.Getenv("COGNITO_USER_POOL_ID")
if userPoolID == "" {
userPoolID = "" // No default, will need to be provided
logger.Warn("COGNITO_USER_POOL_ID environment variable is not set")
}
// Get Cognito domain
domain := os.Getenv("COGNITO_DOMAIN")
if domain == "" {
//logger.Warn("COGNITO_DOMAIN environment variable is not set, using default")
//// Use the user pool ID directly as part of the domain
//domain = fmt.Sprintf("%s.auth.%s.amazoncognito.com", userPoolID, region)
log.Fatalf("COGNITO_DOMAIN environment variable is not set")
}
// Ensure domain format is correct (no protocol prefix)
domain = strings.TrimPrefix(domain, "https://")
domain = strings.TrimPrefix(domain, "http://")
// Sanitize base URL (remove trailing slash)
baseURL = strings.TrimSuffix(baseURL, "/")
config := &Config{
ClientID: os.Getenv("COGNITO_CLIENT_ID"),
ClientSecret: os.Getenv("COGNITO_CLIENT_SECRET"),
RedirectURI: baseURL + callbackPath,
TokenURL: fmt.Sprintf("https://%s/oauth2/token", domain),
AuthURL: fmt.Sprintf("https://%s/oauth2/authorize", domain),
JwksURL: fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", region, userPoolID),
UserPoolID: userPoolID,
Region: region,
LoginPath: loginPath,
CallbackPath: callbackPath,
HomePath: homePath,
LogoutPath: logoutPath,
Logger: logger,
RoutePermissions: make(map[string][]string),
}
config.PrettyPrint()
return config
}
func (c *Config) PrettyPrint() {
fmt.Printf("Config:\n")
fmt.Printf(" ClientID: %s\n", c.ClientID)
fmt.Printf(" ClientSecret: %s\n", c.ClientSecret)
fmt.Printf(" RedirectURI: %s\n", c.RedirectURI)
fmt.Printf(" TokenURL: %s\n", c.TokenURL)
fmt.Printf(" AuthURL: %s\n", c.AuthURL)
fmt.Printf(" JwksURL: %s\n", c.JwksURL)
fmt.Printf(" UserPoolID: %s\n", c.UserPoolID)
fmt.Printf(" Region: %s\n", c.Region)
fmt.Printf(" LoginPath: %s\n", c.LoginPath)
fmt.Printf(" CallbackPath: %s\n", c.CallbackPath)
fmt.Printf(" HomePath: %s\n", c.HomePath)
fmt.Printf(" LogoutPath: %s\n", c.LogoutPath)
// pretty print the route permissions map
for route, permissions := range c.RoutePermissions {
fmt.Printf(" Route: %s, Permissions: %v\n", route, permissions)
}
}
// SetRoutePermissions sets the route permissions map
func (c *Config) SetRoutePermissions(permissions map[string][]string) {
c.RoutePermissions = permissions
}
+243
View File
@@ -0,0 +1,243 @@
package cognitoauth
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/labstack/echo/v4"
)
// initiateLoginWithPKCE generates PKCE code challenge and redirects to Cognito
// This function starts the OAuth authorization flow with PKCE
func initiateLoginWithPKCE(c echo.Context, config *Config) error {
// Generate random state parameter to prevent CSRF
state, err := generateRandomString(32)
if err != nil {
config.Logger.Error("Failed to generate state parameter", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authentication initialization failed"})
}
// Generate code verifier (random string between 43-128 chars)
codeVerifier, err := generateRandomString(64)
if err != nil {
config.Logger.Error("Failed to generate code verifier", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authentication initialization failed"})
}
// Create code challenge from verifier (SHA256 + Base64URL without padding)
codeChallenge := createCodeChallenge(codeVerifier)
// Store in session for later verification (with expiration time)
storePKCESession(state, codeVerifier, config.Logger)
// Build authorization URL with PKCE parameters
params := url.Values{}
params.Set("client_id", config.ClientID)
params.Set("response_type", "code")
params.Set("redirect_uri", config.RedirectURI)
params.Set("scope", "openid email profile")
params.Set("state", state)
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
authURL := fmt.Sprintf("%s?%s", config.AuthURL, params.Encode())
if os.Getenv("DEBUG") == "true" {
config.Logger.Debug("Initiating login with PKCE",
"code_verifier", codeVerifier,
"code_challenge", codeChallenge,
"state", state,
"redirect_uri", config.RedirectURI)
}
// Redirect user to Cognito login page
return c.Redirect(http.StatusFound, authURL)
}
// exchangeCodeForTokensWithPKCE exchanges the authorization code for tokens using PKCE
// Called during the OAuth callback flow to get tokens from the authorization code
// Makes an HTTP request to Cognito's token endpoint to perform this exchange
func exchangeCodeForTokensWithPKCE(authCode, codeVerifier string, config Config) (*TokenResponse, error) {
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("client_id", config.ClientID)
data.Set("code", authCode)
data.Set("redirect_uri", config.RedirectURI)
data.Set("code_verifier", codeVerifier) // Include code verifier for PKCE
config.Logger.Debug("Token request details",
"url", config.TokenURL,
"client_id", config.ClientID,
"redirect_uri", config.RedirectURI)
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
// Add Authorization header if client secret is provided
if config.ClientSecret != "" {
req.SetBasicAuth(config.ClientID, config.ClientSecret)
config.Logger.Debug("Using Basic Auth authentication")
} else {
config.Logger.Debug("Using public client authentication (no secret)")
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(body))
}
var tokenResponse TokenResponse
if err := json.Unmarshal(body, &tokenResponse); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
return &tokenResponse, nil
}
// handleOAuthCallback handles the OAuth 2.0 authorization code flow callback with PKCE
// Called when Cognito redirects back to our application with an authorization code
// Exchanges the code for tokens, verifies them, and redirects to home page with token in cookie
func handleOAuthCallback(c echo.Context, config *Config) error {
// Extract the authorization code and state
code := c.QueryParam("code")
if code == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing code parameter"})
}
state := c.QueryParam("state")
if state == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing state parameter"})
}
// Check for OAuth errors
errorMsg := c.QueryParam("error")
if errorMsg != "" {
errorDesc := c.QueryParam("error_description")
return c.JSON(http.StatusBadRequest, map[string]string{
"error": errorMsg,
"error_description": errorDesc,
})
}
// Retrieve stored code verifier
codeVerifier, err := getCodeVerifier(state, config.Logger)
if err != nil {
config.Logger.Error("Failed to retrieve code verifier", "error", err, "state", state)
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid or expired session"})
}
// Exchange the code for tokens using PKCE
tokens, err := exchangeCodeForTokensWithPKCE(code, codeVerifier, *config)
if err != nil {
config.Logger.Error("Failed to exchange code for tokens", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": err.Error()})
}
// Get the JWKS for token verification
keySet, err := GetJWKS(config.JwksURL, config.Logger)
if err != nil {
config.Logger.Error("Failed to fetch JWKS", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
}
// Verify ID token
idTokenClaims, err := verifyToken(tokens.IDToken, keySet, *config)
if err != nil {
config.Logger.Error("Failed to verify ID token", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
}
// Extract user groups
userGroups, err := GetUserGroups(idTokenClaims)
if err != nil {
config.Logger.Warn("Failed to extract user groups", "error", err)
userGroups = []string{} // Initialize as empty array
}
// Debug logging for tokens
if os.Getenv("DEBUG") == "true" {
// Log decoded token for debugging
config.Logger.Info("ID Token Claims", "claims", idTokenClaims)
config.Logger.Info("Raw ID Token", "token", tokens.IDToken)
config.Logger.Info("Raw Access Token", "token", tokens.AccessToken)
}
// Check authorization
authorized, requiredGroups := checkPermissions(config.CallbackPath, userGroups, config.RoutePermissions, config.Logger)
if !authorized {
config.Logger.Warn("OAuth callback - access denied",
"user", idTokenClaims["cognito:username"],
"groups", userGroups,
"required", requiredGroups)
return c.JSON(http.StatusForbidden, map[string]interface{}{
"error": "Insufficient permissions",
"message": "User authenticated successfully but doesn't have the required group membership",
"authenticated": true,
"username": idTokenClaims["cognito:username"],
"email": idTokenClaims["email"],
"groups": userGroups,
"required_groups": requiredGroups,
})
}
// User is authenticated and authorized
// Calculate token expiration time based on token's expires_in value
expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
// Set a cookie with the access token
tokenCookie := new(http.Cookie)
tokenCookie.Name = "auth_token"
tokenCookie.Value = tokens.AccessToken
tokenCookie.Path = "/"
tokenCookie.Expires = expiresAt
tokenCookie.HttpOnly = true // Not accessible via JavaScript
// In production, set Secure to true
// tokenCookie.Secure = true
c.SetCookie(tokenCookie)
// Extract username for display purposes (optional)
username, _ := idTokenClaims["cognito:username"].(string)
config.Logger.Info("User authenticated successfully", "username", username, "groups", userGroups)
// Redirect to home page
return c.Redirect(http.StatusFound, config.HomePath)
}
// LogoutHandler handles the logout process by clearing the auth cookie
func LogoutHandler(c echo.Context, config *Config) error {
// Clear the token cookie
cookie := new(http.Cookie)
cookie.Name = "auth_token"
cookie.Value = ""
cookie.Path = "/"
cookie.Expires = time.Now().Add(-1 * time.Hour) // Set expiry in the past
cookie.HttpOnly = true
c.SetCookie(cookie)
// Redirect to home page
return c.Redirect(http.StatusFound, config.HomePath)
}
+75
View File
@@ -0,0 +1,75 @@
package cognitoauth
import (
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
)
// GetJWKS returns the cached JWKS or fetches a new one if needed
// This function implements caching logic to minimize external requests
// Used during token validation to get the key set for signature verification
func GetJWKS(jwksURL string, logger *slog.Logger) (jwk.Set, error) {
// Try to use cached JWKS first (read lock)
jwksCache.mutex.RLock()
if jwksCache.KeySet != nil && time.Now().Before(jwksCache.ExpiresAt) {
defer jwksCache.mutex.RUnlock()
logger.Debug("Using cached JWKS")
return jwksCache.KeySet, nil
}
jwksCache.mutex.RUnlock()
// Need to fetch new JWKS (write lock)
jwksCache.mutex.Lock()
defer jwksCache.mutex.Unlock()
// Double-check expiration after acquiring the write lock
if jwksCache.KeySet != nil && time.Now().Before(jwksCache.ExpiresAt) {
return jwksCache.KeySet, nil
}
logger.Info("Fetching new JWKS", "url", jwksURL)
jwksJSON, err := fetchJWKS(jwksURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
}
keySet, err := jwk.ParseString(jwksJSON)
if err != nil {
return nil, fmt.Errorf("failed to parse JWKS: %w", err)
}
jwksCache.KeySet = keySet
// Cache for 24 hours - you can adjust this based on your needs
jwksCache.ExpiresAt = time.Now().Add(24 * time.Hour)
return keySet, nil
}
// fetchJWKS retrieves the JSON Web Key Set from Cognito
// Used to get the public keys needed to verify token signatures
// Makes an HTTP request to the JWKS URL and returns the raw JSON
func fetchJWKS(jwksURL string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", jwksURL, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
jwksData, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(jwksData), nil
}
+142
View File
@@ -0,0 +1,142 @@
package cognitoauth
import (
"net/http"
"strings"
"github.com/labstack/echo/v4"
)
// TokenValidationMiddleware verifies JWT tokens efficiently
// This is the primary middleware that handles both authentication and authorization
// Applied to all routes to enforce security requirements
func TokenValidationMiddleware(config *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
requestPath := c.Request().URL.Path
config.Logger.Debug("Processing request", "path", requestPath, "method", c.Request().Method)
// Skip token validation for specified public paths
publicPaths := []string{config.HomePath, config.LogoutPath}
for _, path := range publicPaths {
if requestPath == path {
config.Logger.Debug("Skipping token validation for public path", "path", requestPath)
return next(c)
}
}
// Special case for the OAuth callback path
if requestPath == config.CallbackPath && (c.QueryParam("code") != "" || c.QueryParam("error") != "") {
config.Logger.Debug("Handling OAuth callback",
"has_code", c.QueryParam("code") != "",
"has_error", c.QueryParam("error") != "")
// Skip token validation for OAuth callback - it will be handled by the callback handler
return handleOAuthCallback(c, config)
}
// Initialize login flow if this is a login request
if requestPath == config.LoginPath {
return initiateLoginWithPKCE(c, config)
}
// Get authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
config.Logger.Warn("No authorization header provided")
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization required"})
}
// Extract token
tokenStr := authHeader
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr = authHeader[7:]
}
// Get JWKS (cached if possible)
keySet, err := GetJWKS(config.JwksURL, config.Logger)
if err != nil {
config.Logger.Error("Failed to get JWKS", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
}
// Verify token
claims, err := verifyToken(tokenStr, keySet, *config)
if err != nil {
config.Logger.Warn("Token verification failed", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
}
// Extract user groups
userGroups, err := GetUserGroups(claims)
if err != nil {
config.Logger.Warn("Failed to extract user groups", "error", err)
userGroups = []string{} // Empty array if no groups found
}
// Store in context
c.Set("user_claims", claims)
c.Set("user_groups", userGroups)
// Create and store user info in context
userInfo := ExtractUserInfo(claims)
c.Set("user_info", userInfo)
// Check authorization
authorized, requiredGroups := checkPermissions(requestPath, userGroups, config.RoutePermissions, config.Logger)
if !authorized {
config.Logger.Warn("Access denied",
"path", requestPath,
"user", claims["cognito:username"],
"groups", userGroups,
"required", requiredGroups)
return c.JSON(http.StatusForbidden, map[string]interface{}{
"error": "Insufficient permissions",
"message": "User doesn't have the required group membership",
"username": claims["cognito:username"],
"groups": userGroups,
"required_groups": requiredGroups,
})
}
// User is authenticated and authorized
return next(c)
}
}
}
// JWTAuthMiddleware checks for JWT token in cookies and adds it to the Authorization header
func JWTAuthMiddleware(config *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Skip for login and callback paths
if c.Path() == config.LoginPath || c.Path() == config.CallbackPath {
return next(c)
}
// Skip for home and logout as they should be accessible without auth
if c.Path() == config.HomePath || c.Path() == config.LogoutPath {
return next(c)
}
// Check if Authorization header is already set
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
// Authorization header is already set, proceed to the next handler
return next(c)
}
// Try to get token from cookie
tokenCookie, err := c.Cookie("auth_token")
if err != nil || tokenCookie.Value == "" {
// No token cookie found
return c.Redirect(http.StatusFound, config.LoginPath)
}
// Add token to Authorization header
c.Request().Header.Set("Authorization", "Bearer "+tokenCookie.Value)
return next(c)
}
}
}
+55
View File
@@ -0,0 +1,55 @@
package cognitoauth
import (
"sync"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
)
// TokenResponse represents the response from the token endpoint
// Returned by Cognito when exchanging an authorization code for tokens
type TokenResponse struct {
IDToken string `json:"id_token"` // OpenID Connect ID token containing user claims
AccessToken string `json:"access_token"` // OAuth2 access token for accessing resources
RefreshToken string `json:"refresh_token"` // Token used to get new access tokens without re-authentication
ExpiresIn int `json:"expires_in"` // Token validity period in seconds
TokenType string `json:"token_type"` // Type of token, typically "Bearer"
}
// PKCESession stores PKCE code verifier for verification
// We need to store the code verifier temporarily to use during token exchange
type PKCESession struct {
CodeVerifier string
CreatedAt time.Time
ExpiresAt time.Time
}
// JWKSCache caches the JWKS to avoid frequent fetches
// Implements a thread-safe caching mechanism with expiration
type JWKSCache struct {
KeySet jwk.Set // The cached JSON Web Key Set
ExpiresAt time.Time // Expiration time for the cache
mutex sync.RWMutex // Mutex for thread-safe access
}
// Global map to store PKCE sessions by state parameter
// In a production environment, this should be replaced with a proper session store
var pkceSessionMap = struct {
sync.RWMutex
sessions map[string]*PKCESession
}{
sessions: make(map[string]*PKCESession),
}
// Global JWKS cache instance
var jwksCache = &JWKSCache{
ExpiresAt: time.Now(), // Initial state is expired, forcing a fetch on first use
}
// UserInfo represents the user information extracted from JWT claims
type UserInfo struct {
Username string // Username from the token
Email string // Email from the token
Groups []string // Groups the user belongs to
}
+34
View File
@@ -0,0 +1,34 @@
# Cognito Auth
A reusable Go package for AWS Cognito authentication and authorization with Echo framework using PKCE flow.
## Features
- Complete OAuth 2.0 PKCE flow for AWS Cognito
- JWT token validation and verification
- Route-based authorization using Cognito user groups
- Middleware for token handling
- Cookie-based token storage
- Default home page with authentication status
- Simple integration with Echo framework
## Installation
```bash
go get github.com/yourusername/cognitoauth
```
## Configuration
The package reads configuration from environment variables:
- `COGNITO_CLIENT_ID`: Your AWS Cognito App Client ID
- `COGNITO_CLIENT_SECRET`: Your AWS Cognito App Client Secret (optional for public clients)
- `COGNITO_USER_POOL_ID`: Your AWS Cognito User Pool ID
- `COGNITO_DOMAIN`: Your AWS Cognito domain
- `AWS_REGION`: AWS region where your Cognito User Pool is located
- `DEBUG`: Set to "true" for debug logging
Optional path configuration:
- `COGNITO_LOGIN_PATH`: Path for login endpoint (default: "/login")
- `COGNITO_CALLBACK_PATH`: Path for OAuth callback (default: "/
+10
View File
@@ -0,0 +1,10 @@
# Cognitoauth structure
cognitoauth/
├── auth.go # Main package file with exported functions
├── handler.go # HTTP handlers for login, callback, etc.
├── middleware.go # Echo middleware implementation
├── jwks.go # JWKS handling and validation
├── token.go # Token verification and management
├── models.go # Data models and types
├── utils.go # Helper functions
└── config.go # Configuration structures
+70
View File
@@ -0,0 +1,70 @@
# Summary of the Cognito Auth Package
This package handles the complete PKCE flow with AWS Cognito and integrates cleanly with the Echo web framework.
## Key Components:
### Config Structure: Centralizes all configuration and can be initialized from environment variables.
Middleware:
### JWTAuthMiddleware: Extracts tokens from cookies and adds them to Authorization headers
TokenValidationMiddleware: Handles token validation and authorization
### Route Handlers:
Login initiation
OAuth callback processing
Logout functionality
Home page with authentication status
### Helper Functions:
Token verification
User group extraction
Permission checking
## How to Use It:
Initialize the Config:
```
goconfig := cognitoauth.NewConfigFromEnv("http://localhost:8080", logger)
Set Route Permissions:
goroutePermissions := map[string][]string{
"/users": {"exporters", "uploaders"},
"/orders": {"exporters"},
}
config.SetRoutePermissions(routePermissions)
```
### Register Routes and Middleware:
`gocognitoauth.RegisterRoutes(e, config)`
### Define Your Protected Routes:
```
e.GET("/users", handleUsers)
e.GET("/orders", handleOrders)
```
### Authentication Flow:
User navigates to /login
User is redirected to Cognito login page
After successful login, Cognito redirects to /login-callback
The callback handler exchanges the authorization code for tokens
Tokens are stored in cookies for subsequent requests
Protected routes check token validity and user permissions
### Customization:
The package is designed to be customizable:
Configure paths for login, callback, logout, and home
Define your own route permissions
Use RequireGroups middleware for fine-grained control
Access user information in your handlers via GetUserInfo
+155
View File
@@ -0,0 +1,155 @@
package cognitoauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// verifyToken verifies the JWT token and returns its claims
// Used during both OAuth callback and subsequent API requests with bearer token
// Verifies signature, expiration, issuer, and other JWT claims
func verifyToken(tokenString string, keySet jwk.Set, config Config) (map[string]interface{}, error) {
region := config.Region
issuer := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", region, config.UserPoolID)
// Verify the token with the keySet
verifiedToken, err := jwt.Parse(
[]byte(tokenString),
jwt.WithKeySet(keySet),
jwt.WithValidate(true),
jwt.WithIssuer(issuer),
// Add other validation options as needed
)
if err != nil {
return nil, err
}
// Extract claims to a map
claims, err := verifiedToken.AsMap(context.Background())
if err != nil {
return nil, err
}
return claims, nil
}
// GetUserGroups extracts user groups from the token claims
// Attempts to find groups in different claim names depending on Cognito setup
// Returns an array of group names or an error if no groups found
func GetUserGroups(claims map[string]interface{}) ([]string, error) {
// The claim containing the groups might have different names depending on your Cognito setup
// Common names are "cognito:groups", "groups", or a custom attribute
possibleGroupFields := []string{"cognito:groups", "groups", "custom:groups"}
for _, field := range possibleGroupFields {
if rawGroups, ok := claims[field]; ok {
switch groups := rawGroups.(type) {
case []interface{}:
result := make([]string, len(groups))
for i, g := range groups {
result[i] = fmt.Sprintf("%v", g)
}
return result, nil
case []string:
return groups, nil
case string:
return []string{groups}, nil
}
}
}
return nil, errors.New("no groups found in token claims")
}
// createCodeChallenge creates a code challenge from a code verifier
// Implements S256 code challenge method as specified in PKCE RFC 7636
func createCodeChallenge(verifier string) string {
// Create SHA256 hash of the verifier
hash := sha256.Sum256([]byte(verifier))
// Base64URL encode the hash without padding
challenge := base64.RawURLEncoding.EncodeToString(hash[:])
return challenge
}
// generateRandomString creates a cryptographically secure random string
// Used for generating state parameter and code verifier
func generateRandomString(length int) (string, error) {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b)[:length], nil
}
// storePKCESession stores the PKCE session for later verification
// In a production environment, this should be replaced with a proper session store
func storePKCESession(state, codeVerifier string, logger *slog.Logger) {
pkceSessionMap.Lock()
defer pkceSessionMap.Unlock()
// Create new session with expiration (5 minutes is common)
session := &PKCESession{
CodeVerifier: codeVerifier,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(5 * time.Minute),
}
pkceSessionMap.sessions[state] = session
// Clean up expired sessions
for k, v := range pkceSessionMap.sessions {
if time.Now().After(v.ExpiresAt) {
logger.Debug("Cleaning up expired PKCE session", "state", k)
delete(pkceSessionMap.sessions, k)
}
}
}
// getCodeVerifier retrieves and validates the code verifier for a state
func getCodeVerifier(state string, logger *slog.Logger) (string, error) {
pkceSessionMap.RLock()
defer pkceSessionMap.RUnlock()
session, ok := pkceSessionMap.sessions[state]
if !ok {
return "", errors.New("no session found for state")
}
if time.Now().After(session.ExpiresAt) {
return "", errors.New("session expired")
}
return session.CodeVerifier, nil
}
// ExtractUserInfo extracts user information from JWT claims
func ExtractUserInfo(claims map[string]interface{}) UserInfo {
userInfo := UserInfo{}
// Extract username
if username, ok := claims["cognito:username"].(string); ok {
userInfo.Username = username
}
// Extract email
if email, ok := claims["email"].(string); ok {
userInfo.Email = email
}
// Extract groups
groups, _ := GetUserGroups(claims)
userInfo.Groups = groups
return userInfo
}
+97
View File
@@ -0,0 +1,97 @@
package cognitoauth
import (
"log/slog"
"regexp"
"strings"
)
// matchRoute checks if a request path matches a route pattern
// Converts Echo route patterns to regex for matching
// Supports parameter patterns like '/users/:id' and wildcard patterns
func matchRoute(requestPath, routePattern string) bool {
// Convert Echo route pattern to regex pattern
// e.g., "/users/:id" to "/users/([^/]+)"
regexPattern := "^"
patternParts := strings.Split(routePattern, "/")
for i, part := range patternParts {
if i > 0 {
regexPattern += "/"
}
if strings.HasPrefix(part, ":") {
// Parameter part (e.g., :id)
regexPattern += "([^/]+)"
} else if part == "*" {
// Wildcard - match anything
regexPattern += ".*"
} else {
// Literal part - escape any regex metacharacters
regexPattern += regexp.QuoteMeta(part)
}
}
regexPattern += "$"
// Compile and match
regex, err := regexp.Compile(regexPattern)
if err != nil {
return false
}
return regex.MatchString(requestPath)
}
// checkPermissions checks if the user has the required permissions
// Used by both the main token validation middleware and the OAuth callback handler
// Returns true if authorized, false if not, along with the required groups
func checkPermissions(path string, userGroups []string, routePermissions map[string][]string, logger *slog.Logger) (bool, []string) {
// Find matching route pattern
var requiredGroups []string
var matched bool
for pattern, groups := range routePermissions {
if matchRoute(path, pattern) {
requiredGroups = groups
matched = true
logger.Debug("Found matching route pattern", "pattern", pattern, "requiredGroups", requiredGroups)
break
}
}
if !matched {
// No matching pattern found - could either allow or deny by default
logger.Info("No matching route pattern found for path", "path", path)
return true, nil // Allowing by default
}
// If no groups are required, allow access
if len(requiredGroups) == 0 {
return true, requiredGroups
}
// Check if user has any of the required groups
for _, requiredGroup := range requiredGroups {
for _, userGroup := range userGroups {
if userGroup == requiredGroup {
logger.Debug("User has required group", "group", requiredGroup)
return true, requiredGroups
}
}
}
return false, requiredGroups
}
// Helper function to mask sensitive values
// Used for logging sensitive information like client IDs
func maskString(s string) string {
if s == "" {
return "<not set>"
}
if len(s) <= 8 {
return "****"
}
return s[:4] + "..." + s[len(s)-4:]
}