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