817927cb7d
schemas / check-release (pull_request) Successful in 1m57s
schemas / vulnerabilities (pull_request) Successful in 2m48s
schemas / check (pull_request) Successful in 8m17s
pre-commit / pre-commit (pull_request) Successful in 11m38s
schemas / build (pull_request) Successful in 5m31s
schemas / deploy-prod (pull_request) Has been skipped
- Use validator and jwks packages for JWT validation - Replace manual JWKS caching with jwks.NewCachingProvider - Add CustomClaims struct for https://unbound.se/roles claim - Rename TokenFromContext to ClaimsFromContext - Update middleware/auth.go to use new claims structure - Update tests to use core.SetClaims and validator.ValidatedClaims Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
567 lines
15 KiB
Go
567 lines
15 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/auth0/go-jwt-middleware/v3/core"
|
|
"github.com/auth0/go-jwt-middleware/v3/validator"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
"gitlab.com/unboundsoftware/eventsourced/eventsourced"
|
|
|
|
"gitea.unbound.se/unboundsoftware/schemas/domain"
|
|
)
|
|
|
|
// MockCache is a mock implementation of the Cache interface
|
|
type MockCache struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *MockCache) OrganizationByAPIKey(apiKey string) *domain.Organization {
|
|
args := m.Called(apiKey)
|
|
if args.Get(0) == nil {
|
|
return nil
|
|
}
|
|
return args.Get(0).(*domain.Organization)
|
|
}
|
|
|
|
func TestAuthMiddleware_Handler_WithValidAPIKey(t *testing.T) {
|
|
// Setup
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
orgID := uuid.New()
|
|
expectedOrg := &domain.Organization{
|
|
BaseAggregate: eventsourced.BaseAggregate{
|
|
ID: eventsourced.IdFromString(orgID.String()),
|
|
},
|
|
Name: "Test Organization",
|
|
}
|
|
|
|
apiKey := "test-api-key-123"
|
|
|
|
// Mock expects plaintext key (cache handles hashing internally)
|
|
mockCache.On("OrganizationByAPIKey", apiKey).Return(expectedOrg)
|
|
|
|
// Create a test handler that checks the context
|
|
var capturedOrg *domain.Organization
|
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if org := r.Context().Value(OrganizationKey); org != nil {
|
|
if o, ok := org.(domain.Organization); ok {
|
|
capturedOrg = &o
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Create request with API key in context
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
ctx := context.WithValue(req.Context(), ApiKey, apiKey)
|
|
req = req.WithContext(ctx)
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
// Execute
|
|
authMiddleware.Handler(testHandler).ServeHTTP(rec, req)
|
|
|
|
// Assert
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
require.NotNil(t, capturedOrg)
|
|
assert.Equal(t, expectedOrg.Name, capturedOrg.Name)
|
|
assert.Equal(t, expectedOrg.ID.String(), capturedOrg.ID.String())
|
|
mockCache.AssertExpectations(t)
|
|
}
|
|
|
|
func TestAuthMiddleware_Handler_WithInvalidAPIKey(t *testing.T) {
|
|
// Setup
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
apiKey := "invalid-api-key"
|
|
|
|
// Mock expects plaintext key (cache handles hashing internally)
|
|
mockCache.On("OrganizationByAPIKey", apiKey).Return(nil)
|
|
|
|
// Create a test handler that checks the context
|
|
var capturedOrg *domain.Organization
|
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if org := r.Context().Value(OrganizationKey); org != nil {
|
|
if o, ok := org.(domain.Organization); ok {
|
|
capturedOrg = &o
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Create request with API key in context
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
ctx := context.WithValue(req.Context(), ApiKey, apiKey)
|
|
req = req.WithContext(ctx)
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
// Execute
|
|
authMiddleware.Handler(testHandler).ServeHTTP(rec, req)
|
|
|
|
// Assert
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Nil(t, capturedOrg, "Organization should not be set for invalid API key")
|
|
mockCache.AssertExpectations(t)
|
|
}
|
|
|
|
func TestAuthMiddleware_Handler_WithoutAPIKey(t *testing.T) {
|
|
// Setup
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
// The middleware passes the plaintext API key (cache handles hashing)
|
|
mockCache.On("OrganizationByAPIKey", "").Return(nil)
|
|
|
|
// Create a test handler that checks the context
|
|
var capturedOrg *domain.Organization
|
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if org := r.Context().Value(OrganizationKey); org != nil {
|
|
if o, ok := org.(domain.Organization); ok {
|
|
capturedOrg = &o
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Create request without API key
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
// Execute
|
|
authMiddleware.Handler(testHandler).ServeHTTP(rec, req)
|
|
|
|
// Assert
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Nil(t, capturedOrg, "Organization should not be set without API key")
|
|
mockCache.AssertExpectations(t)
|
|
}
|
|
|
|
func TestAuthMiddleware_Handler_WithValidJWT(t *testing.T) {
|
|
// Setup
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
// The middleware passes the plaintext API key (cache handles hashing)
|
|
mockCache.On("OrganizationByAPIKey", "").Return(nil)
|
|
|
|
userID := "user-123"
|
|
claims := &validator.ValidatedClaims{
|
|
RegisteredClaims: validator.RegisteredClaims{
|
|
Subject: userID,
|
|
},
|
|
}
|
|
|
|
// Create a test handler that checks the context
|
|
var capturedUser string
|
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if user := r.Context().Value(UserKey); user != nil {
|
|
if u, ok := user.(string); ok {
|
|
capturedUser = u
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Create request with JWT claims in context
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
ctx := core.SetClaims(req.Context(), claims)
|
|
req = req.WithContext(ctx)
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
// Execute
|
|
authMiddleware.Handler(testHandler).ServeHTTP(rec, req)
|
|
|
|
// Assert
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Equal(t, userID, capturedUser)
|
|
}
|
|
|
|
func TestAuthMiddleware_Handler_APIKeyErrorHandling(t *testing.T) {
|
|
// Setup
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Create request with invalid API key type in context
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
ctx := context.WithValue(req.Context(), ApiKey, 12345) // Invalid type
|
|
req = req.WithContext(ctx)
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
// Execute
|
|
authMiddleware.Handler(testHandler).ServeHTTP(rec, req)
|
|
|
|
// Assert
|
|
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), "Invalid API Key format")
|
|
}
|
|
|
|
func TestAuthMiddleware_Handler_JWTMissingClaims(t *testing.T) {
|
|
// Setup
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
// The middleware passes the plaintext API key (cache handles hashing)
|
|
mockCache.On("OrganizationByAPIKey", "").Return(nil)
|
|
|
|
// Create a test handler that checks the context
|
|
var capturedUser string
|
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if user := r.Context().Value(UserKey); user != nil {
|
|
if u, ok := user.(string); ok {
|
|
capturedUser = u
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Create request without JWT claims - user should not be set
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
// Execute
|
|
authMiddleware.Handler(testHandler).ServeHTTP(rec, req)
|
|
|
|
// Assert
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Empty(t, capturedUser, "User should not be set when no claims in context")
|
|
}
|
|
|
|
func TestAuthMiddleware_Handler_BothJWTAndAPIKey(t *testing.T) {
|
|
// Setup
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
orgID := uuid.New()
|
|
expectedOrg := &domain.Organization{
|
|
BaseAggregate: eventsourced.BaseAggregate{
|
|
ID: eventsourced.IdFromString(orgID.String()),
|
|
},
|
|
Name: "Test Organization",
|
|
}
|
|
|
|
userID := "user-123"
|
|
apiKey := "test-api-key-123"
|
|
|
|
claims := &validator.ValidatedClaims{
|
|
RegisteredClaims: validator.RegisteredClaims{
|
|
Subject: userID,
|
|
},
|
|
}
|
|
|
|
// Mock expects plaintext key (cache handles hashing internally)
|
|
mockCache.On("OrganizationByAPIKey", apiKey).Return(expectedOrg)
|
|
|
|
// Create a test handler that checks both user and organization in context
|
|
var capturedUser string
|
|
var capturedOrg *domain.Organization
|
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if user := r.Context().Value(UserKey); user != nil {
|
|
if u, ok := user.(string); ok {
|
|
capturedUser = u
|
|
}
|
|
}
|
|
if org := r.Context().Value(OrganizationKey); org != nil {
|
|
if o, ok := org.(domain.Organization); ok {
|
|
capturedOrg = &o
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Create request with both JWT claims and API key in context
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
ctx := core.SetClaims(req.Context(), claims)
|
|
ctx = context.WithValue(ctx, ApiKey, apiKey)
|
|
req = req.WithContext(ctx)
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
// Execute
|
|
authMiddleware.Handler(testHandler).ServeHTTP(rec, req)
|
|
|
|
// Assert
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Equal(t, userID, capturedUser)
|
|
require.NotNil(t, capturedOrg)
|
|
assert.Equal(t, expectedOrg.Name, capturedOrg.Name)
|
|
mockCache.AssertExpectations(t)
|
|
}
|
|
|
|
func TestUserFromContext(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ctx context.Context
|
|
expected string
|
|
}{
|
|
{
|
|
name: "with valid user",
|
|
ctx: context.WithValue(context.Background(), UserKey, "user-123"),
|
|
expected: "user-123",
|
|
},
|
|
{
|
|
name: "without user",
|
|
ctx: context.Background(),
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "with invalid type",
|
|
ctx: context.WithValue(context.Background(), UserKey, 123),
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := UserFromContext(tt.ctx)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOrganizationFromContext(t *testing.T) {
|
|
orgID := uuid.New()
|
|
org := domain.Organization{
|
|
BaseAggregate: eventsourced.BaseAggregate{
|
|
ID: eventsourced.IdFromString(orgID.String()),
|
|
},
|
|
Name: "Test Org",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
ctx context.Context
|
|
expected string
|
|
}{
|
|
{
|
|
name: "with valid organization",
|
|
ctx: context.WithValue(context.Background(), OrganizationKey, org),
|
|
expected: orgID.String(),
|
|
},
|
|
{
|
|
name: "without organization",
|
|
ctx: context.Background(),
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "with invalid type",
|
|
ctx: context.WithValue(context.Background(), OrganizationKey, "not-an-org"),
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := OrganizationFromContext(tt.ctx)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_Directive_RequiresUser(t *testing.T) {
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
requireUser := true
|
|
|
|
// Test with user present
|
|
ctx := context.WithValue(context.Background(), UserKey, "user-123")
|
|
_, err := authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
|
|
return "success", nil
|
|
}, &requireUser, nil)
|
|
assert.NoError(t, err)
|
|
|
|
// Test without user
|
|
ctx = context.Background()
|
|
_, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
|
|
return "success", nil
|
|
}, &requireUser, nil)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "no user available in request")
|
|
}
|
|
|
|
func TestAuthMiddleware_Directive_RequiresOrganization(t *testing.T) {
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
requireOrg := true
|
|
orgID := uuid.New()
|
|
org := domain.Organization{
|
|
BaseAggregate: eventsourced.BaseAggregate{
|
|
ID: eventsourced.IdFromString(orgID.String()),
|
|
},
|
|
Name: "Test Org",
|
|
}
|
|
|
|
// Test with organization present
|
|
ctx := context.WithValue(context.Background(), OrganizationKey, org)
|
|
_, err := authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
|
|
return "success", nil
|
|
}, nil, &requireOrg)
|
|
assert.NoError(t, err)
|
|
|
|
// Test without organization
|
|
ctx = context.Background()
|
|
_, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
|
|
return "success", nil
|
|
}, nil, &requireOrg)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "no organization available in request")
|
|
}
|
|
|
|
func TestAuthMiddleware_Directive_RequiresBoth(t *testing.T) {
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
requireUser := true
|
|
requireOrg := true
|
|
orgID := uuid.New()
|
|
org := domain.Organization{
|
|
BaseAggregate: eventsourced.BaseAggregate{
|
|
ID: eventsourced.IdFromString(orgID.String()),
|
|
},
|
|
Name: "Test Org",
|
|
}
|
|
|
|
// When both user and organization are marked as acceptable,
|
|
// the directive uses OR logic - either one is sufficient
|
|
|
|
// Test with both present - should succeed
|
|
ctx := context.WithValue(context.Background(), UserKey, "user-123")
|
|
ctx = context.WithValue(ctx, OrganizationKey, org)
|
|
_, err := authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
|
|
return "success", nil
|
|
}, &requireUser, &requireOrg)
|
|
assert.NoError(t, err)
|
|
|
|
// Test with only user - should succeed (OR logic)
|
|
ctx = context.WithValue(context.Background(), UserKey, "user-123")
|
|
_, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
|
|
return "success", nil
|
|
}, &requireUser, &requireOrg)
|
|
assert.NoError(t, err)
|
|
|
|
// Test with only organization - should succeed (OR logic)
|
|
ctx = context.WithValue(context.Background(), OrganizationKey, org)
|
|
_, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
|
|
return "success", nil
|
|
}, &requireUser, &requireOrg)
|
|
assert.NoError(t, err)
|
|
|
|
// Test with neither - should fail
|
|
ctx = context.Background()
|
|
_, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
|
|
return "success", nil
|
|
}, &requireUser, &requireOrg)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "authentication required")
|
|
}
|
|
|
|
func TestAuthMiddleware_Directive_NoRequirements(t *testing.T) {
|
|
mockCache := new(MockCache)
|
|
authMiddleware := NewAuth(mockCache)
|
|
|
|
// Test with no requirements
|
|
ctx := context.Background()
|
|
result, err := authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
|
|
return "success", nil
|
|
}, nil, nil)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "success", result)
|
|
}
|
|
|
|
func TestUserHasRole_WithValidRole(t *testing.T) {
|
|
// Create claims with roles
|
|
claims := &validator.ValidatedClaims{
|
|
RegisteredClaims: validator.RegisteredClaims{
|
|
Subject: "user-123",
|
|
},
|
|
CustomClaims: &CustomClaims{
|
|
Roles: []string{"admin", "user"},
|
|
},
|
|
}
|
|
|
|
ctx := core.SetClaims(context.Background(), claims)
|
|
|
|
// Test for existing role
|
|
hasRole := UserHasRole(ctx, "admin")
|
|
assert.True(t, hasRole)
|
|
|
|
hasRole = UserHasRole(ctx, "user")
|
|
assert.True(t, hasRole)
|
|
}
|
|
|
|
func TestUserHasRole_WithoutRole(t *testing.T) {
|
|
// Create claims with roles
|
|
claims := &validator.ValidatedClaims{
|
|
RegisteredClaims: validator.RegisteredClaims{
|
|
Subject: "user-123",
|
|
},
|
|
CustomClaims: &CustomClaims{
|
|
Roles: []string{"user"},
|
|
},
|
|
}
|
|
|
|
ctx := core.SetClaims(context.Background(), claims)
|
|
|
|
// Test for non-existing role
|
|
hasRole := UserHasRole(ctx, "admin")
|
|
assert.False(t, hasRole)
|
|
}
|
|
|
|
func TestUserHasRole_WithoutRolesClaim(t *testing.T) {
|
|
// Create claims without custom claims
|
|
claims := &validator.ValidatedClaims{
|
|
RegisteredClaims: validator.RegisteredClaims{
|
|
Subject: "user-123",
|
|
},
|
|
}
|
|
|
|
ctx := core.SetClaims(context.Background(), claims)
|
|
|
|
// Test should return false when custom claims is missing
|
|
hasRole := UserHasRole(ctx, "admin")
|
|
assert.False(t, hasRole)
|
|
}
|
|
|
|
func TestUserHasRole_WithoutClaims(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Test should return false when no claims in context
|
|
hasRole := UserHasRole(ctx, "admin")
|
|
assert.False(t, hasRole)
|
|
}
|
|
|
|
func TestUserHasRole_WithEmptyRoles(t *testing.T) {
|
|
// Create claims with empty roles
|
|
claims := &validator.ValidatedClaims{
|
|
RegisteredClaims: validator.RegisteredClaims{
|
|
Subject: "user-123",
|
|
},
|
|
CustomClaims: &CustomClaims{
|
|
Roles: []string{},
|
|
},
|
|
}
|
|
|
|
ctx := core.SetClaims(context.Background(), claims)
|
|
|
|
// Test should return false when roles array is empty
|
|
hasRole := UserHasRole(ctx, "admin")
|
|
assert.False(t, hasRole)
|
|
}
|