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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
})
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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 "..."
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user