Merged in feature/permit-integration-1 (pull request #172)

Permit integration - part 1

* WIP permit integration

* slim down build

* Merge branch 'main' of bitbucket.org:aarete/query-orchestration into feature/permit-integration-1

* omit swagger from auth

* clean build

* comment

* part 1 completed

this is the initial permitio parts and tests without integration. Also testing.md since we have a new test target `task test:permitio`

* feature flag for no jwt validation

* permit integration

* fix ci unit tests

* ci fix

* build fix

* fix home handler

* fix redirect

* test fix

* update docs for auth
This commit is contained in:
Jay Brown
2025-07-11 19:27:14 +00:00
parent 673ba503c8
commit 2ff6e05ffc
53 changed files with 2800 additions and 292 deletions
+16 -34
View File
@@ -126,43 +126,25 @@ func GetUserInfo(c echo.Context) (UserInfo, bool) {
return userInfo, true
}
// RequireGroups creates a middleware that enforces group-based authorization.
// It checks if the authenticated user belongs to at least one of the specified groups.
// If the user is not authenticated, it returns a 401 Unauthorized response.
// If the user is authenticated but doesn't belong to any of the required groups,
// it returns a 403 Forbidden response with detailed information.
// GetUserSubject extracts the subject ID from JWT claims for Permit.io
// The subject ('sub') claim uniquely identifies the user in Cognito
//
// Parameters:
// - groups: Variable number of group names that grant access
// - c: The Echo context containing the HTTP request and context values
//
// Returns:
// - echo.MiddlewareFunc: Middleware function for Echo framework
func RequireGroups(groups ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
userInfo, ok := GetUserInfo(c)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "Authentication required",
})
}
// Check if user has any of the required groups
for _, requiredGroup := range groups {
for _, userGroup := range userInfo.Groups {
if userGroup == requiredGroup {
return next(c)
}
}
}
return c.JSON(http.StatusForbidden, map[string]interface{}{
"error": "Insufficient permissions",
"message": "User doesn't have the required group membership",
"username": userInfo.Username,
"groups": userInfo.Groups,
"required_groups": groups,
})
}
// - string: The user's subject ID from the JWT token
// - bool: True if subject was successfully extracted, false otherwise
func GetUserSubject(c echo.Context) (string, bool) {
userClaims, ok := c.Get("user_claims").(map[string]interface{})
if !ok {
return "", false
}
subject, ok := userClaims["sub"].(string)
if !ok {
return "", false
}
return subject, true
}
+6 -26
View File
@@ -215,36 +215,16 @@ func handleOAuthCallback(c echo.Context, config auth.ConfigProvider) error {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
}
// Extract user groups
userGroups, err := GetUserGroups(idTokenClaims)
if err != nil {
config.GetAuthLogger().Warn("Failed to extract user groups", "error", err)
userGroups = []string{} // Initialize as empty array
}
// Log decoded token for debugging
config.GetAuthLogger().Debug("ID Token Claims", "claims", idTokenClaims)
config.GetAuthLogger().Debug("Raw ID Token", "token", tokens.IDToken)
config.GetAuthLogger().Debug("Raw Access Token", "token", tokens.AccessToken)
// Check authorization
authorized, requiredGroups := checkPermissions(config.GetAuthCallbackPath(), userGroups, config.GetAuthRoutePermissions(), config.GetAuthLogger())
if !authorized {
config.GetAuthLogger().Debug("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,
})
}
// OAuth callback path is typically allowed for all authenticated users
// No additional authorization check needed here as the callback handles authentication
config.GetAuthLogger().Debug("OAuth callback - user authenticated successfully",
"user", idTokenClaims["cognito:username"],
"email", idTokenClaims["email"])
// User is authenticated and authorized
@@ -265,7 +245,7 @@ func handleOAuthCallback(c echo.Context, config auth.ConfigProvider) error {
// Extract username for display purposes (optional)
username, _ := idTokenClaims["cognito:username"].(string)
config.GetAuthLogger().Info("User authenticated successfully", "username", username, "groups", userGroups)
config.GetAuthLogger().Info("User authenticated successfully", "username", username)
// Redirect to home page
return c.Redirect(http.StatusFound, config.GetAuthHomePath())
+27
View File
@@ -0,0 +1,27 @@
package cognitoauth
import "strings"
// GetResourceFromRoute extracts the resource identifier from the HTTP route
// Returns only the first path segment (e.g., "/document/foo/123" -> "document")
// Removes leading slash to match Permit.io resource naming convention
func GetResourceFromRoute(route string) string {
// Remove leading slash if present
route = strings.TrimPrefix(route, "/")
// Find the first slash and return only the part before it
if slashIndex := strings.Index(route, "/"); slashIndex != -1 {
return route[:slashIndex]
}
// No slash found, return the whole string
return route
}
// GetActionFromMethod maps HTTP methods to Permit.io actions
// Converts HTTP methods to lowercase to match Permit.io action naming conventions
func GetActionFromMethod(method string) string {
// Convert to lowercase to match Permit.io action naming conventions
// This ensures case-insensitive matching with configured actions
return strings.ToLower(method)
}
+111
View File
@@ -0,0 +1,111 @@
package cognitoauth
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestGetResourceFromRoute tests the resource mapping function
func TestGetResourceFromRoute(t *testing.T) {
tests := []struct {
name string
route string
expected string
}{
{
name: "SimpleRoute",
route: "/document",
expected: "document",
},
{
name: "RouteWithID",
route: "/document/123",
expected: "document",
},
{
name: "RouteWithMultipleSegments",
route: "/document/foo/123",
expected: "document",
},
{
name: "NestedRoute",
route: "/api/v1/document",
expected: "api",
},
{
name: "RootRoute",
route: "/",
expected: "",
},
{
name: "RouteWithoutLeadingSlash",
route: "document",
expected: "document",
},
{
name: "RouteWithoutLeadingSlashMultipleSegments",
route: "document/foo/123",
expected: "document",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetResourceFromRoute(tt.route)
assert.Equal(t, tt.expected, result)
})
}
}
// TestGetActionFromMethod tests the action mapping function
func TestGetActionFromMethod(t *testing.T) {
tests := []struct {
name string
method string
expected string
}{
{
name: "GET",
method: "GET",
expected: "get",
},
{
name: "POST",
method: "POST",
expected: "post",
},
{
name: "PUT",
method: "PUT",
expected: "put",
},
{
name: "DELETE",
method: "DELETE",
expected: "delete",
},
{
name: "PATCH",
method: "PATCH",
expected: "patch",
},
{
name: "LowercaseMethod",
method: "get",
expected: "get",
},
{
name: "MixedCaseMethod",
method: "GeT",
expected: "get",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetActionFromMethod(tt.method)
assert.Equal(t, tt.expected, result)
})
}
}
+155 -73
View File
@@ -1,6 +1,7 @@
package cognitoauth
import (
"fmt"
"net/http"
"time"
@@ -9,20 +10,131 @@ import (
"strings"
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwk"
)
// isPublicPath checks if the request path should skip authentication
func isPublicPath(requestPath string, config auth.ConfigProvider) bool {
publicPaths := []string{config.GetAuthHomePath(), config.GetAuthLogoutPath(), config.GetHealthPath()}
for _, path := range publicPaths {
if requestPath == path {
return true
}
}
return strings.HasPrefix(requestPath, "/swagger/")
}
// isSpecialAuthPath checks for OAuth callback and login paths that need special handling
func isSpecialAuthPath(requestPath string, c echo.Context, config auth.ConfigProvider) (bool, error) {
// Special case for the OAuth callback path
if requestPath == config.GetAuthCallbackPath() && (c.QueryParam("code") != "" || c.QueryParam("error") != "") {
config.GetAuthLogger().Debug("Handling OAuth callback",
"has_code", c.QueryParam("code") != "",
"has_error", c.QueryParam("error") != "")
return true, handleOAuthCallback(c, config)
}
// Initialize login flow if this is a login request
if requestPath == config.GetAuthLoginPath() {
return true, initiateLoginWithPKCE(c, config)
}
return false, nil
}
// extractToken gets the JWT token from Authorization header or cookie
func extractToken(c echo.Context, config auth.ConfigProvider) (string, error) {
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
return authHeader[7:], nil
}
// Try cookie
tokenCookie, err := c.Cookie("auth_token")
if err != nil || tokenCookie.Value == "" {
config.GetAuthLogger().Warn("No authorization header or cookie provided")
return "", fmt.Errorf("no authorization header or cookie provided")
}
return tokenCookie.Value, nil
}
// performPermitIOAuthorization handles the Permit.io authorization check
func performPermitIOAuthorization(c echo.Context, requestPath string, config auth.ConfigProvider) error {
// Skip authorization for swagger and metrics
if strings.HasPrefix(requestPath, "/swagger") || strings.HasPrefix(requestPath, "/metrics") {
return nil
}
// Get user subject from JWT for Permit.io
userSubject, ok := GetUserSubject(c)
if !ok {
config.GetAuthLogger().Warn("No user subject found in request")
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "Authentication required",
})
}
// Initialize Permit.io client
permitClient := NewPermitIOClient(
config.GetPermitIOAPIKey(),
config.GetPermitIOPDPURL(),
config.GetAuthLogger(),
)
// Authorize with Permit.io
resource := GetResourceFromRoute(requestPath)
action := GetActionFromMethod(c.Request().Method)
permitted, err := permitClient.CheckPermission(userSubject, action, resource)
if err != nil {
config.GetAuthLogger().Error("Permit.io authorization failed",
"error", err,
"user", userSubject,
"resource", resource,
"action", action)
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Authorization service unavailable",
})
}
if !permitted {
config.GetAuthLogger().Warn("Access denied by Permit.io",
"user", userSubject,
"resource", resource,
"action", action,
"path", requestPath)
if err := c.JSON(http.StatusForbidden, map[string]interface{}{
"error": "Insufficient permissions",
"message": "Access denied by authorization service",
"user": userSubject,
"resource": resource,
"action": action,
}); err != nil {
config.GetAuthLogger().Error("Failed to send JSON response", "error", err)
}
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
config.GetAuthLogger().Debug("Access granted by Permit.io",
"user", userSubject,
"resource", resource,
"action", action)
return nil
}
// TokenValidationMiddleware verifies JWT tokens and enforces authentication and authorization.
//
// This middleware performs several critical security functions:
// - Validates JWT tokens and their signatures against the JWKS from the identity provider
// - Extracts and validates claims from the token
// - Extracts user groups and stores them in the context
// - Checks authorization based on the user's group memberships
// - Checks authorization using Permit.io for dynamic permissions
// - Enforces route-specific permissions
//
// The middleware skips authentication for public paths and handles special cases
// like OAuth callbacks and login initiation. For protected routes, it enforces
// both authentication (valid token) and authorization (correct group membership).
// both authentication (valid token) and authorization (Permit.io permissions).
//
// Parameters:
// - config: A ConfigProvider implementation that supplies all necessary auth configuration
@@ -35,87 +147,52 @@ func TokenValidationMiddleware(config auth.ConfigProvider) echo.MiddlewareFunc {
requestPath := c.Request().URL.Path
config.GetAuthLogger().Debug("Processing request", "path", requestPath, "method", c.Request().Method)
// Skip token validation for specified public paths
publicPaths := []string{config.GetAuthHomePath(), config.GetAuthLogoutPath(), config.GetHealthPath()}
for _, path := range publicPaths {
if requestPath == path {
config.GetAuthLogger().Debug("Skipping token validation for public path", "path", requestPath)
return next(c)
// Check for public paths that skip authentication
if isPublicPath(requestPath, config) {
config.GetAuthLogger().Debug("Skipping token validation for public path", "path", requestPath)
return next(c)
}
// Check for special auth paths (OAuth callback, login)
if isSpecial, err := isSpecialAuthPath(requestPath, c, config); isSpecial {
return err
}
// Extract JWT token from header or cookie
tokenStr, err := extractToken(c, config)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
"error": "Authorization required",
"message": "This endpoint requires authentication. Please obtain a valid JWT token.",
"login_url": config.GetAuthLoginPath(),
})
}
// Get JWKS (skip in test mode)
var keySet jwk.Set
if !config.GetNoJWTValidation() {
keySet, err = GetJWKS(config.GetAuthJwksURL(), config.GetAuthLogger())
if err != nil {
config.GetAuthLogger().Error("Failed to get JWKS", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
}
}
// Special case for the OAuth callback path
if requestPath == config.GetAuthCallbackPath() && (c.QueryParam("code") != "" || c.QueryParam("error") != "") {
config.GetAuthLogger().Debug("Handling OAuth callback",
"has_code", c.QueryParam("code") != "",
"has_error", c.QueryParam("error") != "")
// Skip token validation for OAuth callback - it will be handled by the callback handler
return handleOAuthCallback(c, config)
}
// Initialize login flow if this is a login request
if requestPath == config.GetAuthLoginPath() {
return initiateLoginWithPKCE(c, config)
}
// Get authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
config.GetAuthLogger().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.GetAuthJwksURL(), config.GetAuthLogger())
if err != nil {
config.GetAuthLogger().Error("Failed to get JWKS", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
}
// Verify token
// Verify token (keySet can be nil in test mode)
claims, err := verifyToken(tokenStr, keySet, config)
if err != nil {
config.GetAuthLogger().Warn("Token verification failed", "error", err)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
}
// Extract user groups
userGroups, err := GetUserGroups(claims)
if err != nil {
config.GetAuthLogger().Warn("Failed to extract user groups", "error", err)
userGroups = []string{} // Empty array if no groups found
}
// Store in context
// Store user info in context
c.Set("user_claims", claims)
c.Set("user_groups", userGroups)
// Create and store user info in context
userInfo := ExtractUserInfo(claims)
c.Set("user_info", userInfo)
// Check authorization
authorized, requiredGroups := checkPermissions(requestPath, userGroups, config.GetAuthRoutePermissions(), config.GetAuthLogger())
if !authorized {
config.GetAuthLogger().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,
})
// Perform Permit.io authorization
if err := performPermitIOAuthorization(c, requestPath, config); err != nil {
return err
}
// User is authenticated and authorized
@@ -165,6 +242,11 @@ func JWTAuthMiddleware(config auth.ConfigProvider) echo.MiddlewareFunc {
return next(c)
}
// Skip for swagger documentation paths
if strings.HasPrefix(c.Path(), "/swagger/") {
return next(c)
}
// Check if Authorization header is already set
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
@@ -175,8 +257,8 @@ func JWTAuthMiddleware(config auth.ConfigProvider) echo.MiddlewareFunc {
// Try to get token from cookie
tokenCookie, err := c.Cookie("auth_token")
if err != nil || tokenCookie.Value == "" {
// No token cookie found
return c.Redirect(http.StatusFound, config.GetAuthLoginPath())
// No token cookie found - let TokenValidationMiddleware handle the error
return next(c)
}
// Add token to Authorization header
+2 -3
View File
@@ -106,13 +106,12 @@ func TestJWTAuthMiddleware(t *testing.T) {
expectHeader: "Bearer cookie-token",
},
{
name: "redirect to login if no token",
name: "continue to next middleware if no token",
path: "/api/test",
setupRequest: func(req *http.Request) {
// No auth header or cookie
},
expectStatus: http.StatusFound, // 302 Found (redirect)
expectLocation: "/login",
expectStatus: http.StatusOK, // Should continue to test handler
},
}
-61
View File
@@ -1,7 +1,6 @@
package cognitoauth
import (
"log/slog"
"regexp"
"strings"
)
@@ -56,63 +55,3 @@ func matchRoute(requestPath, routePattern string) bool {
return regex.MatchString(requestPath)
}
// checkPermissions verifies if a user has the necessary permissions to access a specific
// path based on their group memberships and the route permission configuration.
//
// This function is used by both the main token validation middleware and the OAuth
// callback handler to implement route-based access control. It works by:
// 1. Finding a matching route pattern in the routePermissions map
// 2. Extracting the required groups for that route
// 3. Checking if the user belongs to any of the required groups
//
// If no matching route pattern is found, access is allowed by default.
// If a matching pattern is found but no groups are required, access is allowed.
// Otherwise, the user must belong to at least one of the required groups.
//
// Parameters:
// - path: The HTTP request path to check permissions for
// - userGroups: A slice of group names that the user belongs to
// - routePermissions: A map where keys are route patterns and values are slices of required group names
// - logger: A structured logger for recording debug and authorization information
//
// Returns:
// - bool: true if the user is authorized, false otherwise
// - []string: The list of required groups for the matching route (may be empty)
func checkPermissions(path string, userGroups []string, routePermissions map[string][]string, logger *slog.Logger) (bool, []string) {
// Find matching route pattern
var requiredGroups []string
var matched bool
for pattern, groups := range routePermissions {
if matchRoute(path, pattern) {
requiredGroups = groups
matched = true
logger.Debug("Found matching route pattern", "pattern", pattern, "requiredGroups", requiredGroups)
break
}
}
if !matched {
// No matching pattern found - could either allow or deny by default
logger.Info("No matching route pattern found for path", "path", path)
return true, nil // Allowing by default
}
// If no groups are required, allow access
if len(requiredGroups) == 0 {
return true, requiredGroups
}
// Check if user has any of the required groups
for _, requiredGroup := range requiredGroups {
for _, userGroup := range userGroups {
if userGroup == requiredGroup {
logger.Debug("User has required group", "group", requiredGroup)
return true, requiredGroups
}
}
}
return false, requiredGroups
}
@@ -1,9 +1,6 @@
package cognitoauth
import (
"log/slog"
"os"
"reflect"
"testing"
)
@@ -103,6 +100,7 @@ func TestMatchRoute(t *testing.T) {
}
}
// Legacy test - checkPermissions function has been replaced with Permit.io authorization
// TestCheckPermissions verifies the checkPermissions function correctly enforces
// access control based on user groups and route permissions. It tests:
// - Admin access to various routes
@@ -113,7 +111,9 @@ func TestMatchRoute(t *testing.T) {
// - Routes with non-existent required groups
// - Path parameter matching with permissions
// - Default behavior for unmatched routes
func TestCheckPermissions(t *testing.T) {
func TestCheckPermissions_Legacy(t *testing.T) {
t.Skip("Skipping legacy group-based authorization test - replaced with Permit.io")
/* Legacy code - all commented out
// Setup logger
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
@@ -218,4 +218,5 @@ func TestCheckPermissions(t *testing.T) {
}
})
}
*/
}
+100
View File
@@ -0,0 +1,100 @@
package cognitoauth
import (
"log/slog"
"sync"
"github.com/permitio/permit-golang/pkg/config"
"github.com/permitio/permit-golang/pkg/enforcement"
"github.com/permitio/permit-golang/pkg/permit"
)
// clientKey represents the unique identifier for a PermitIO client configuration
type clientKey struct {
apiKey string
pdpURL string
}
// PermitIOClient wraps the Permit.io client with logging
type PermitIOClient struct {
client *permit.Client
logger *slog.Logger
}
var (
// clientCache stores cached PermitIO clients by their configuration
clientCache = make(map[clientKey]*PermitIOClient)
// clientMutex protects concurrent access to the cache
clientMutex sync.RWMutex
)
// NewPermitIOClient creates or returns a cached Permit.io client instance
// Uses caching based on the combination of apiKey and pdpURL to avoid creating
// duplicate clients for the same configuration.
func NewPermitIOClient(apiKey, pdpURL string, logger *slog.Logger) *PermitIOClient {
key := clientKey{
apiKey: apiKey,
pdpURL: pdpURL,
}
// First, try to get existing client with read lock
clientMutex.RLock()
if existingClient, exists := clientCache[key]; exists {
clientMutex.RUnlock()
return existingClient
}
clientMutex.RUnlock()
// If not found, acquire write lock and check again (double-checked locking)
clientMutex.Lock()
defer clientMutex.Unlock()
// Check again in case another goroutine created it while we were waiting
if existingClient, exists := clientCache[key]; exists {
return existingClient
}
// Create new client
newClient := &PermitIOClient{
client: permit.NewPermit(
config.NewConfigBuilder(apiKey).
WithPdpUrl(pdpURL).
Build(),
),
logger: logger,
}
// Cache the new client
clientCache[key] = newClient
return newClient
}
// CheckPermission verifies if a user has permission to perform an action on a resource
func (p *PermitIOClient) CheckPermission(userID, action, resourceType string) (bool, error) {
user := enforcement.UserBuilder(userID).Build()
resource := enforcement.ResourceBuilder(resourceType).Build()
p.logger.Debug("Sending permission check to PDP",
"user_id", userID,
"action", action,
"resource_type", resourceType)
permitted, err := p.client.Check(user, enforcement.Action(action), resource)
if err != nil {
p.logger.Error("Permit.io authorization check failed",
"user", userID,
"action", action,
"resource", resourceType,
"error", err)
return false, err
}
p.logger.Debug("Permit.io authorization check",
"user", userID,
"action", action,
"resource", resourceType,
"permitted", permitted)
return permitted, nil
}
+381
View File
@@ -0,0 +1,381 @@
//go:build permitio
package cognitoauth
import (
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/joho/godotenv"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// Test configuration for local PDP
testPDPURL = "http://localhost:7766"
testUserID = "test-user-1"
testResource = "document"
testAction = "read"
)
func init() {
// Load .env file if it exists (for tests)
// Try multiple possible locations
_ = godotenv.Load("../../.env")
_ = godotenv.Load(".env")
}
// getTestAPIKey returns the test API key from environment - fails if not set
func getTestAPIKey() string {
key := os.Getenv("PERMIT_IO_API_KEY")
if key == "" {
panic("PERMIT_IO_API_KEY environment variable must be set to run Permit.io tests")
}
return key
}
// TestPermitIOClient_NewPermitIOClient verifies that a new Permit.io client can be created successfully
func TestPermitIOClient_NewPermitIOClient(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
client := NewPermitIOClient(getTestAPIKey(), testPDPURL, logger)
assert.NotNil(t, client)
assert.NotNil(t, client.client)
assert.Equal(t, logger, client.logger)
}
// TestPermitIOClient_CheckPermission tests the CheckPermission method with a real local PDP
// This test requires the local PDP to be running as described in the demo.sh script
func TestPermitIOClient_CheckPermission(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
client := NewPermitIOClient(getTestAPIKey(), testPDPURL, logger)
// Test successful permission check
t.Run("ValidPermissionCheck", func(t *testing.T) {
permitted, err := client.CheckPermission(testUserID, testAction, testResource)
// Even if permission is denied, the call should succeed without error
assert.NoError(t, err)
assert.IsType(t, bool(false), permitted)
// Log the result for debugging
t.Logf("Permission check result: user=%s, action=%s, resource=%s, permitted=%v",
testUserID, testAction, testResource, permitted)
})
// Test with different actions
t.Run("DifferentActions", func(t *testing.T) {
actions := []string{"GET", "POST", "PUT", "DELETE"}
for _, action := range actions {
t.Run(action, func(t *testing.T) {
permitted, err := client.CheckPermission(testUserID, action, testResource)
assert.NoError(t, err)
assert.IsType(t, bool(false), permitted)
t.Logf("Action %s result: permitted=%v", action, permitted)
})
}
})
// Test with different resources
t.Run("DifferentResources", func(t *testing.T) {
resources := []string{"document", "query-endpoint", "queryAPI"}
for _, resource := range resources {
t.Run(resource, func(t *testing.T) {
permitted, err := client.CheckPermission(testUserID, testAction, resource)
assert.NoError(t, err)
assert.IsType(t, bool(false), permitted)
t.Logf("Resource %s result: permitted=%v", resource, permitted)
})
}
})
}
// TestPermitIOClient_CheckPermission_ErrorHandling tests error scenarios
func TestPermitIOClient_CheckPermission_ErrorHandling(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
t.Run("InvalidPDPURL", func(t *testing.T) {
// Use an invalid URL to test error handling
client := NewPermitIOClient(getTestAPIKey(), "http://invalid-url:9999", logger)
permitted, err := client.CheckPermission(testUserID, testAction, testResource)
// Should return an error and false for permission
assert.Error(t, err)
assert.False(t, permitted)
})
t.Run("EmptyUserID", func(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
client := NewPermitIOClient(getTestAPIKey(), testPDPURL, logger)
// Test with empty user ID
permitted, err := client.CheckPermission("", testAction, testResource)
// This might succeed or fail depending on Permit.io's handling of empty user IDs
// The important thing is that we handle it gracefully
t.Logf("Empty user ID result: permitted=%v, error=%v", permitted, err)
assert.IsType(t, bool(false), permitted)
})
}
// TestPermitIOClient_CheckPermission_Timeout tests that the client handles timeouts appropriately
func TestPermitIOClient_CheckPermission_Timeout(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
client := NewPermitIOClient(getTestAPIKey(), testPDPURL, logger)
// Test that the client completes within a reasonable time
start := time.Now()
_, err := client.CheckPermission(testUserID, testAction, testResource)
elapsed := time.Since(start)
// Should complete within 30 seconds (generous timeout for testing)
assert.True(t, elapsed < 30*time.Second, "Permission check took too long: %v", elapsed)
// Log results even if there's an error (might be expected if PDP is not running)
t.Logf("Permission check completed in %v, error: %v", elapsed, err)
}
// TestPermitIOClient_Integration is a comprehensive integration test
// This test requires the local PDP to be running
func TestPermitIOClient_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
client := NewPermitIOClient(getTestAPIKey(), testPDPURL, logger)
// Test scenarios that mirror the actual API endpoints
testCases := []struct {
name string
userID string
resource string
action string
}{
{"DocumentRead", "test-user-1", "document", "read"},
{"DocumentCreate", "test-user-1", "document", "create"},
{"QueryRead", "test-user-2", "query-endpoint", "read"},
{"QueryAPIRead", "test-user-3", "queryAPI", "read"},
{"AdminStuffRead", "admin-user", "admin-stuff", "read"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
permitted, err := client.CheckPermission(tc.userID, tc.action, tc.resource)
// Log all results for debugging purposes
t.Logf("Test case %s: user=%s, resource=%s, action=%s, permitted=%v, error=%v",
tc.name, tc.userID, tc.resource, tc.action, permitted, err)
// We don't assert specific permission results since the test environment
// may not have specific user/role configurations set up
// The important thing is that the calls complete without panicking
assert.IsType(t, bool(false), permitted)
})
}
}
// TestPermitIOClient_ConfiguredUsers tests with actual users configured in Permit.io
// This test uses the users and roles defined in permitio_test_config.go
func TestPermitIOClient_ConfiguredUsers(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
client := NewPermitIOClient(getTestAPIKey(), testPDPURL, logger)
// Test all configured users with all actions on document resource
for _, user := range TestUsers {
for _, action := range TestResources["document"].AvailableActions {
t.Run(user.Name+"_"+action+"_document", func(t *testing.T) {
permitted, err := client.CheckPermission(user.Key, action, "document")
// Calculate expected result based on test configuration
expectedPermitted := HasPermission(user.Key, "document", action)
t.Logf("User: %s (%s), Action: %s, Resource: document", user.Name, user.Key, action)
t.Logf("User Roles: %v", user.Roles)
t.Logf("Expected: %v, Actual: %v, Error: %v", expectedPermitted, permitted, err)
// Assert no error occurred
assert.NoError(t, err, "Permission check should not fail")
// Assert that the actual result matches expectations
if expectedPermitted != permitted {
t.Logf("MISMATCH: Expected %v but got %v for user %s (%s) doing %s on document",
expectedPermitted, permitted, user.Name, user.Key, action)
t.Logf("User roles: %v", user.Roles)
for role, actions := range TestResources["document"].RolePermissions {
t.Logf("Role %s can do: %v", role, actions)
}
// FAIL the test when expectations don't match actual results
t.Errorf("Permission expectation failed: expected %v, got %v", expectedPermitted, permitted)
} else {
t.Logf("MATCH: Permission result matches expectation")
}
})
}
}
}
// TestPermitIOClient_SpecificUserPermissions tests specific permission scenarios
func TestPermitIOClient_SpecificUserPermissions(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
client := NewPermitIOClient(getTestAPIKey(), testPDPURL, logger)
testUser, exists := GetUserByName("test user")
require.True(t, exists, "Test user should exist in configuration")
johnSmith, exists := GetUserByName("john smith")
require.True(t, exists, "John Smith should exist in configuration")
testCases := []struct {
name string
user TestUser
action string
resource string
expected bool
}{
// Test user (viewer + editor roles)
{"TestUser_CanRead", testUser, "read", "document", true}, // both viewer and editor can read
{"TestUser_CanCreate", testUser, "create", "document", true}, // editor can create
{"TestUser_CanUpdate", testUser, "update", "document", true}, // editor can update
{"TestUser_CannotDelete", testUser, "delete", "document", false}, // editor cannot delete
// John Smith (editor + viewer roles)
{"JohnSmith_CanRead", johnSmith, "read", "document", true}, // both viewer and editor can read
{"JohnSmith_CanCreate", johnSmith, "create", "document", true}, // editor can create
{"JohnSmith_CanUpdate", johnSmith, "update", "document", true}, // editor can update
{"JohnSmith_CannotDelete", johnSmith, "delete", "document", false}, // editor cannot delete
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
permitted, err := client.CheckPermission(tc.user.Key, tc.action, tc.resource)
assert.NoError(t, err, "Permission check should not fail")
t.Logf("User: %s (%s), Action: %s, Resource: %s", tc.user.Name, tc.user.Key, tc.action, tc.resource)
t.Logf("User Roles: %v", tc.user.Roles)
t.Logf("Expected: %v, Actual: %v", tc.expected, permitted)
if tc.expected != permitted {
t.Logf("EXPECTATION MISMATCH: Expected %v but got %v", tc.expected, permitted)
// Log role permissions for debugging
for role, actions := range TestResources[tc.resource].RolePermissions {
t.Logf("Role %s permissions on %s: %v", role, tc.resource, actions)
}
// FAIL the test when expectations don't match actual results
t.Errorf("Permission expectation failed: expected %v, got %v", tc.expected, permitted)
} else {
t.Logf("MATCH: Permission result matches expectation")
}
})
}
}
// BenchmarkPermitIOClient_CheckPermission benchmarks the permission check performance
func BenchmarkPermitIOClient_CheckPermission(b *testing.B) {
if testing.Short() {
b.Skip("Skipping benchmark in short mode")
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
client := NewPermitIOClient(getTestAPIKey(), testPDPURL, logger)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.CheckPermission(testUserID, testAction, testResource)
}
}
// TestAPIKeyValidation tests API key validation scenarios for startup validation
func TestAPIKeyValidation(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
t.Run("ValidAPIKey", func(t *testing.T) {
// Test with the correct API key - should succeed (no error, regardless of permission result)
client := NewPermitIOClient(getTestAPIKey(), testPDPURL, logger)
// Use dummy test data for validation
testUser := "startup-validation-user-12345"
testAction := "read"
testResource := "startup-validation-resource"
permitted, err := client.CheckPermission(testUser, testAction, testResource)
// Should not have an error (even if permission is denied)
assert.NoError(t, err, "Valid API key should not produce errors")
assert.IsType(t, bool(false), permitted)
t.Logf("Valid API key test: permitted=%v, error=%v", permitted, err)
})
t.Run("InvalidAPIKey", func(t *testing.T) {
// Modify the API key by changing the last character
validKey := getTestAPIKey()
require.NotEmpty(t, validKey, "Test API key must not be empty")
// Change the last character to make it invalid
invalidKey := validKey[:len(validKey)-1] + "X"
if validKey[len(validKey)-1] == 'X' {
invalidKey = validKey[:len(validKey)-1] + "Y"
}
client := NewPermitIOClient(invalidKey, testPDPURL, logger)
// Use dummy test data for validation
testUser := "startup-validation-user-12345"
testAction := "read"
testResource := "startup-validation-resource"
permitted, err := client.CheckPermission(testUser, testAction, testResource)
// Should have an error due to invalid API key
assert.Error(t, err, "Invalid API key should produce an error")
assert.False(t, permitted, "Invalid API key should result in denied permission")
// Check that it's an authentication/API key error, not a connectivity error
errStr := err.Error()
t.Logf("Invalid API key error: %v", err)
// Should be an API-related error, not a connectivity error
assert.True(t,
strings.Contains(errStr, "UnprocessableEntityError") ||
strings.Contains(errStr, "api_error") ||
strings.Contains(errStr, "Unauthorized") ||
strings.Contains(errStr, "authentication"),
"Error should indicate API key/authentication issue, got: %s", errStr)
})
}
@@ -0,0 +1,93 @@
//go:build permitio
package cognitoauth
// TestUser represents a test user with their assigned roles
type TestUser struct {
Key string
Name string
Email string
Roles []string
}
// TestUsers contains the users configured in the Permit.io test environment
var TestUsers = []TestUser{
{
Key: "e12bb510-30e1-7062-a69a-ca7f3f38d80e",
Name: "test user",
Email: "betot75403@isorax.com",
Roles: []string{"viewer", "editor"},
},
{
Key: "d12bf580-b071-7017-b1ff-a27961affc34",
Name: "john smith",
Email: "work.jay@gmail.com",
Roles: []string{"editor", "viewer"},
},
}
// TestResource represents a resource with its available actions and role permissions
type TestResource struct {
Name string
AvailableActions []string
RolePermissions map[string][]string // role -> actions
}
// TestResources contains the resources configured in the Permit.io test environment
var TestResources = map[string]TestResource{
"document": {
Name: "document",
AvailableActions: []string{"create", "delete", "read", "update"},
RolePermissions: map[string][]string{
"admin": {"create", "delete", "read", "update"},
"editor": {"create", "read", "update"}, // delete is unchecked
"viewer": {"read"}, // only read is checked
},
},
}
// GetUserByKey returns a test user by their key
func GetUserByKey(key string) (TestUser, bool) {
for _, user := range TestUsers {
if user.Key == key {
return user, true
}
}
return TestUser{}, false
}
// GetUserByName returns a test user by their name
func GetUserByName(name string) (TestUser, bool) {
for _, user := range TestUsers {
if user.Name == name {
return user, true
}
}
return TestUser{}, false
}
// HasPermission checks if a user should have permission for a resource/action based on test configuration
func HasPermission(userKey, resource, action string) bool {
user, exists := GetUserByKey(userKey)
if !exists {
return false
}
testResource, resourceExists := TestResources[resource]
if !resourceExists {
return false
}
// Check if any of the user's roles have permission for this action
for _, userRole := range user.Roles {
if allowedActions, roleExists := testResource.RolePermissions[userRole]; roleExists {
for _, allowedAction := range allowedActions {
if allowedAction == action {
return true
}
}
}
}
return false
}
@@ -0,0 +1,35 @@
package cognitoauth
import (
"log/slog"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
// TestNewPermitIOClient_UnitTest tests client creation without requiring PDP
func TestNewPermitIOClient_UnitTest(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
client := NewPermitIOClient("test-key", "http://localhost:7766", logger)
assert.NotNil(t, client)
assert.NotNil(t, client.client)
assert.Equal(t, logger, client.logger)
}
// TestCheckPermission_UnitTest tests the method signature and error handling
func TestCheckPermission_UnitTest(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
// Create client with invalid URL to test error handling
client := NewPermitIOClient("test-key", "http://invalid-nonexistent-host:9999", logger)
// This should return an error because the host doesn't exist
permitted, err := client.CheckPermission("test-user", "GET", "/test")
// We expect an error and false permission
assert.Error(t, err)
assert.False(t, permitted)
}
+177 -11
View File
@@ -1,13 +1,15 @@
# Cognito Auth
A reusable Go package for AWS Cognito authentication and authorization with Echo framework using PKCE flow.
A reusable Go package for AWS Cognito authentication and Permit.io authorization with Echo framework using PKCE flow.
## Features
- Complete OAuth 2.0 PKCE flow for AWS Cognito
- Complete OAuth 2.0 PKCE flow for AWS Cognito authentication
- JWT token validation and verification
- Route-based authorization using Cognito user groups
- Middleware for token handling
- Dynamic route-based authorization using Permit.io Policy Decision Point (PDP)
- Automatic resource mapping from HTTP routes to Permit.io resources
- HTTP method to action mapping for authorization checks
- Middleware for token handling and permission enforcement
- Cookie-based token storage
- Default home page with authentication status
- Simple integration with Echo framework
@@ -24,18 +26,182 @@ package see the [summary](./summary.md) file.
The package reads configuration from environment variables:
### AWS Cognito (Authentication)
- `COGNITO_CLIENT_ID`: Your AWS Cognito App Client ID
- `COGNITO_CLIENT_SECRET`: Your AWS Cognito App Client Secret (optional for public clients)
- `COGNITO_CLIENT_SECRET`: Your AWS Cognito App Client Secret
- `COGNITO_USER_POOL_ID`: Your AWS Cognito User Pool ID
- `COGNITO_DOMAIN`: Your AWS Cognito domain
- `AWS_REGION`: AWS region where your Cognito User Pool is located (us-east-2)
- `AWS_REGION`: AWS region where your Cognito User Pool is located (default: us-east-2)
- `COGNITO_REGION`: AWS region for Cognito service (default: us-east-2)
### Permit.io (Authorization)
- `PERMIT_IO_API_KEY`: Your Permit.io API key for accessing the PDP
- `PERMIT_IO_PDP_URL`: URL of your Permit.io Policy Decision Point (default: `http://localhost:7766`)
### General
- `DEBUG`: Set to "true" for debug logging
## Feature flag
### Environment Variable Loading
Environment variables are loaded in the following hierarchy (first found wins):
1. **System environment variables**
2. **devbox.json "env" section** - Development environment defaults
3. **.env file** - Local overrides and secrets (loaded via `env_from` in devbox.json)
## Feature flags
### Disable Authentication
You can disable all of the auth features with the following environment variable:
`DISABLE_AUTH=true`
## Route Permissions
Route permissions are hard-coded in the `route_permissions.go` file.
In the future we may
integrate this information into the swagger API document.
### Test Mode (JWT Validation Bypass)
For testing purposes only, you can bypass JWT signature validation with:
`NO_JWT_VALIDATION=true`
**Warning**: This flag should ONLY be used in testing environments. When enabled:
- JWT signature validation is bypassed
- Token expiration is ignored
- Unsigned test tokens can be used for authorization testing
- The system logs warnings when this mode is active
This allows testing of authorization logic without requiring valid AWS Cognito tokens.
## Middleware Architecture
### Middleware Chain Order
The authentication system uses two middleware components that must be registered in the correct order:
1. **`JWTAuthMiddleware`** (First)
- Converts JWT tokens from cookies to Authorization headers
- Skips processing for public paths (`/login`, `/logout`, `/home`, `/swagger/*`)
- Handles logout by clearing auth cookies
- **Does NOT perform authentication** - only prepares the request
- Passes requests to the next middleware in the chain
2. **`TokenValidationMiddleware`** (Second)
- **Performs authentication**: Validates JWT tokens and extracts user claims
- **Performs authorization**: Checks permissions via Permit.io PDP
- Returns JSON error responses for authentication/authorization failures
- Sets user context for subsequent handlers
**Why order matters**: The first middleware prepares the request for token validation, while the second middleware performs the actual **authentication and authorization enforcement**.
### Client Caching
The system implements efficient client caching for Permit.io connections:
- **Singleton pattern**: One client instance per unique (apiKey, pdpURL) combination
- **Thread-safe**: Uses `sync.RWMutex` for concurrent access protection
- **Automatic cleanup**: Clients are reused across requests to the same PDP
## Startup Validation
The system performs comprehensive validation at startup to ensure proper configuration:
### Configuration Validation (`ValidatePermitIOConfiguration`)
1. **Skip validation**: If `DISABLE_AUTH=true` (case-insensitive)
2. **PDP connectivity test**: HTTP health check to `/healthy` endpoint
3. **API key validation**: Quick permission check to verify API key validity
4. **Error handling**: Application halts if validation fails
### Validation Process
- **PDP URL**: Uses `PERMIT_IO_PDP_URL` or defaults to `http://localhost:7766`
- **API key**: Requires `PERMIT_IO_API_KEY` environment variable
- **Health check**: Tests connectivity with 10-second timeout
- **Permission test**: Validates API key with dummy authorization request
## Error Handling
### Authentication vs Authorization Errors
**Authentication Errors** (No valid JWT token):
```json
{
"error": "Authorization required",
"message": "This endpoint requires authentication. Please obtain a valid JWT token.",
"login_url": "/login"
}
```
- **Status Code**: `401 Unauthorized`
- **When**: Missing, invalid, or expired JWT tokens
**Authorization Errors** (Valid token, insufficient permissions):
```json
{
"error": "Insufficient permissions",
"message": "Access denied by authorization service",
"user": "user-subject-id",
"resource": "document",
"action": "get"
}
```
- **Status Code**: `403 Forbidden`
- **When**: Valid JWT but Permit.io denies access
### Error Response Format
All error responses return JSON with consistent structure:
- **error**: Brief error identifier
- **message**: Human-readable description
- **Additional fields**: Context-specific information (user, resource, action, etc.)
## Testing
### Test Mode JWT Handling
When `NO_JWT_VALIDATION=true` is set:
- **Pretty-printed JWTs**: Handles formatted JSON payloads with newlines/tabs
- **2-part JWT format**: Automatically handles `header.payload` format (adds empty signature)
- **Unsigned tokens**: Bypasses signature verification for testing
- **Flexible parsing**: More permissive token parsing for development
### Running Integration Tests
```bash
# Requires local PDP and API key
task test:permitio
# Or directly with verbose output
go test -tags=permitio -parallel 2 -count=1 -v ./internal/cognitoauth
```
### Test Environment Requirements
- **Local PDP**: Permit.io Policy Decision Point running on port 7766
- **API key**: Valid `PERMIT_IO_API_KEY` in environment or `.env` file
- **Test data**: Configured users, resources, and permissions in Permit.io
## Authorization System
### Permit.io Integration
This package uses Permit.io for dynamic authorization instead of static group-based permissions. The system:
1. **Authenticates** users via AWS Cognito (JWT tokens)
2. **Authorizes** requests via Permit.io Policy Decision Point (PDP)
3. **Maps** HTTP routes to Permit.io resources automatically
4. **Converts** HTTP methods to Permit.io actions
### Automatic Resource Mapping
The system automatically maps HTTP routes to Permit.io resources using the following logic:
- **Extracts the first path segment** from the route as the resource name
- **Removes leading slashes** to match Permit.io naming conventions
- **Ignores path parameters and nested segments**
#### Examples:
- `/document``document`
- `/document/123``document`
- `/document/foo/123``document`
- `/api/v1/users``api`
- `/query-endpoint/run``query-endpoint`
### HTTP Method to Action Mapping
HTTP methods are mapped to lowercase Permit.io actions:
- `GET``get`
- `POST``post`
- `PUT``put`
- `PATCH``patch`
- `DELETE``delete`
### Authorization Flow
1. User makes request to protected endpoint
2. Middleware extracts JWT token and validates it with Cognito
3. User subject (`sub` claim) is extracted from JWT
4. Route is mapped to resource name (first path segment)
5. HTTP method is mapped to action name
6. Permit.io PDP is queried: `CheckPermission(user, action, resource)`
7. Request is allowed/denied based on PDP response
+54
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"queryorchestration/internal/serviceconfig/auth"
@@ -23,6 +24,7 @@ const PKCE_SESSION_TTL = 5 * time.Minute
// This function validates the provided JWT token against a JWK key set,
// verifying signature, expiration, issuer, and other standard JWT claims.
// It's used during both OAuth callback and subsequent API requests with bearer tokens.
// When NO_JWT_VALIDATION is enabled, it bypasses signature validation for testing.
//
// Parameters:
// - tokenString: The raw JWT token string to verify
@@ -33,6 +35,11 @@ const PKCE_SESSION_TTL = 5 * time.Minute
// - map[string]interface{}: The verified token claims as a map
// - error: Error if token verification fails for any reason
func verifyToken(tokenString string, keySet jwk.Set, config auth.ConfigProvider) (map[string]interface{}, error) {
// Check if JWT validation is disabled for testing
if config.GetNoJWTValidation() {
return verifyTokenTestMode(tokenString, config)
}
region := config.GetAuthRegion()
issuer := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", region, config.GetAuthUserPoolID())
@@ -57,6 +64,53 @@ func verifyToken(tokenString string, keySet jwk.Set, config auth.ConfigProvider)
return claims, nil
}
// verifyTokenTestMode parses JWT without signature validation for testing.
//
// This function is used when NO_JWT_VALIDATION=true is set. It parses the JWT
// without verifying the signature, validates basic structure, and extracts claims.
// This allows testing with unsigned tokens while still extracting user information.
//
// Parameters:
// - tokenString: The raw JWT token string to parse
// - config: Configuration provider containing auth settings
//
// Returns:
// - map[string]interface{}: The token claims as a map
// - error: Error if token parsing fails
func verifyTokenTestMode(tokenString string, config auth.ConfigProvider) (map[string]interface{}, error) {
logger := config.GetAuthLogger()
logger.Warn("JWT validation disabled - using test mode. This should only be used for testing!")
// Handle RFC-compliant unsecured JWTs that may have only 2 parts (header.payload.)
// The JWT library expects 3 parts, so we need to ensure proper format
parts := strings.Split(tokenString, ".")
if len(parts) == 2 {
// RFC 7519 Section 6 compliant unsecured JWT (header.payload.)
// Add empty signature part to make it parseable by the JWT library
tokenString = tokenString + "."
} else if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format: expected 2 or 3 parts, got %d", len(parts))
}
// Parse token without signature verification
parsedToken, err := jwt.Parse(
[]byte(tokenString),
jwt.WithVerify(false), // Skip signature verification
jwt.WithValidate(false), // Skip expiration and other validations
)
if err != nil {
return nil, fmt.Errorf("failed to parse token in test mode: %w", err)
}
// Extract claims to a map
claims, err := parsedToken.AsMap(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to extract claims in test mode: %w", err)
}
return claims, nil
}
// GetUserGroups extracts user groups from the token claims.
//
// This function attempts to find groups in different claim names depending
+174 -2
View File
@@ -14,8 +14,9 @@ import (
// It provides predefined values for configuration methods and no-op implementations
// for setters, allowing tests to run without requiring actual AWS Cognito credentials.
type mockConfigProvider struct {
region string
userPoolID string
region string
userPoolID string
noJWTValidation bool
}
func (m *mockConfigProvider) GetAuthRegion() string { return m.region }
@@ -36,6 +37,8 @@ func (m *mockConfigProvider) GetAuthJwksURL() string { return "http://loca
func (m *mockConfigProvider) SetAuthJwksURL(s string) {}
func (m *mockConfigProvider) GetAuthURL() string { return "http://localhost/auth" }
func (m *mockConfigProvider) SetAuthURL(s string) {}
func (m *mockConfigProvider) GetAuthLogoutURL() string { return "http://localhost/logout" }
func (m *mockConfigProvider) SetAuthLogoutURL(s string) {}
func (m *mockConfigProvider) GetAuthLoginPath() string { return "/login" }
func (m *mockConfigProvider) SetAuthLoginPath(s string) {}
func (m *mockConfigProvider) GetAuthCallbackPath() string { return "/callback" }
@@ -52,9 +55,17 @@ func (m *mockConfigProvider) GetAuthRoutePermissions() map[string][]string {
return map[string][]string{"/test": {"admin"}}
}
func (m *mockConfigProvider) SetAuthRoutePermissions(map[string][]string) {}
func (m *mockConfigProvider) GetPermitIOAPIKey() string {
return "permit_key_test_mock_key_for_testing_purposes_only"
}
func (m *mockConfigProvider) SetPermitIOAPIKey(s string) {}
func (m *mockConfigProvider) GetPermitIOPDPURL() string { return "http://localhost:7766" }
func (m *mockConfigProvider) SetPermitIOPDPURL(s string) {}
func (m *mockConfigProvider) InitializeAuthConfig(string, *slog.Logger) error {
return nil
}
func (m *mockConfigProvider) GetNoJWTValidation() bool { return m.noJWTValidation }
func (m *mockConfigProvider) SetNoJWTValidation(val bool) { m.noJWTValidation = val }
// Helper function to generate test JWT and JWK
@@ -407,3 +418,164 @@ func TestExtractUserInfo(t *testing.T) {
})
}
}
// TestVerifyTokenTestMode verifies that the verifyToken function correctly handles
// test mode when NO_JWT_VALIDATION is enabled. It tests:
// - Parsing of unsigned JWT tokens without signature validation
// - Extraction of user information from test tokens
// - Proper group membership validation
// - Error handling for malformed tokens
// verifyGroupMemberships is a helper function to verify group memberships in tests
func verifyGroupMemberships(t *testing.T, groups []string) {
// Verify user is in the document group
hasDocumentGroup := false
for _, group := range groups {
if group == "document" {
hasDocumentGroup = true
break
}
}
if !hasDocumentGroup {
t.Errorf("User should be in 'document' group, groups: %v", groups)
}
// Verify user is NOT in the foo group
hasFooGroup := false
for _, group := range groups {
if group == "foo" {
hasFooGroup = true
break
}
}
if hasFooGroup {
t.Errorf("User should NOT be in 'foo' group, groups: %v", groups)
}
// Verify all expected groups are present
expectedGroups := []string{"uploaders", "querybuilders", "document"}
for _, expectedGroup := range expectedGroups {
found := false
for _, group := range groups {
if group == expectedGroup {
found = true
break
}
}
if !found {
t.Errorf("Expected group '%s' not found in groups: %v", expectedGroup, groups)
}
}
}
// createTestJWTToken creates an unsigned JWT token for testing
func createTestJWTToken() string {
// Create a simple unsigned JWT token (header.payload.signature)
// Header: {"alg":"none","typ":"JWT"}
header := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0" // {"alg":"none","typ":"JWT"}
// Create payload JSON and encode it
payloadJSON := `{
"sub": "e12bb510-30e1-7062-a69a-ca7f3f38d80e",
"cognito:groups": ["uploaders", "querybuilders", "document"],
"iss": "https://cognito-idp.us-east-2.amazonaws.com/us-east-2_1y6po8rR8",
"version": 2,
"client_id": "552cqkf3640t39ncehkmgpce31",
"origin_jti": "0750d18e-8588-4d12-acbd-754d7279f317",
"event_id": "89841577-8dd9-4cd4-a698-a890393305ff",
"token_use": "access",
"scope": "openid profile email",
"auth_time": 1747326253,
"exp": 1747329853,
"iat": 1747326253,
"jti": "739302f1-7334-4718-b124-b51fb958af9a",
"cognito:username": "testuser"
}`
payload := base64.RawURLEncoding.EncodeToString([]byte(payloadJSON))
// RFC 7519 Section 6: Unsecured JWTs with alg:none should not have signature part
// Create a 2-part token (header.payload.) that's RFC compliant
return fmt.Sprintf("%s.%s.", header, payload)
}
func TestVerifyTokenTestMode(t *testing.T) {
unsignedToken := createTestJWTToken()
// Print the test JWT token for manual integration testing
t.Logf("=== TEST JWT TOKEN FOR MANUAL TESTING ===")
t.Logf("Use this unsigned JWT token for integration tests:")
t.Logf("%s", unsignedToken)
t.Logf("=== END TEST JWT TOKEN ===")
// Also print it to stdout so it's visible even without -v flag
fmt.Printf("\n=== TEST JWT TOKEN FOR MANUAL TESTING ===\n")
fmt.Printf("Token: %s\n", unsignedToken)
fmt.Printf("=== END TEST JWT TOKEN ===\n\n")
t.Run("test mode with unsigned token", func(t *testing.T) {
// Create mock config with test mode enabled
mockConfig := &mockConfigProvider{
region: "us-east-2",
userPoolID: "us-east-2_1y6po8rR8",
noJWTValidation: true,
}
// Verify the token in test mode (keySet is not used in test mode)
claims, err := verifyToken(unsignedToken, nil, mockConfig)
if err != nil {
t.Errorf("verifyToken() in test mode failed: %v", err)
return
}
// Verify the subject was extracted correctly
if sub, ok := claims["sub"].(string); !ok || sub != "e12bb510-30e1-7062-a69a-ca7f3f38d80e" {
t.Errorf("verifyToken() sub = %v, want %v", claims["sub"], "e12bb510-30e1-7062-a69a-ca7f3f38d80e")
}
// Verify the username was extracted correctly
if username, ok := claims["cognito:username"].(string); !ok || username != "testuser" {
t.Errorf("verifyToken() cognito:username = %v, want %v", claims["cognito:username"], "testuser")
}
// Test group extraction
groups, err := GetUserGroups(claims)
if err != nil {
t.Errorf("GetUserGroups() failed: %v", err)
return
}
// Test group memberships using helper function
verifyGroupMemberships(t, groups)
})
t.Run("normal mode with unsigned token should fail", func(t *testing.T) {
// Create mock config with test mode disabled
mockConfig := &mockConfigProvider{
region: "us-east-2",
userPoolID: "us-east-2_1y6po8rR8",
noJWTValidation: false,
}
// This should fail because we're trying to verify an unsigned token in normal mode
_, err := verifyToken(unsignedToken, nil, mockConfig)
if err == nil {
t.Error("verifyToken() in normal mode should fail with unsigned token")
}
})
t.Run("test mode with malformed token", func(t *testing.T) {
// Create mock config with test mode enabled
mockConfig := &mockConfigProvider{
region: "us-east-2",
userPoolID: "us-east-2_1y6po8rR8",
noJWTValidation: true,
}
// Test with malformed token
invalidToken := "invalid.jwt.structure.here" // nolint:gosec // This is a test string, not real credentials
_, err := verifyToken(invalidToken, nil, mockConfig)
if err == nil {
t.Error("verifyToken() in test mode should fail with malformed token")
}
})
}
+107
View File
@@ -0,0 +1,107 @@
package cognitoauth
import (
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
)
// ValidatePermitIOConfiguration validates both PDP connectivity and API key at startup
// This function should be called during application startup to ensure configuration is correct
// before processing any real authorization requests.
//
// It performs two checks:
// 1. PDP Health Check: Tests connectivity to the PDP URL using the /healthy endpoint
// 2. API Key Validation: Makes a dummy permission check to verify the API key is valid
//
// If DISABLE_AUTH=true, validation is skipped and function returns success.
// Returns an error if either check fails, which should cause the application to halt startup.
func ValidatePermitIOConfiguration() error {
// Skip validation if auth is disabled
disableAuth := os.Getenv("DISABLE_AUTH")
if strings.ToLower(disableAuth) == "true" {
fmt.Println("✓ Permit.io validation skipped (DISABLE_AUTH=true)")
return nil
}
pdpURL := os.Getenv("PERMIT_IO_PDP_URL")
apiKey := os.Getenv("PERMIT_IO_API_KEY")
// Apply default for PDP URL if not set
if pdpURL == "" {
pdpURL = "http://localhost:7766"
}
if apiKey == "" {
return fmt.Errorf("PERMIT_IO_API_KEY environment variable must be set")
}
// 1. Test PDP connectivity with /healthy endpoint
client := &http.Client{Timeout: 10 * time.Second}
healthURL := strings.TrimSuffix(pdpURL, "/") + "/healthy"
req, err := http.NewRequest("GET", healthURL, nil)
if err != nil {
return fmt.Errorf("failed to create health check request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("Permit.io PDP connectivity failed with PDP_URL=%s: %v", pdpURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("Permit.io PDP health check failed with PDP_URL=%s: HTTP %d", pdpURL, resp.StatusCode)
}
// 2. Test API key validity with a dummy permission check
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
permitClient := NewPermitIOClient(apiKey, pdpURL, logger)
// Use dummy test data that will likely be denied but should work with valid API key
testUser := "startup-validation-user-12345"
testAction := "read"
testResource := "startup-validation-resource"
_, err = permitClient.CheckPermission(testUser, testAction, testResource)
if err != nil {
// Check for specific error patterns to distinguish error types
errStr := err.Error()
if strings.Contains(errStr, "UnexpectedError") && (strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "no such host")) {
return fmt.Errorf("Permit.io PDP connectivity failed with PDP_URL=%s: %v", pdpURL, err)
}
if strings.Contains(errStr, "Unauthorized") || strings.Contains(errStr, "Invalid PDP token") || strings.Contains(errStr, "api_error") {
return fmt.Errorf("Permit.io API key validation failed with API_KEY=%s and PDP_URL=%s: %v",
maskAPIKey(apiKey), pdpURL, err)
}
if strings.Contains(errStr, "UnprocessableEntityError") {
return fmt.Errorf("Permit.io API configuration error with API_KEY=%s and PDP_URL=%s: %v",
maskAPIKey(apiKey), pdpURL, err)
}
// Any other error is also a configuration problem
return fmt.Errorf("Permit.io validation failed with API_KEY=%s and PDP_URL=%s: %v",
maskAPIKey(apiKey), pdpURL, err)
}
// Success - both PDP connectivity and API key are valid
fmt.Printf("✓ Permit.io configuration validated successfully (PDP_URL=%s, API_KEY=%s)\n",
pdpURL, maskAPIKey(apiKey))
return nil
}
// maskAPIKey masks the API key for logging, showing only the first 8 characters
func maskAPIKey(apiKey string) string {
if len(apiKey) <= 8 {
return "***masked***"
}
return apiKey[:8] + "..."
}
+181
View File
@@ -0,0 +1,181 @@
//go:build permitio
package cognitoauth
import (
"os"
"testing"
"github.com/joho/godotenv"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func init() {
// Load .env file if it exists (for tests)
_ = godotenv.Load("../../.env")
_ = godotenv.Load(".env")
}
// TestValidatePermitIOConfiguration tests the startup validation function
func TestValidatePermitIOConfiguration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Save original environment variables
originalAPIKey := os.Getenv("PERMIT_IO_API_KEY")
originalPDPURL := os.Getenv("PERMIT_IO_PDP_URL")
originalDisableAuth := os.Getenv("DISABLE_AUTH")
// Clean up after test
defer func() {
os.Setenv("PERMIT_IO_API_KEY", originalAPIKey)
os.Setenv("PERMIT_IO_PDP_URL", originalPDPURL)
os.Setenv("DISABLE_AUTH", originalDisableAuth)
}()
t.Run("AuthDisabled", func(t *testing.T) {
// Test that validation is skipped when DISABLE_AUTH=true
os.Setenv("DISABLE_AUTH", "true")
os.Setenv("PERMIT_IO_API_KEY", "") // Missing API key
os.Setenv("PERMIT_IO_PDP_URL", "") // Missing PDP URL
// Should succeed even with missing credentials
err := ValidatePermitIOConfiguration()
assert.NoError(t, err, "Should skip validation when DISABLE_AUTH=true")
})
t.Run("ValidConfiguration", func(t *testing.T) {
// Ensure auth is enabled for this test
os.Setenv("DISABLE_AUTH", "false")
// Set up valid configuration
validAPIKey := originalAPIKey
if validAPIKey == "" {
validAPIKey = getTestAPIKey()
}
require.NotEmpty(t, validAPIKey, "Test API key must be available")
os.Setenv("PERMIT_IO_API_KEY", validAPIKey)
os.Setenv("PERMIT_IO_PDP_URL", testPDPURL)
// Should succeed with valid configuration
err := ValidatePermitIOConfiguration()
assert.NoError(t, err, "Valid configuration should pass validation")
})
t.Run("MissingAPIKey", func(t *testing.T) {
// Ensure auth is enabled for this test
os.Setenv("DISABLE_AUTH", "false")
// Test missing API key (PDP URL should use default)
os.Setenv("PERMIT_IO_API_KEY", "")
os.Setenv("PERMIT_IO_PDP_URL", testPDPURL)
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
assert.Contains(t, err.Error(), "PERMIT_IO_API_KEY environment variable must be set")
})
t.Run("DefaultPDPURL", func(t *testing.T) {
// Ensure auth is enabled for this test
os.Setenv("DISABLE_AUTH", "false")
// Test that default PDP URL is used when not set
validAPIKey := originalAPIKey
if validAPIKey == "" {
validAPIKey = getTestAPIKey()
}
os.Setenv("PERMIT_IO_API_KEY", validAPIKey)
os.Setenv("PERMIT_IO_PDP_URL", "") // Use default
// This will likely fail because default localhost:7766 might not be running,
// but it should not fail due to missing environment variable
err := ValidatePermitIOConfiguration()
// Error should be about connectivity, not missing env var
if err != nil {
assert.NotContains(t, err.Error(), "environment variable must be set")
}
})
t.Run("InvalidPDPURL", func(t *testing.T) {
// Ensure auth is enabled for this test
os.Setenv("DISABLE_AUTH", "false")
validAPIKey := originalAPIKey
if validAPIKey == "" {
validAPIKey = getTestAPIKey()
}
os.Setenv("PERMIT_IO_API_KEY", validAPIKey)
os.Setenv("PERMIT_IO_PDP_URL", "http://invalid-host-12345:9999")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
assert.Contains(t, err.Error(), "PDP connectivity failed")
})
t.Run("InvalidAPIKey", func(t *testing.T) {
// Ensure auth is enabled for this test
os.Setenv("DISABLE_AUTH", "false")
// Create an invalid API key by modifying the valid one
validAPIKey := originalAPIKey
if validAPIKey == "" {
validAPIKey = getTestAPIKey()
}
require.NotEmpty(t, validAPIKey, "Test API key must be available")
// Change the last character to make it invalid
invalidAPIKey := validAPIKey[:len(validAPIKey)-1] + "X"
if validAPIKey[len(validAPIKey)-1] == 'X' {
invalidAPIKey = validAPIKey[:len(validAPIKey)-1] + "Y"
}
os.Setenv("PERMIT_IO_API_KEY", invalidAPIKey)
os.Setenv("PERMIT_IO_PDP_URL", testPDPURL)
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
assert.Contains(t, err.Error(), "API key validation failed")
})
}
// TestMaskAPIKey tests the API key masking function
func TestMaskAPIKey(t *testing.T) {
tests := []struct {
name string
apiKey string
expected string
}{
{
name: "LongAPIKey",
apiKey: "permit_key_1234567890abcdef",
expected: "permit_k...",
},
{
name: "ShortAPIKey",
apiKey: "short",
expected: "***masked***",
},
{
name: "ExactlyEightChars",
apiKey: "12345678",
expected: "***masked***",
},
{
name: "NineChars",
apiKey: "123456789",
expected: "12345678...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maskAPIKey(tt.apiKey)
assert.Equal(t, tt.expected, result)
})
}
}
@@ -0,0 +1,362 @@
package cognitoauth
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestValidatePermitIOConfiguration_Unit tests validation function without permitio tag
func TestValidatePermitIOConfiguration_Unit(t *testing.T) {
// Using t.Setenv() for automatic cleanup
t.Run("AuthDisabled", func(t *testing.T) {
// Test that validation is skipped when DISABLE_AUTH=true
t.Setenv("DISABLE_AUTH", "true")
t.Setenv("PERMIT_IO_API_KEY", "") // Missing API key
t.Setenv("PERMIT_IO_PDP_URL", "") // Missing PDP URL
// Should succeed even with missing credentials
err := ValidatePermitIOConfiguration()
assert.NoError(t, err, "Should skip validation when DISABLE_AUTH=true")
})
t.Run("AuthDisabledCaseInsensitive", func(t *testing.T) {
// Test case insensitive handling of DISABLE_AUTH
testCases := []string{"TRUE", "True", "true", "tRuE"}
for _, value := range testCases {
t.Run("Value_"+value, func(t *testing.T) {
t.Setenv("DISABLE_AUTH", value)
t.Setenv("PERMIT_IO_API_KEY", "")
t.Setenv("PERMIT_IO_PDP_URL", "")
err := ValidatePermitIOConfiguration()
assert.NoError(t, err, "Should skip validation for DISABLE_AUTH=%s", value)
})
}
})
t.Run("AuthEnabledMissingAPIKey", func(t *testing.T) {
// Test that validation fails when auth is enabled but API key is missing
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "")
t.Setenv("PERMIT_IO_PDP_URL", "http://localhost:7766")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
assert.Contains(t, err.Error(), "PERMIT_IO_API_KEY environment variable must be set")
})
t.Run("AuthEnabledDefaultBehavior", func(t *testing.T) {
// Test that validation runs when DISABLE_AUTH is not set
t.Setenv("DISABLE_AUTH", "") // Not set
t.Setenv("PERMIT_IO_API_KEY", "")
t.Setenv("PERMIT_IO_PDP_URL", "")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
assert.Contains(t, err.Error(), "PERMIT_IO_API_KEY environment variable must be set")
})
t.Run("AuthEnabledWithAPIKeyButBadURL", func(t *testing.T) {
// Test connectivity failure with invalid URL
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-api-key-12345")
t.Setenv("PERMIT_IO_PDP_URL", "http://invalid-nonexistent-host-12345:9999")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on connectivity, not API key validation
assert.Contains(t, err.Error(), "PDP connectivity failed")
})
t.Run("AuthEnabledWithDefaultURL", func(t *testing.T) {
// Test that default URL is used when not set
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-api-key-12345")
t.Setenv("PERMIT_IO_PDP_URL", "") // Should use default
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail trying to connect to default localhost:7766
assert.Contains(t, err.Error(), "http://localhost:7766")
})
t.Run("HealthCheckConnectivityError", func(t *testing.T) {
// Test health check connectivity failure
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-api-key-12345")
// Use an invalid host that will fail to connect
t.Setenv("PERMIT_IO_PDP_URL", "http://definitely-invalid-host-123456:9999")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on connectivity
assert.Contains(t, err.Error(), "PDP connectivity failed")
})
t.Run("HealthCheckNon200Response", func(t *testing.T) {
// Test health check with non-200 response (hard to simulate without server)
// This tests the path where connectivity works but PDP returns non-200
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-api-key-12345")
// Use localhost with a port that might return different status
t.Setenv("PERMIT_IO_PDP_URL", "http://httpbin.org/status/500")
err := ValidatePermitIOConfiguration()
// This will likely fail on connectivity, but if it connects, should test non-200 path
if err != nil {
// Accept either connectivity failure or health check failure
assert.True(t,
strings.Contains(err.Error(), "PDP connectivity failed") ||
strings.Contains(err.Error(), "health check failed"),
"Should fail on either connectivity or health check")
}
})
t.Run("PDPURLWithTrailingSlash", func(t *testing.T) {
// Test that trailing slash is properly handled in PDP URL
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-api-key-12345")
t.Setenv("PERMIT_IO_PDP_URL", "http://localhost:7766/")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should still attempt connection to localhost:7766
assert.Contains(t, err.Error(), "localhost:7766")
})
t.Run("APIKeyEdgeCases", func(t *testing.T) {
// Test API key with special characters and edge cases
testCases := []struct {
name string
apiKey string
}{
{"WithSpaces", "test api key 123"},
{"WithSpecialChars", "test-api-key-!@#$%"},
{"VeryLong", strings.Repeat("a", 100)},
{"SingleChar", "x"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", tc.apiKey)
t.Setenv("PERMIT_IO_PDP_URL", "http://invalid-host:9999")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on connectivity
assert.Contains(t, err.Error(), "PDP connectivity failed")
})
}
})
t.Run("DisableAuthVariations", func(t *testing.T) {
// Test various values that should NOT disable auth
testCases := []string{"false", "False", "FALSE", "0", "no", "off", ""}
for _, value := range testCases {
t.Run("Value_"+value, func(t *testing.T) {
t.Setenv("DISABLE_AUTH", value)
t.Setenv("PERMIT_IO_API_KEY", "")
t.Setenv("PERMIT_IO_PDP_URL", "")
err := ValidatePermitIOConfiguration()
assert.Error(t, err, "Should require validation for DISABLE_AUTH=%s", value)
assert.Contains(t, err.Error(), "PERMIT_IO_API_KEY environment variable must be set")
})
}
})
t.Run("TestHTTPStatusPath", func(t *testing.T) {
// Test that health check properly handles HTTP status codes
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-api-key-12345")
// Use a URL that will return a specific HTTP status for health checks
t.Setenv("PERMIT_IO_PDP_URL", "https://httpbin.org/status/404")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail due to non-200 health check response
assert.Contains(t, err.Error(), "health check failed")
assert.Contains(t, err.Error(), "HTTP 404")
})
t.Run("EmptyAPIKeyAfterDefault", func(t *testing.T) {
// Ensure we test the empty API key check after default URL assignment
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "") // Empty API key
t.Setenv("PERMIT_IO_PDP_URL", "") // Will use default
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
assert.Contains(t, err.Error(), "PERMIT_IO_API_KEY environment variable must be set")
})
t.Run("SuccessfulValidation", func(t *testing.T) {
// Test successful validation path by using an actual working PDP
// This test simulates success by using httpbin that returns 200 for health
// but will fail on permit client validation (which is expected)
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-valid-api-key-12345")
// Use a service that returns 200 for any path to pass health check
t.Setenv("PERMIT_IO_PDP_URL", "https://httpbin.org/anything")
err := ValidatePermitIOConfiguration()
// This should pass health check but fail on permit client validation
// We want to test that the success message path gets coverage
if err == nil {
// If this somehow succeeds, that's fine - we got coverage of success path
t.Log("Validation succeeded unexpectedly, but that's okay for coverage")
} else {
// Should fail on permit validation, not health check
assert.NotContains(t, err.Error(), "health check failed")
}
})
t.Run("ErrorPatternMatching", func(t *testing.T) {
// Test different error pattern matching by setting up scenarios
// that would trigger different error handling branches
testCases := []struct {
name string
apiKey string
pdpURL string
expectError string
}{
{
name: "UnexpectedErrorPattern",
apiKey: "test-unexpected-error-key",
pdpURL: "http://invalid-host-that-will-refuse:9999",
expectError: "PDP connectivity failed",
},
{
name: "UnauthorizedPattern",
apiKey: "test-unauthorized-key",
pdpURL: "https://httpbin.org/status/401",
expectError: "validation failed", // Will get generic error since httpbin doesn't speak permit protocol
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", tc.apiKey)
t.Setenv("PERMIT_IO_PDP_URL", tc.pdpURL)
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// We mainly want to exercise the error handling paths
assert.NotEmpty(t, err.Error())
})
}
})
t.Run("HTTPClientTimeout", func(t *testing.T) {
// Test HTTP client timeout scenarios
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-timeout-key")
// Use httpbin delay to potentially trigger timeout behavior
t.Setenv("PERMIT_IO_PDP_URL", "https://httpbin.org/delay/15")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on either connectivity, timeout, or permit validation
assert.NotEmpty(t, err.Error())
t.Logf("HTTPClientTimeout error: %v", err)
})
t.Run("MalformedURL", func(t *testing.T) {
// Test with malformed URLs that might cause http.NewRequest to fail
t.Setenv("DISABLE_AUTH", "false")
t.Setenv("PERMIT_IO_API_KEY", "test-malformed-url-key")
// Use malformed URL
t.Setenv("PERMIT_IO_PDP_URL", "ht@tp://invalid-url-format")
err := ValidatePermitIOConfiguration()
assert.Error(t, err)
// Should fail on either request creation or connectivity
assert.NotEmpty(t, err.Error())
})
}
// TestMaskAPIKey_Unit tests the API key masking function
func TestMaskAPIKey_Unit(t *testing.T) {
tests := []struct {
name string
apiKey string
expected string
}{
{
name: "LongAPIKey",
apiKey: "permit_key_1234567890abcdef",
expected: "permit_k...",
},
{
name: "ShortAPIKey",
apiKey: "short",
expected: "***masked***",
},
{
name: "ExactlyEightChars",
apiKey: "12345678",
expected: "***masked***",
},
{
name: "NineChars",
apiKey: "123456789",
expected: "12345678...",
},
{
name: "EmptyString",
apiKey: "",
expected: "***masked***",
},
{
name: "OneChar",
apiKey: "a",
expected: "***masked***",
},
{
name: "TenChars",
apiKey: "1234567890",
expected: "12345678...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maskAPIKey(tt.apiKey)
assert.Equal(t, tt.expected, result)
})
}
}
// TestMaskAPIKey_EdgeCases tests edge cases for API key masking
func TestMaskAPIKey_EdgeCases(t *testing.T) {
// Test that the function doesn't panic with various inputs
t.Run("NilString", func(t *testing.T) {
// Go strings can't be nil, but test empty string
result := maskAPIKey("")
assert.Equal(t, "***masked***", result)
})
t.Run("SpecialCharacters", func(t *testing.T) {
apiKey := "permit_!@#$%^&*()"
expected := "permit_!..."
result := maskAPIKey(apiKey)
assert.Equal(t, expected, result)
})
t.Run("UnicodeCharacters", func(t *testing.T) {
// Test with Unicode characters (not hardcoded credentials)
apiKey := "permit_" + "🔑🛡️🚀"
// First 8 bytes, not characters for Unicode
result := maskAPIKey(apiKey)
require.NotEmpty(t, result)
assert.True(t, len(result) > 3) // Should have some content plus "..."
})
}
+1 -5
View File
@@ -189,11 +189,7 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
e := echo.New()
cfg.SetRouter(e)
// auth start - may move to separate rbac function
// update these to match the endpoints.
routePermissions := GetRoutePermissions()
cfg.SetAuthRoutePermissions(routePermissions)
// auth start - using Permit.io for authorization
cognitoauth.RegisterRoutes(cfg.GetRouter(), cfg)
// auth end
-11
View File
@@ -1,11 +0,0 @@
package api
func GetRoutePermissions() map[string][]string {
return map[string][]string{
"/client": {"exporters", "uploaders", "querybuilders"},
"/document": {"exporters", "querybuilders"},
"/query": {"exporters", "uploaders", "querybuilders"},
"/export": {"exporters"},
"/settings": {"exporters"},
}
}
+53 -1
View File
@@ -20,11 +20,17 @@ type CognitoConfig struct {
AuthDomain string `env:"COGNITO_DOMAIN,required,notEmpty"`
AuthClientID string `env:"COGNITO_CLIENT_ID,required,notEmpty"`
DisableAuth string `env:"DISABLE_AUTH" envDefault:"false"`
NoJWTValidation bool `env:"NO_JWT_VALIDATION" envDefault:"false"`
// Permit.io configuration fields
PermitIOAPIKey string `env:"PERMIT_IO_API_KEY"`
PermitIOPDPURL string `env:"PERMIT_IO_PDP_URL" envDefault:"http://localhost:7766"`
AuthRedirectURI string // URL where Cognito redirects after authentication
AuthTokenURL string // Cognito endpoint for token operations
AuthJwksURL string // URL for JSON Web Key Set (for token verification)
AuthURL string // Cognito authorization endpoint for initiating login
AuthLogoutURL string // Cognito logout endpoint for session termination
AuthLoginPath string `env:"COGNITO_LOGIN_PATH" envDefault:"/login"`
AuthCallbackPath string `env:"COGNITO_CALLBACK_PATH" envDefault:"/login-callback"`
AuthHomePath string `env:"COGNITO_HOME_PATH" envDefault:"/home"`
@@ -46,6 +52,14 @@ type ConfigProvider interface {
SetAuthDomain(string)
GetAuthClientID() string
SetAuthClientID(string)
GetNoJWTValidation() bool
SetNoJWTValidation(bool)
// Permit.io configuration methods
GetPermitIOAPIKey() string
SetPermitIOAPIKey(string)
GetPermitIOPDPURL() string
SetPermitIOPDPURL(string)
// Non-env fields
GetAuthRedirectURI() string
@@ -56,6 +70,8 @@ type ConfigProvider interface {
SetAuthJwksURL(string)
GetAuthURL() string
SetAuthURL(string)
GetAuthLogoutURL() string
SetAuthLogoutURL(string)
GetAuthLoginPath() string
SetAuthLoginPath(string)
GetAuthCallbackPath() string
@@ -136,6 +152,7 @@ func InitializeConfig(baseURL string, logger *slog.Logger) *CognitoConfig {
tempConfig.AuthRedirectURI = baseURL + tempConfig.AuthCallbackPath
tempConfig.AuthTokenURL = fmt.Sprintf("%s/oauth2/token", tempConfig.AuthDomain)
tempConfig.AuthURL = fmt.Sprintf("%s/oauth2/authorize", tempConfig.AuthDomain)
tempConfig.AuthLogoutURL = fmt.Sprintf("%s/logout", tempConfig.AuthDomain)
tempConfig.AuthJwksURL = fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json",
tempConfig.AuthRegion,
tempConfig.AuthUserPoolID)
@@ -155,6 +172,7 @@ func (c *CognitoConfig) PrettyPrintDebugOnly() {
"AuthRedirectURI", c.AuthRedirectURI,
"AuthTokenURL", c.AuthTokenURL,
"AuthURL", c.AuthURL,
"AuthLogoutURL", c.AuthLogoutURL,
"AuthJwksURL", c.AuthJwksURL,
"AuthUserPoolID", c.AuthUserPoolID,
"AuthRegion", c.AuthRegion,
@@ -164,7 +182,9 @@ func (c *CognitoConfig) PrettyPrintDebugOnly() {
"AuthLogoutPath", c.AuthLogoutPath,
"HealthPath", c.HealthPath,
"DisableAuth", c.DisableAuth,
"AuthDomain", c.AuthDomain)
"AuthDomain", c.AuthDomain,
"PermitIOAPIKey", c.PermitIOAPIKey,
"PermitIOPDPURL", c.PermitIOPDPURL)
// pretty print the route permissions map
for route, permissions := range c.AuthRoutePermissions {
@@ -248,6 +268,14 @@ func (c *CognitoConfig) SetAuthURL(val string) {
c.AuthURL = val
}
func (c *CognitoConfig) GetAuthLogoutURL() string {
return c.AuthLogoutURL
}
func (c *CognitoConfig) SetAuthLogoutURL(val string) {
c.AuthLogoutURL = val
}
func (c *CognitoConfig) GetAuthLoginPath() string {
return c.AuthLoginPath
}
@@ -303,3 +331,27 @@ func (c *CognitoConfig) GetAuthRoutePermissions() map[string][]string {
func (c *CognitoConfig) SetAuthRoutePermissions(val map[string][]string) {
c.AuthRoutePermissions = val
}
func (c *CognitoConfig) GetPermitIOAPIKey() string {
return c.PermitIOAPIKey
}
func (c *CognitoConfig) SetPermitIOAPIKey(val string) {
c.PermitIOAPIKey = val
}
func (c *CognitoConfig) GetPermitIOPDPURL() string {
return c.PermitIOPDPURL
}
func (c *CognitoConfig) SetPermitIOPDPURL(val string) {
c.PermitIOPDPURL = val
}
func (c *CognitoConfig) GetNoJWTValidation() bool {
return c.NoJWTValidation
}
func (c *CognitoConfig) SetNoJWTValidation(val bool) {
c.NoJWTValidation = val
}
@@ -0,0 +1,29 @@
package auth
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestPermitIOConfigMethods tests the getter and setter methods for Permit.io configuration
func TestPermitIOConfigMethods(t *testing.T) {
config := &CognitoConfig{}
// Test PermitIOAPIKey getter/setter
testAPIKey := "test-api-key-12345"
config.SetPermitIOAPIKey(testAPIKey)
assert.Equal(t, testAPIKey, config.GetPermitIOAPIKey())
// Test PermitIOPDPURL getter/setter
testPDPURL := "http://localhost:7766"
config.SetPermitIOPDPURL(testPDPURL)
assert.Equal(t, testPDPURL, config.GetPermitIOPDPURL())
// Test empty values
config.SetPermitIOAPIKey("")
assert.Equal(t, "", config.GetPermitIOAPIKey())
config.SetPermitIOPDPURL("")
assert.Equal(t, "", config.GetPermitIOPDPURL())
}
@@ -249,6 +249,12 @@ func TestGettersAndSetters(t *testing.T) {
getter: func() interface{} { return cfg.GetAuthLogoutPath() },
expected: "/custom-logout",
},
{
name: "AuthLogoutURL",
setter: func() { cfg.SetAuthLogoutURL("https://example.com/logout") },
getter: func() interface{} { return cfg.GetAuthLogoutURL() },
expected: "https://example.com/logout",
},
{
name: "HealthPath",
setter: func() { cfg.SetHealthPath("/custom-health") },
@@ -267,6 +273,18 @@ func TestGettersAndSetters(t *testing.T) {
getter: func() interface{} { return cfg.GetAuthRoutePermissions() },
expected: testPermissions,
},
{
name: "NoJWTValidation",
setter: func() { cfg.SetNoJWTValidation(true) },
getter: func() interface{} { return cfg.GetNoJWTValidation() },
expected: true,
},
{
name: "NoJWTValidationFalse",
setter: func() { cfg.SetNoJWTValidation(false) },
getter: func() interface{} { return cfg.GetNoJWTValidation() },
expected: false,
},
}
// Run all test cases