auth package WIP
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
Reference in New Issue
Block a user