Cleanup [dec2025] (#101)

* Cleanup excessive comments.

* Remove leftovers hanging around from previous refactor

* Improve test coverage
This commit is contained in:
2025-12-09 01:38:02 +00:00
committed by GitHub
parent 9126c74723
commit c474bbafd6
172 changed files with 25136 additions and 52201 deletions
+140
View File
@@ -0,0 +1,140 @@
package testutil
import (
"time"
"github.com/lukaszraczylo/traefikoidc/internal/testutil/fixtures"
"github.com/lukaszraczylo/traefikoidc/internal/testutil/mocks"
"github.com/lukaszraczylo/traefikoidc/internal/testutil/servers"
)
// Re-export types for easier access from main package tests
type (
// Mocks
JWKCacheMock = mocks.JWKCache
TokenExchangerMock = mocks.TokenExchanger
TokenVerifierMock = mocks.TokenVerifier
JWTVerifierMock = mocks.JWTVerifier
SessionManagerMock = mocks.SessionManager
CacheMock = mocks.Cache
TokenCacheMock = mocks.TokenCache
BlacklistMock = mocks.Blacklist
HTTPClientMock = mocks.HTTPClient
RoundTripperMock = mocks.RoundTripper
LoggerMock = mocks.Logger
// Mock types
JWKSet = mocks.JWKSet
JWK = mocks.JWK
MockTokenResponse = mocks.TokenResponse
MockSessionData = mocks.SessionData
IntrospectionResp = mocks.IntrospectionResponse
// Fixtures
TokenFixture = fixtures.TokenFixture
// Servers
OIDCServer = servers.OIDCServer
OIDCServerConfig = servers.OIDCServerConfig
OIDCError = servers.OIDCError
)
// NewJWKCacheMock creates a new JWK cache mock
func NewJWKCacheMock() *mocks.JWKCache {
return new(mocks.JWKCache)
}
// NewTokenExchangerMock creates a new token exchanger mock
func NewTokenExchangerMock() *mocks.TokenExchanger {
return new(mocks.TokenExchanger)
}
// NewTokenVerifierMock creates a new token verifier mock
func NewTokenVerifierMock() *mocks.TokenVerifier {
return new(mocks.TokenVerifier)
}
// NewJWTVerifierMock creates a new JWT verifier mock
func NewJWTVerifierMock() *mocks.JWTVerifier {
return new(mocks.JWTVerifier)
}
// NewSessionManagerMock creates a new session manager mock
func NewSessionManagerMock() *mocks.SessionManager {
return new(mocks.SessionManager)
}
// NewCacheMock creates a new cache mock
func NewCacheMock() *mocks.Cache {
return new(mocks.Cache)
}
// NewTokenCacheMock creates a new token cache mock
func NewTokenCacheMock() *mocks.TokenCache {
return new(mocks.TokenCache)
}
// NewBlacklistMock creates a new blacklist mock
func NewBlacklistMock() *mocks.Blacklist {
return new(mocks.Blacklist)
}
// NewHTTPClientMock creates a new HTTP client mock
func NewHTTPClientMock() *mocks.HTTPClient {
return new(mocks.HTTPClient)
}
// NewRoundTripperMock creates a new round tripper mock
func NewRoundTripperMock() *mocks.RoundTripper {
return new(mocks.RoundTripper)
}
// NewLoggerMock creates a new logger mock
func NewLoggerMock() *mocks.Logger {
return new(mocks.Logger)
}
// NewTokenFixture creates a new token fixture
func NewTokenFixture() (*fixtures.TokenFixture, error) {
return fixtures.NewTokenFixture()
}
// NewOIDCServer creates a new mock OIDC server
func NewOIDCServer(config *servers.OIDCServerConfig) *servers.OIDCServer {
return servers.NewOIDCServer(config)
}
// DefaultServerConfig returns a default server configuration
func DefaultServerConfig() *servers.OIDCServerConfig {
return servers.DefaultConfig()
}
// GoogleServerConfig returns a Google-like server configuration
func GoogleServerConfig() *servers.OIDCServerConfig {
return servers.GoogleConfig()
}
// AzureServerConfig returns an Azure AD-like server configuration
func AzureServerConfig() *servers.OIDCServerConfig {
return servers.AzureConfig()
}
// Auth0ServerConfig returns an Auth0-like server configuration
func Auth0ServerConfig() *servers.OIDCServerConfig {
return servers.Auth0Config()
}
// KeycloakServerConfig returns a Keycloak-like server configuration
func KeycloakServerConfig() *servers.OIDCServerConfig {
return servers.KeycloakConfig()
}
// SlowServerConfig returns a configuration with delays
func SlowServerConfig(delay time.Duration) *servers.OIDCServerConfig {
return servers.SlowServerConfig(delay)
}
// RateLimitedServerConfig returns a rate-limited configuration
func RateLimitedServerConfig(afterN int) *servers.OIDCServerConfig {
return servers.RateLimitedConfig(afterN)
}
+330
View File
@@ -0,0 +1,330 @@
package fixtures
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"time"
)
// TokenFixture provides JWT token generation for tests
type TokenFixture struct {
RSAPrivateKey *rsa.PrivateKey
RSAPublicKey *rsa.PublicKey
ECPrivateKey *ecdsa.PrivateKey
ECPublicKey *ecdsa.PublicKey
KeyID string
Issuer string
Audience string
ClockSkew time.Duration
}
// NewTokenFixture creates a new token fixture with generated keys
func NewTokenFixture() (*TokenFixture, error) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
return &TokenFixture{
RSAPrivateKey: rsaKey,
RSAPublicKey: &rsaKey.PublicKey,
ECPrivateKey: ecKey,
ECPublicKey: &ecKey.PublicKey,
KeyID: "test-key-id",
Issuer: "https://test-issuer.com",
Audience: "test-client-id",
ClockSkew: 2 * time.Minute,
}, nil
}
// DefaultClaims returns standard JWT claims
func (f *TokenFixture) DefaultClaims() map[string]interface{} {
now := time.Now()
return map[string]interface{}{
"iss": f.Issuer,
"aud": f.Audience,
"sub": "test-subject",
"email": "[email protected]",
"exp": now.Add(1 * time.Hour).Unix(),
"iat": now.Add(-f.ClockSkew).Unix(),
"nbf": now.Add(-f.ClockSkew).Unix(),
"nonce": "test-nonce",
"jti": generateJTI(),
}
}
// ValidToken creates a valid JWT token with optional claim overrides
func (f *TokenFixture) ValidToken(claimOverrides map[string]interface{}) (string, error) {
claims := f.DefaultClaims()
for k, v := range claimOverrides {
claims[k] = v
}
return f.createJWT(claims, "RS256", f.KeyID)
}
// ExpiredToken creates an expired JWT token
func (f *TokenFixture) ExpiredToken() (string, error) {
claims := f.DefaultClaims()
claims["exp"] = time.Now().Add(-1 * time.Hour).Unix()
return f.createJWT(claims, "RS256", f.KeyID)
}
// NotYetValidToken creates a token that's not valid yet
func (f *TokenFixture) NotYetValidToken() (string, error) {
claims := f.DefaultClaims()
claims["nbf"] = time.Now().Add(1 * time.Hour).Unix()
return f.createJWT(claims, "RS256", f.KeyID)
}
// TokenWithSkew creates a token with a specific time offset
func (f *TokenFixture) TokenWithSkew(skew time.Duration) (string, error) {
claims := f.DefaultClaims()
claims["exp"] = time.Now().Add(skew).Unix()
return f.createJWT(claims, "RS256", f.KeyID)
}
// TokenWithRoles creates a token with specific roles
func (f *TokenFixture) TokenWithRoles(roles []string) (string, error) {
claims := f.DefaultClaims()
claims["roles"] = roles
return f.createJWT(claims, "RS256", f.KeyID)
}
// TokenWithGroups creates a token with specific groups
func (f *TokenFixture) TokenWithGroups(groups []string) (string, error) {
claims := f.DefaultClaims()
claims["groups"] = groups
return f.createJWT(claims, "RS256", f.KeyID)
}
// TokenWithEmail creates a token with a specific email
func (f *TokenFixture) TokenWithEmail(email string) (string, error) {
claims := f.DefaultClaims()
claims["email"] = email
return f.createJWT(claims, "RS256", f.KeyID)
}
// TokenWithAudience creates a token with a specific audience
func (f *TokenFixture) TokenWithAudience(audience string) (string, error) {
claims := f.DefaultClaims()
claims["aud"] = audience
return f.createJWT(claims, "RS256", f.KeyID)
}
// TokenWithIssuer creates a token with a specific issuer
func (f *TokenFixture) TokenWithIssuer(issuer string) (string, error) {
claims := f.DefaultClaims()
claims["iss"] = issuer
return f.createJWT(claims, "RS256", f.KeyID)
}
// TokenMissingClaim creates a token missing specified claims
func (f *TokenFixture) TokenMissingClaim(missingClaims ...string) (string, error) {
claims := f.DefaultClaims()
for _, claim := range missingClaims {
delete(claims, claim)
}
return f.createJWT(claims, "RS256", f.KeyID)
}
// TokenWithCustomClaims creates a token with custom claims
func (f *TokenFixture) TokenWithCustomClaims(customClaims map[string]interface{}) (string, error) {
claims := f.DefaultClaims()
for k, v := range customClaims {
claims[k] = v
}
return f.createJWT(claims, "RS256", f.KeyID)
}
// MalformedToken returns an invalid JWT string
func (f *TokenFixture) MalformedToken() string {
return "not.a.valid.jwt"
}
// EmptyToken returns an empty string
func (f *TokenFixture) EmptyToken() string {
return ""
}
// TokenWithWrongSignature creates a token signed with a different key
func (f *TokenFixture) TokenWithWrongSignature() (string, error) {
wrongKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", err
}
claims := f.DefaultClaims()
return createJWTWithKey(claims, "RS256", f.KeyID, wrongKey)
}
// TokenWithWrongAlgorithm creates a token with mismatched algorithm
func (f *TokenFixture) TokenWithWrongAlgorithm() (string, error) {
claims := f.DefaultClaims()
// Create token claiming RS256 but we'll return it as-is
// This simulates algorithm confusion attacks
return f.createJWT(claims, "none", f.KeyID)
}
// ECToken creates a token signed with EC key
func (f *TokenFixture) ECToken() (string, error) {
claims := f.DefaultClaims()
return f.createECJWT(claims, "ES256", f.KeyID)
}
// GetJWKS returns a JWKS containing the test public key
func (f *TokenFixture) GetJWKS() map[string]interface{} {
return map[string]interface{}{
"keys": []map[string]interface{}{
{
"kty": "RSA",
"kid": f.KeyID,
"use": "sig",
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(f.RSAPublicKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(f.RSAPublicKey.E)))),
},
},
}
}
// GetJWKSBytes returns JWKS as JSON bytes
func (f *TokenFixture) GetJWKSBytes() ([]byte, error) {
return json.Marshal(f.GetJWKS())
}
// createJWT creates a JWT with the fixture's RSA key
func (f *TokenFixture) createJWT(claims map[string]interface{}, alg, kid string) (string, error) {
return createJWTWithKey(claims, alg, kid, f.RSAPrivateKey)
}
// createECJWT creates a JWT with the fixture's EC key
func (f *TokenFixture) createECJWT(claims map[string]interface{}, alg, kid string) (string, error) {
return createECJWTWithKey(claims, alg, kid, f.ECPrivateKey)
}
// Helper functions
func generateJTI() string {
b := make([]byte, 16)
_, _ = rand.Read(b) // #nosec G104 - test fixture, crypto strength not critical
return base64.RawURLEncoding.EncodeToString(b)
}
func bigIntToBytes(i *big.Int) []byte {
return i.Bytes()
}
func createJWTWithKey(claims map[string]interface{}, alg, kid string, key *rsa.PrivateKey) (string, error) {
header := map[string]interface{}{
"alg": alg,
"typ": "JWT",
"kid": kid,
}
headerBytes, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsBytes, err := json.Marshal(claims)
if err != nil {
return "", err
}
headerB64 := base64.RawURLEncoding.EncodeToString(headerBytes)
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsBytes)
signingInput := headerB64 + "." + claimsB64
// For "none" algorithm, return without signature
if alg == "none" {
return signingInput + ".", nil
}
// Sign with RSA-SHA256
signature, err := signRS256([]byte(signingInput), key)
if err != nil {
return "", err
}
signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
return signingInput + "." + signatureB64, nil
}
func createECJWTWithKey(claims map[string]interface{}, alg, kid string, key *ecdsa.PrivateKey) (string, error) {
header := map[string]interface{}{
"alg": alg,
"typ": "JWT",
"kid": kid,
}
headerBytes, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsBytes, err := json.Marshal(claims)
if err != nil {
return "", err
}
headerB64 := base64.RawURLEncoding.EncodeToString(headerBytes)
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsBytes)
signingInput := headerB64 + "." + claimsB64
// Sign with ECDSA-SHA256
signature, err := signES256([]byte(signingInput), key)
if err != nil {
return "", err
}
signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
return signingInput + "." + signatureB64, nil
}
func signRS256(data []byte, key *rsa.PrivateKey) ([]byte, error) {
h := hashSHA256(data)
return rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, h)
}
func signES256(data []byte, key *ecdsa.PrivateKey) ([]byte, error) {
h := hashSHA256(data)
r, s, err := ecdsa.Sign(rand.Reader, key, h)
if err != nil {
return nil, err
}
// Encode r and s as fixed-size byte arrays
curveBits := key.Curve.Params().BitSize
keyBytes := curveBits / 8
if curveBits%8 > 0 {
keyBytes++
}
rBytes := r.Bytes()
sBytes := s.Bytes()
signature := make([]byte, 2*keyBytes)
copy(signature[keyBytes-len(rBytes):keyBytes], rBytes)
copy(signature[2*keyBytes-len(sBytes):], sBytes)
return signature, nil
}
func hashSHA256(data []byte) []byte {
h := sha256.Sum256(data)
return h[:]
}
+244
View File
@@ -0,0 +1,244 @@
package fixtures
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewTokenFixture(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
assert.NotNil(t, fixture.RSAPrivateKey)
assert.NotNil(t, fixture.RSAPublicKey)
assert.NotNil(t, fixture.ECPrivateKey)
assert.NotNil(t, fixture.ECPublicKey)
assert.NotEmpty(t, fixture.KeyID)
assert.NotEmpty(t, fixture.Issuer)
assert.NotEmpty(t, fixture.Audience)
}
func TestDefaultClaims(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
claims := fixture.DefaultClaims()
assert.Equal(t, fixture.Issuer, claims["iss"])
assert.Equal(t, fixture.Audience, claims["aud"])
assert.NotEmpty(t, claims["sub"])
assert.NotEmpty(t, claims["email"])
assert.NotNil(t, claims["exp"])
assert.NotNil(t, claims["iat"])
assert.NotNil(t, claims["nbf"])
assert.NotEmpty(t, claims["jti"])
}
func TestValidToken(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
t.Run("creates valid JWT structure", func(t *testing.T) {
token, err := fixture.ValidToken(nil)
require.NoError(t, err)
assert.NotEmpty(t, token)
// JWT has 3 parts
parts := strings.Split(token, ".")
assert.Len(t, parts, 3)
})
t.Run("applies claim overrides", func(t *testing.T) {
token, err := fixture.ValidToken(map[string]interface{}{
"email": "[email protected]",
})
require.NoError(t, err)
assert.NotEmpty(t, token)
})
}
func TestExpiredToken(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token, err := fixture.ExpiredToken()
require.NoError(t, err)
assert.NotEmpty(t, token)
parts := strings.Split(token, ".")
assert.Len(t, parts, 3)
}
func TestNotYetValidToken(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token, err := fixture.NotYetValidToken()
require.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestTokenWithSkew(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
t.Run("positive skew", func(t *testing.T) {
token, err := fixture.TokenWithSkew(5 * time.Minute)
require.NoError(t, err)
assert.NotEmpty(t, token)
})
t.Run("negative skew", func(t *testing.T) {
token, err := fixture.TokenWithSkew(-5 * time.Minute)
require.NoError(t, err)
assert.NotEmpty(t, token)
})
}
func TestTokenWithRoles(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token, err := fixture.TokenWithRoles([]string{"admin", "user"})
require.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestTokenWithGroups(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token, err := fixture.TokenWithGroups([]string{"developers", "admins"})
require.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestTokenWithEmail(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token, err := fixture.TokenWithEmail("[email protected]")
require.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestTokenWithAudience(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token, err := fixture.TokenWithAudience("custom-audience")
require.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestTokenWithIssuer(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token, err := fixture.TokenWithIssuer("https://custom-issuer.com")
require.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestTokenMissingClaim(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
t.Run("missing single claim", func(t *testing.T) {
token, err := fixture.TokenMissingClaim("email")
require.NoError(t, err)
assert.NotEmpty(t, token)
})
t.Run("missing multiple claims", func(t *testing.T) {
token, err := fixture.TokenMissingClaim("email", "sub", "nonce")
require.NoError(t, err)
assert.NotEmpty(t, token)
})
}
func TestTokenWithCustomClaims(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token, err := fixture.TokenWithCustomClaims(map[string]interface{}{
"custom_claim": "custom_value",
"another_claim": 123,
})
require.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestMalformedToken(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token := fixture.MalformedToken()
assert.Equal(t, "not.a.valid.jwt", token)
parts := strings.Split(token, ".")
assert.Len(t, parts, 4) // 4 parts instead of 3
}
func TestEmptyToken(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token := fixture.EmptyToken()
assert.Empty(t, token)
}
func TestTokenWithWrongSignature(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
token, err := fixture.TokenWithWrongSignature()
require.NoError(t, err)
assert.NotEmpty(t, token)
parts := strings.Split(token, ".")
assert.Len(t, parts, 3)
}
func TestGetJWKS(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
jwks := fixture.GetJWKS()
assert.Contains(t, jwks, "keys")
keys, ok := jwks["keys"].([]map[string]interface{})
require.True(t, ok)
assert.Len(t, keys, 1)
key := keys[0]
assert.Equal(t, "RSA", key["kty"])
assert.Equal(t, fixture.KeyID, key["kid"])
assert.NotEmpty(t, key["n"])
assert.NotEmpty(t, key["e"])
}
func TestGetJWKSBytes(t *testing.T) {
fixture, err := NewTokenFixture()
require.NoError(t, err)
jwksBytes, err := fixture.GetJWKSBytes()
require.NoError(t, err)
assert.NotEmpty(t, jwksBytes)
assert.Contains(t, string(jwksBytes), "keys")
}
+108
View File
@@ -0,0 +1,108 @@
package mocks
import (
"time"
"github.com/stretchr/testify/mock"
)
// Cache is a testify mock for cache operations
type Cache struct {
mock.Mock
}
// Get retrieves a value from the cache
func (m *Cache) Get(key string) (interface{}, bool) {
args := m.Called(key)
return args.Get(0), args.Bool(1)
}
// Set stores a value in the cache
func (m *Cache) Set(key string, value interface{}) {
m.Called(key, value)
}
// SetWithTTL stores a value with a specific TTL
func (m *Cache) SetWithTTL(key string, value interface{}, ttl time.Duration) {
m.Called(key, value, ttl)
}
// Delete removes a value from the cache
func (m *Cache) Delete(key string) {
m.Called(key)
}
// Has checks if a key exists in the cache
func (m *Cache) Has(key string) bool {
args := m.Called(key)
return args.Bool(0)
}
// Clear removes all entries from the cache
func (m *Cache) Clear() {
m.Called()
}
// Close closes the cache
func (m *Cache) Close() {
m.Called()
}
// Size returns the number of items in the cache
func (m *Cache) Size() int {
args := m.Called()
return args.Int(0)
}
// TokenCache is a testify mock for token-specific cache operations
type TokenCache struct {
mock.Mock
}
// Get retrieves a token from the cache
func (m *TokenCache) Get(key string) (string, bool) {
args := m.Called(key)
return args.String(0), args.Bool(1)
}
// Set stores a token in the cache
func (m *TokenCache) Set(key string, token string, ttl time.Duration) {
m.Called(key, token, ttl)
}
// Delete removes a token from the cache
func (m *TokenCache) Delete(key string) {
m.Called(key)
}
// Has checks if a token exists in the cache
func (m *TokenCache) Has(key string) bool {
args := m.Called(key)
return args.Bool(0)
}
// Blacklist is a testify mock for token blacklist operations
type Blacklist struct {
mock.Mock
}
// IsBlacklisted checks if a token is blacklisted
func (m *Blacklist) IsBlacklisted(jti string) bool {
args := m.Called(jti)
return args.Bool(0)
}
// Add adds a token to the blacklist
func (m *Blacklist) Add(jti string, expiry time.Time) {
m.Called(jti, expiry)
}
// Remove removes a token from the blacklist
func (m *Blacklist) Remove(jti string) {
m.Called(jti)
}
// Cleanup removes expired entries from the blacklist
func (m *Blacklist) Cleanup() {
m.Called()
}
+203
View File
@@ -0,0 +1,203 @@
package mocks
import (
"context"
"net/http"
"github.com/stretchr/testify/mock"
)
// JWKSet represents a JSON Web Key Set for testing
type JWKSet struct {
Keys []JWK `json:"keys"`
}
// JWK represents a JSON Web Key for testing
type JWK struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Use string `json:"use,omitempty"`
Alg string `json:"alg,omitempty"`
N string `json:"n,omitempty"`
E string `json:"e,omitempty"`
Crv string `json:"crv,omitempty"`
X string `json:"x,omitempty"`
Y string `json:"y,omitempty"`
}
// TokenResponse represents an OAuth token response for testing
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
IDToken string `json:"id_token,omitempty"`
Scope string `json:"scope,omitempty"`
}
// IntrospectionResponse represents a token introspection response
type IntrospectionResponse struct {
Active bool `json:"active"`
Scope string `json:"scope,omitempty"`
ClientID string `json:"client_id,omitempty"`
Username string `json:"username,omitempty"`
TokenType string `json:"token_type,omitempty"`
Exp int64 `json:"exp,omitempty"`
Iat int64 `json:"iat,omitempty"`
Nbf int64 `json:"nbf,omitempty"`
Sub string `json:"sub,omitempty"`
Aud string `json:"aud,omitempty"`
Iss string `json:"iss,omitempty"`
Jti string `json:"jti,omitempty"`
}
// JWKCache is a testify mock for JWK caching operations
type JWKCache struct {
mock.Mock
}
// GetJWKS retrieves a JWKS from the cache or fetches it
func (m *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
args := m.Called(ctx, jwksURL, httpClient)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*JWKSet), args.Error(1)
}
// Close cleans up the cache
func (m *JWKCache) Close() {
m.Called()
}
// Cleanup performs periodic cleanup
func (m *JWKCache) Cleanup() {
m.Called()
}
// TokenExchanger is a testify mock for token exchange operations
type TokenExchanger struct {
mock.Mock
}
// ExchangeCodeForToken exchanges an authorization code for tokens
func (m *TokenExchanger) ExchangeCodeForToken(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) {
args := m.Called(ctx, grantType, codeOrToken, redirectURL, codeVerifier)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*TokenResponse), args.Error(1)
}
// GetNewTokenWithRefreshToken refreshes an access token
func (m *TokenExchanger) GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
args := m.Called(refreshToken)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*TokenResponse), args.Error(1)
}
// RevokeTokenWithProvider revokes a token
func (m *TokenExchanger) RevokeTokenWithProvider(token, tokenType string) error {
args := m.Called(token, tokenType)
return args.Error(0)
}
// TokenVerifier is a testify mock for token verification
type TokenVerifier struct {
mock.Mock
}
// VerifyToken verifies a JWT token
func (m *TokenVerifier) VerifyToken(token string) error {
args := m.Called(token)
return args.Error(0)
}
// JWTVerifier is a testify mock for JWT verification
type JWTVerifier struct {
mock.Mock
}
// VerifyJWT verifies a JWT and returns claims
func (m *JWTVerifier) VerifyJWT(token string) (map[string]interface{}, error) {
args := m.Called(token)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]interface{}), args.Error(1)
}
// HTTPClient is a testify mock for HTTP client operations
type HTTPClient struct {
mock.Mock
}
// Do executes an HTTP request
func (m *HTTPClient) Do(req *http.Request) (*http.Response, error) {
args := m.Called(req)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*http.Response), args.Error(1)
}
// RoundTripper is a testify mock for HTTP transport
type RoundTripper struct {
mock.Mock
}
// RoundTrip executes a single HTTP transaction
func (m *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
args := m.Called(req)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*http.Response), args.Error(1)
}
// Logger is a testify mock for logging operations
type Logger struct {
mock.Mock
}
// Debug logs a debug message
func (m *Logger) Debug(msg string) {
m.Called(msg)
}
// Debugf logs a formatted debug message
func (m *Logger) Debugf(format string, args ...interface{}) {
m.Called(format, args)
}
// Info logs an info message
func (m *Logger) Info(msg string) {
m.Called(msg)
}
// Infof logs a formatted info message
func (m *Logger) Infof(format string, args ...interface{}) {
m.Called(format, args)
}
// Error logs an error message
func (m *Logger) Error(msg string) {
m.Called(msg)
}
// Errorf logs a formatted error message
func (m *Logger) Errorf(format string, args ...interface{}) {
m.Called(format, args)
}
// Warn logs a warning message
func (m *Logger) Warn(msg string) {
m.Called(msg)
}
// Warnf logs a formatted warning message
func (m *Logger) Warnf(format string, args ...interface{}) {
m.Called(format, args)
}
+255
View File
@@ -0,0 +1,255 @@
package mocks
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestJWKCache(t *testing.T) {
t.Run("GetJWKS returns configured response", func(t *testing.T) {
m := new(JWKCache)
expectedJWKS := &JWKSet{
Keys: []JWK{{Kty: "RSA", Kid: "test-key"}},
}
m.On("GetJWKS", mock.Anything, "https://example.com/jwks", mock.Anything).
Return(expectedJWKS, nil)
result, err := m.GetJWKS(context.Background(), "https://example.com/jwks", nil)
assert.NoError(t, err)
assert.Equal(t, expectedJWKS, result)
m.AssertExpectations(t)
})
t.Run("GetJWKS returns error", func(t *testing.T) {
m := new(JWKCache)
expectedErr := errors.New("network error")
m.On("GetJWKS", mock.Anything, mock.Anything, mock.Anything).
Return(nil, expectedErr)
result, err := m.GetJWKS(context.Background(), "https://example.com/jwks", nil)
assert.Nil(t, result)
assert.Equal(t, expectedErr, err)
m.AssertExpectations(t)
})
t.Run("Close is callable", func(t *testing.T) {
m := new(JWKCache)
m.On("Close").Return()
m.Close()
m.AssertExpectations(t)
})
}
func TestTokenExchanger(t *testing.T) {
t.Run("ExchangeCodeForToken success", func(t *testing.T) {
m := new(TokenExchanger)
expectedResp := &TokenResponse{
AccessToken: "access-token",
RefreshToken: "refresh-token",
IDToken: "id-token",
ExpiresIn: 3600,
}
m.On("ExchangeCodeForToken", mock.Anything, "authorization_code", "test-code", "https://example.com/callback", "verifier").
Return(expectedResp, nil)
result, err := m.ExchangeCodeForToken(context.Background(), "authorization_code", "test-code", "https://example.com/callback", "verifier")
assert.NoError(t, err)
assert.Equal(t, expectedResp, result)
m.AssertExpectations(t)
})
t.Run("RefreshToken success", func(t *testing.T) {
m := new(TokenExchanger)
expectedResp := &TokenResponse{
AccessToken: "new-access-token",
ExpiresIn: 3600,
}
m.On("GetNewTokenWithRefreshToken", "refresh-token").
Return(expectedResp, nil)
result, err := m.GetNewTokenWithRefreshToken("refresh-token")
assert.NoError(t, err)
assert.Equal(t, expectedResp, result)
m.AssertExpectations(t)
})
t.Run("RevokeToken success", func(t *testing.T) {
m := new(TokenExchanger)
m.On("RevokeTokenWithProvider", "token", "access_token").Return(nil)
err := m.RevokeTokenWithProvider("token", "access_token")
assert.NoError(t, err)
m.AssertExpectations(t)
})
}
func TestTokenVerifier(t *testing.T) {
t.Run("VerifyToken success", func(t *testing.T) {
m := new(TokenVerifier)
m.On("VerifyToken", "valid-token").Return(nil)
err := m.VerifyToken("valid-token")
assert.NoError(t, err)
m.AssertExpectations(t)
})
t.Run("VerifyToken failure", func(t *testing.T) {
m := new(TokenVerifier)
expectedErr := errors.New("token expired")
m.On("VerifyToken", "expired-token").Return(expectedErr)
err := m.VerifyToken("expired-token")
assert.Equal(t, expectedErr, err)
m.AssertExpectations(t)
})
}
func TestSessionManager(t *testing.T) {
t.Run("GetSession returns session", func(t *testing.T) {
m := new(SessionManager)
expectedSession := &SessionData{
Email: "[email protected]",
AccessToken: "access-token",
}
m.On("GetSession", mock.AnythingOfType("*http.Request")).
Return(expectedSession, nil)
req, _ := http.NewRequest("GET", "/", nil)
result, err := m.GetSession(req)
assert.NoError(t, err)
assert.Equal(t, expectedSession, result)
m.AssertExpectations(t)
})
t.Run("SaveSession succeeds", func(t *testing.T) {
m := new(SessionManager)
session := &SessionData{Email: "[email protected]"}
m.On("SaveSession", mock.Anything, mock.Anything, session).Return(nil)
req, _ := http.NewRequest("GET", "/", nil)
err := m.SaveSession(req, nil, session)
assert.NoError(t, err)
m.AssertExpectations(t)
})
t.Run("DeleteSession succeeds", func(t *testing.T) {
m := new(SessionManager)
m.On("DeleteSession", mock.Anything, mock.Anything).Return(nil)
req, _ := http.NewRequest("GET", "/", nil)
err := m.DeleteSession(req, nil)
assert.NoError(t, err)
m.AssertExpectations(t)
})
}
func TestCache(t *testing.T) {
t.Run("Get returns value", func(t *testing.T) {
m := new(Cache)
m.On("Get", "key").Return("value", true)
result, found := m.Get("key")
assert.True(t, found)
assert.Equal(t, "value", result)
m.AssertExpectations(t)
})
t.Run("Get returns not found", func(t *testing.T) {
m := new(Cache)
m.On("Get", "missing").Return(nil, false)
result, found := m.Get("missing")
assert.False(t, found)
assert.Nil(t, result)
m.AssertExpectations(t)
})
t.Run("SetWithTTL is callable", func(t *testing.T) {
m := new(Cache)
m.On("SetWithTTL", "key", "value", 5*time.Minute).Return()
m.SetWithTTL("key", "value", 5*time.Minute)
m.AssertExpectations(t)
})
t.Run("Delete is callable", func(t *testing.T) {
m := new(Cache)
m.On("Delete", "key").Return()
m.Delete("key")
m.AssertExpectations(t)
})
}
func TestHTTPClient(t *testing.T) {
t.Run("Do returns response", func(t *testing.T) {
m := new(HTTPClient)
expectedResp := &http.Response{StatusCode: 200}
m.On("Do", mock.AnythingOfType("*http.Request")).Return(expectedResp, nil)
req, _ := http.NewRequest("GET", "https://example.com", nil)
result, err := m.Do(req)
assert.NoError(t, err)
assert.Equal(t, 200, result.StatusCode)
m.AssertExpectations(t)
})
t.Run("Do returns error", func(t *testing.T) {
m := new(HTTPClient)
expectedErr := errors.New("connection refused")
m.On("Do", mock.Anything).Return(nil, expectedErr)
req, _ := http.NewRequest("GET", "https://example.com", nil)
result, err := m.Do(req)
assert.Nil(t, result)
assert.Equal(t, expectedErr, err)
m.AssertExpectations(t)
})
}
func TestLogger(t *testing.T) {
t.Run("Debug is callable", func(t *testing.T) {
m := new(Logger)
m.On("Debug", "test message").Return()
m.Debug("test message")
m.AssertExpectations(t)
})
t.Run("Error is callable", func(t *testing.T) {
m := new(Logger)
m.On("Error", "error message").Return()
m.Error("error message")
m.AssertExpectations(t)
})
}
+94
View File
@@ -0,0 +1,94 @@
package mocks
import (
"net/http"
"github.com/stretchr/testify/mock"
)
// SessionData represents session data for testing
type SessionData struct {
Email string
AccessToken string
RefreshToken string
IDToken string
Expiry int64
Nonce string
State string
CodeVerifier string
RedirectURL string
Claims map[string]interface{}
}
// SessionManager is a testify mock for session management
type SessionManager struct {
mock.Mock
}
// GetSession retrieves a session from the request
func (m *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
args := m.Called(r)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*SessionData), args.Error(1)
}
// SaveSession saves a session to the response
func (m *SessionManager) SaveSession(r *http.Request, w http.ResponseWriter, session *SessionData) error {
args := m.Called(r, w, session)
return args.Error(0)
}
// DeleteSession removes a session
func (m *SessionManager) DeleteSession(r *http.Request, w http.ResponseWriter) error {
args := m.Called(r, w)
return args.Error(0)
}
// SetAccessToken sets the access token in the session
func (m *SessionManager) SetAccessToken(session *SessionData, token string) error {
args := m.Called(session, token)
return args.Error(0)
}
// SetRefreshToken sets the refresh token in the session
func (m *SessionManager) SetRefreshToken(session *SessionData, token string) error {
args := m.Called(session, token)
return args.Error(0)
}
// SetIDToken sets the ID token in the session
func (m *SessionManager) SetIDToken(session *SessionData, token string) error {
args := m.Called(session, token)
return args.Error(0)
}
// GetAccessToken gets the access token from the session
func (m *SessionManager) GetAccessToken(session *SessionData) string {
args := m.Called(session)
return args.String(0)
}
// GetRefreshToken gets the refresh token from the session
func (m *SessionManager) GetRefreshToken(session *SessionData) string {
args := m.Called(session)
return args.String(0)
}
// GetIDToken gets the ID token from the session
func (m *SessionManager) GetIDToken(session *SessionData) string {
args := m.Called(session)
return args.String(0)
}
// IsExpired checks if the session is expired
func (m *SessionManager) IsExpired(session *SessionData) bool {
args := m.Called(session)
return args.Bool(0)
}
// CleanupOldCookies removes old/stale cookies
func (m *SessionManager) CleanupOldCookies(r *http.Request, w http.ResponseWriter) {
m.Called(r, w)
}
+509
View File
@@ -0,0 +1,509 @@
package servers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/testutil/fixtures"
)
// OIDCServerConfig configures the mock OIDC server behavior
type OIDCServerConfig struct {
// Identity
Issuer string
// Discovery
ScopesSupported []string
ResponseTypesSupported []string
GrantTypesSupported []string
ClaimsSupported []string
TokenEndpointAuthMethods []string
// Token fixture for signing
TokenFixture *fixtures.TokenFixture
// Token endpoint behavior
TokenResponse map[string]interface{}
TokenError *OIDCError
TokenDelay time.Duration
RefreshResponse map[string]interface{}
RefreshError *OIDCError
// JWKS behavior
JWKSResponse map[string]interface{}
JWKSError *OIDCError
JWKSDelay time.Duration
// Introspection behavior
IntrospectionResponse map[string]interface{}
IntrospectionError *OIDCError
// Userinfo behavior
UserinfoResponse map[string]interface{}
UserinfoError *OIDCError
// Simulation flags
SimulateTimeout bool
TimeoutDuration time.Duration
RateLimitAfter int
FailAfterN int
FailWithStatus int
}
// OIDCError represents an OAuth error response
type OIDCError struct {
Error string `json:"error"`
Description string `json:"error_description,omitempty"`
}
// OIDCServer is a configurable mock OIDC provider
type OIDCServer struct {
*httptest.Server
Config *OIDCServerConfig
RequestCount int32
mu sync.Mutex
requests []*http.Request
}
// NewOIDCServer creates a new mock OIDC server
func NewOIDCServer(config *OIDCServerConfig) *OIDCServer {
if config == nil {
config = DefaultConfig()
}
server := &OIDCServer{
Config: config,
}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", server.handleDiscovery)
mux.HandleFunc("/token", server.handleToken)
mux.HandleFunc("/jwks", server.handleJWKS)
mux.HandleFunc("/authorize", server.handleAuthorize)
mux.HandleFunc("/userinfo", server.handleUserinfo)
mux.HandleFunc("/revoke", server.handleRevoke)
mux.HandleFunc("/introspect", server.handleIntrospect)
mux.HandleFunc("/logout", server.handleLogout)
server.Server = httptest.NewServer(mux)
// Update issuer to use actual server URL if not set
if config.Issuer == "" {
config.Issuer = server.URL
}
return server
}
// NewTLSServer creates a new mock OIDC server with TLS
func NewTLSServer(config *OIDCServerConfig) *OIDCServer {
if config == nil {
config = DefaultConfig()
}
server := &OIDCServer{
Config: config,
}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", server.handleDiscovery)
mux.HandleFunc("/token", server.handleToken)
mux.HandleFunc("/jwks", server.handleJWKS)
mux.HandleFunc("/authorize", server.handleAuthorize)
mux.HandleFunc("/userinfo", server.handleUserinfo)
mux.HandleFunc("/revoke", server.handleRevoke)
mux.HandleFunc("/introspect", server.handleIntrospect)
mux.HandleFunc("/logout", server.handleLogout)
server.Server = httptest.NewTLSServer(mux)
if config.Issuer == "" {
config.Issuer = server.URL
}
return server
}
// GetRequestCount returns the number of requests received
func (s *OIDCServer) GetRequestCount() int {
return int(atomic.LoadInt32(&s.RequestCount))
}
// GetRequests returns all recorded requests
func (s *OIDCServer) GetRequests() []*http.Request {
s.mu.Lock()
defer s.mu.Unlock()
return s.requests
}
// Reset clears request tracking
func (s *OIDCServer) Reset() {
atomic.StoreInt32(&s.RequestCount, 0)
s.mu.Lock()
s.requests = nil
s.mu.Unlock()
}
func (s *OIDCServer) recordRequest(r *http.Request) {
atomic.AddInt32(&s.RequestCount, 1)
s.mu.Lock()
s.requests = append(s.requests, r)
s.mu.Unlock()
}
func (s *OIDCServer) shouldFail() bool {
count := int(atomic.LoadInt32(&s.RequestCount))
if s.Config.FailAfterN > 0 && count > s.Config.FailAfterN {
return true
}
if s.Config.RateLimitAfter > 0 && count > s.Config.RateLimitAfter {
return true
}
return false
}
func (s *OIDCServer) handleDiscovery(w http.ResponseWriter, r *http.Request) {
s.recordRequest(r)
if s.Config.SimulateTimeout {
time.Sleep(s.Config.TimeoutDuration)
return
}
discovery := map[string]interface{}{
"issuer": s.Config.Issuer,
"authorization_endpoint": s.Config.Issuer + "/authorize",
"token_endpoint": s.Config.Issuer + "/token",
"userinfo_endpoint": s.Config.Issuer + "/userinfo",
"jwks_uri": s.Config.Issuer + "/jwks",
"revocation_endpoint": s.Config.Issuer + "/revoke",
"introspection_endpoint": s.Config.Issuer + "/introspect",
"end_session_endpoint": s.Config.Issuer + "/logout",
"scopes_supported": s.Config.ScopesSupported,
"response_types_supported": s.Config.ResponseTypesSupported,
"grant_types_supported": s.Config.GrantTypesSupported,
"claims_supported": s.Config.ClaimsSupported,
"token_endpoint_auth_methods_supported": s.Config.TokenEndpointAuthMethods,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(discovery) // #nosec G104 - test server, error handling not critical
}
func (s *OIDCServer) handleToken(w http.ResponseWriter, r *http.Request) {
s.recordRequest(r)
if s.Config.SimulateTimeout {
time.Sleep(s.Config.TimeoutDuration)
return
}
if s.Config.TokenDelay > 0 {
time.Sleep(s.Config.TokenDelay)
}
if s.shouldFail() {
status := http.StatusTooManyRequests
if s.Config.FailWithStatus > 0 {
status = s.Config.FailWithStatus
}
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(OIDCError{Error: "rate_limited"}) // #nosec G104
return
}
if s.Config.TokenError != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(s.Config.TokenError) // #nosec G104
return
}
_ = r.ParseForm() // #nosec G104
grantType := r.FormValue("grant_type")
var response map[string]interface{}
if grantType == "refresh_token" {
if s.Config.RefreshError != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(s.Config.RefreshError) // #nosec G104
return
}
response = s.Config.RefreshResponse
} else {
response = s.Config.TokenResponse
}
if response == nil {
response = s.defaultTokenResponse()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response) // #nosec G104
}
func (s *OIDCServer) handleJWKS(w http.ResponseWriter, r *http.Request) {
s.recordRequest(r)
if s.Config.SimulateTimeout {
time.Sleep(s.Config.TimeoutDuration)
return
}
if s.Config.JWKSDelay > 0 {
time.Sleep(s.Config.JWKSDelay)
}
if s.Config.JWKSError != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(s.Config.JWKSError) // #nosec G104
return
}
response := s.Config.JWKSResponse
if response == nil && s.Config.TokenFixture != nil {
response = s.Config.TokenFixture.GetJWKS()
}
if response == nil {
response = map[string]interface{}{"keys": []interface{}{}}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response) // #nosec G104
}
func (s *OIDCServer) handleAuthorize(w http.ResponseWriter, r *http.Request) {
s.recordRequest(r)
// In real flow, this would redirect with code
// For testing, we return a simple page
state := r.URL.Query().Get("state")
redirectURI := r.URL.Query().Get("redirect_uri")
// Validate redirect URI to prevent open redirect vulnerability
if !isValidRedirectURI(redirectURI, s.URL) {
http.Error(w, "invalid redirect_uri", http.StatusBadRequest)
return
}
redirectURL := fmt.Sprintf("%s?code=test-auth-code&state=%s", redirectURI, state)
http.Redirect(w, r, redirectURL, http.StatusFound)
}
func (s *OIDCServer) handleUserinfo(w http.ResponseWriter, r *http.Request) {
s.recordRequest(r)
if s.Config.UserinfoError != nil {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(s.Config.UserinfoError) // #nosec G104
return
}
response := s.Config.UserinfoResponse
if response == nil {
response = map[string]interface{}{
"sub": "test-subject",
"email": "[email protected]",
"name": "Test User",
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response) // #nosec G104
}
func (s *OIDCServer) handleRevoke(w http.ResponseWriter, r *http.Request) {
s.recordRequest(r)
w.WriteHeader(http.StatusOK)
}
func (s *OIDCServer) handleIntrospect(w http.ResponseWriter, r *http.Request) {
s.recordRequest(r)
if s.Config.IntrospectionError != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(s.Config.IntrospectionError) // #nosec G104
return
}
response := s.Config.IntrospectionResponse
if response == nil {
response = map[string]interface{}{
"active": true,
"sub": "test-subject",
"client_id": "test-client",
"exp": time.Now().Add(time.Hour).Unix(),
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response) // #nosec G104
}
func (s *OIDCServer) handleLogout(w http.ResponseWriter, r *http.Request) {
s.recordRequest(r)
postLogoutRedirect := r.URL.Query().Get("post_logout_redirect_uri")
if postLogoutRedirect != "" {
// Validate post-logout redirect URI to prevent open redirect vulnerability
if !isValidRedirectURI(postLogoutRedirect, s.URL) {
http.Error(w, "invalid post_logout_redirect_uri", http.StatusBadRequest)
return
}
http.Redirect(w, r, postLogoutRedirect, http.StatusFound)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Logged out")) // #nosec G104
}
func (s *OIDCServer) defaultTokenResponse() map[string]interface{} {
var idToken string
if s.Config.TokenFixture != nil {
idToken, _ = s.Config.TokenFixture.ValidToken(nil)
} else {
idToken = "mock-id-token"
}
return map[string]interface{}{
"access_token": "mock-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "mock-refresh-token",
"id_token": idToken,
}
}
// isValidRedirectURI validates that a redirect URI is safe to use.
// It ensures the URI is either:
// 1. A relative path (no scheme or host)
// 2. Points to the same host as the test server (localhost)
// 3. Points to a common test domain (127.0.0.1, localhost)
// This prevents open redirect vulnerabilities in the test server.
func isValidRedirectURI(redirectURI, serverURL string) bool {
if redirectURI == "" {
return false
}
parsed, err := url.Parse(redirectURI)
if err != nil {
return false
}
// Allow relative paths (no scheme means relative)
if parsed.Scheme == "" && parsed.Host == "" {
return true
}
// Parse the server URL to get its host
serverParsed, err := url.Parse(serverURL)
if err != nil {
return false
}
// Allow same host as test server
if parsed.Host == serverParsed.Host {
return true
}
// Allow common localhost variations used in tests
host := strings.ToLower(parsed.Hostname())
allowedHosts := []string{
"localhost",
"127.0.0.1",
"[::1]",
"example.com", // Common test domain
"myapp.com", // Common test domain
"test.example.com",
}
for _, allowed := range allowedHosts {
if host == allowed {
return true
}
}
return false
}
// DefaultConfig returns a default server configuration
func DefaultConfig() *OIDCServerConfig {
return &OIDCServerConfig{
ScopesSupported: []string{"openid", "profile", "email", "offline_access"},
ResponseTypesSupported: []string{"code", "token", "id_token"},
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
ClaimsSupported: []string{"sub", "email", "name", "groups", "roles"},
TokenEndpointAuthMethods: []string{"client_secret_basic", "client_secret_post"},
TimeoutDuration: 30 * time.Second,
}
}
// GoogleConfig returns a Google-like server configuration
func GoogleConfig() *OIDCServerConfig {
config := DefaultConfig()
config.Issuer = "https://accounts.google.com"
config.ScopesSupported = []string{"openid", "profile", "email"}
// Google doesn't support offline_access, uses access_type=offline instead
return config
}
// AzureConfig returns an Azure AD-like server configuration
func AzureConfig() *OIDCServerConfig {
config := DefaultConfig()
config.Issuer = "https://login.microsoftonline.com/common/v2.0"
config.ScopesSupported = []string{"openid", "profile", "email", "offline_access"}
return config
}
// Auth0Config returns an Auth0-like server configuration
func Auth0Config() *OIDCServerConfig {
config := DefaultConfig()
config.ScopesSupported = []string{"openid", "profile", "email", "offline_access"}
return config
}
// KeycloakConfig returns a Keycloak-like server configuration
func KeycloakConfig() *OIDCServerConfig {
config := DefaultConfig()
config.ScopesSupported = []string{"openid", "profile", "email", "offline_access", "roles", "groups"}
return config
}
// SlowServerConfig returns a configuration that simulates slow responses
func SlowServerConfig(delay time.Duration) *OIDCServerConfig {
config := DefaultConfig()
config.TokenDelay = delay
config.JWKSDelay = delay
return config
}
// RateLimitedConfig returns a configuration that rate limits after N requests
func RateLimitedConfig(afterN int) *OIDCServerConfig {
config := DefaultConfig()
config.RateLimitAfter = afterN
return config
}
// FailingConfig returns a configuration that fails after N requests
func FailingConfig(afterN int, status int) *OIDCServerConfig {
config := DefaultConfig()
config.FailAfterN = afterN
config.FailWithStatus = status
return config
}
// TimeoutConfig returns a configuration that simulates timeouts
func TimeoutConfig(duration time.Duration) *OIDCServerConfig {
config := DefaultConfig()
config.SimulateTimeout = true
config.TimeoutDuration = duration
return config
}
+396
View File
@@ -0,0 +1,396 @@
package servers
import (
"encoding/json"
"io"
"net/http"
"testing"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/testutil/fixtures"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewOIDCServer(t *testing.T) {
t.Run("creates server with default config", func(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
assert.NotNil(t, server)
assert.NotEmpty(t, server.URL)
})
t.Run("creates server with custom config", func(t *testing.T) {
config := &OIDCServerConfig{
Issuer: "https://custom-issuer.com",
ScopesSupported: []string{"openid", "custom"},
}
server := NewOIDCServer(config)
defer server.Close()
assert.NotNil(t, server)
assert.Equal(t, "https://custom-issuer.com", server.Config.Issuer)
})
}
func TestDiscoveryEndpoint(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
resp, err := http.Get(server.URL + "/.well-known/openid-configuration")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var discovery map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&discovery)
require.NoError(t, err)
assert.Equal(t, server.URL, discovery["issuer"])
assert.Contains(t, discovery["token_endpoint"], "/token")
assert.Contains(t, discovery["jwks_uri"], "/jwks")
assert.Contains(t, discovery["authorization_endpoint"], "/authorize")
}
func TestTokenEndpoint(t *testing.T) {
t.Run("returns default token response", func(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
"grant_type": {"authorization_code"},
"code": {"test-code"},
})
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var tokenResp map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&tokenResp)
require.NoError(t, err)
assert.NotEmpty(t, tokenResp["access_token"])
assert.NotEmpty(t, tokenResp["refresh_token"])
})
t.Run("returns configured error", func(t *testing.T) {
config := DefaultConfig()
config.TokenError = &OIDCError{
Error: "invalid_grant",
Description: "The authorization code is invalid",
}
server := NewOIDCServer(config)
defer server.Close()
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
"grant_type": {"authorization_code"},
"code": {"test-code"},
})
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var errResp OIDCError
err = json.NewDecoder(resp.Body).Decode(&errResp)
require.NoError(t, err)
assert.Equal(t, "invalid_grant", errResp.Error)
})
t.Run("handles refresh token grant", func(t *testing.T) {
config := DefaultConfig()
config.RefreshResponse = map[string]interface{}{
"access_token": "new-access-token",
"expires_in": 3600,
}
server := NewOIDCServer(config)
defer server.Close()
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
"grant_type": {"refresh_token"},
"refresh_token": {"test-refresh-token"},
})
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var tokenResp map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&tokenResp)
require.NoError(t, err)
assert.Equal(t, "new-access-token", tokenResp["access_token"])
})
}
func TestJWKSEndpoint(t *testing.T) {
t.Run("returns empty JWKS without fixture", func(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
resp, err := http.Get(server.URL + "/jwks")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var jwks map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&jwks)
require.NoError(t, err)
assert.Contains(t, jwks, "keys")
})
t.Run("returns JWKS from fixture", func(t *testing.T) {
fixture, err := fixtures.NewTokenFixture()
require.NoError(t, err)
config := DefaultConfig()
config.TokenFixture = fixture
server := NewOIDCServer(config)
defer server.Close()
resp, err := http.Get(server.URL + "/jwks")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var jwks map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&jwks)
require.NoError(t, err)
keys, ok := jwks["keys"].([]interface{})
require.True(t, ok)
assert.Len(t, keys, 1)
})
}
func TestUserinfoEndpoint(t *testing.T) {
t.Run("returns default userinfo", func(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
resp, err := http.Get(server.URL + "/userinfo")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var userinfo map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&userinfo)
require.NoError(t, err)
assert.NotEmpty(t, userinfo["sub"])
assert.NotEmpty(t, userinfo["email"])
})
t.Run("returns configured userinfo", func(t *testing.T) {
config := DefaultConfig()
config.UserinfoResponse = map[string]interface{}{
"sub": "custom-sub",
"email": "[email protected]",
"name": "Custom User",
}
server := NewOIDCServer(config)
defer server.Close()
resp, err := http.Get(server.URL + "/userinfo")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var userinfo map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&userinfo)
require.NoError(t, err)
assert.Equal(t, "[email protected]", userinfo["email"])
})
}
func TestIntrospectionEndpoint(t *testing.T) {
t.Run("returns active token", func(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
resp, err := http.PostForm(server.URL+"/introspect", map[string][]string{
"token": {"test-token"},
})
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var introspection map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&introspection)
require.NoError(t, err)
assert.Equal(t, true, introspection["active"])
})
}
func TestRevocationEndpoint(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
resp, err := http.PostForm(server.URL+"/revoke", map[string][]string{
"token": {"test-token"},
})
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestLogoutEndpoint(t *testing.T) {
t.Run("returns OK without redirect", func(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(server.URL + "/logout")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("redirects with post_logout_redirect_uri", func(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(server.URL + "/logout?post_logout_redirect_uri=https://example.com/logged-out")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusFound, resp.StatusCode)
assert.Equal(t, "https://example.com/logged-out", resp.Header.Get("Location"))
})
}
func TestRequestTracking(t *testing.T) {
server := NewOIDCServer(nil)
defer server.Close()
assert.Equal(t, 0, server.GetRequestCount())
http.Get(server.URL + "/.well-known/openid-configuration")
assert.Equal(t, 1, server.GetRequestCount())
http.Get(server.URL + "/jwks")
assert.Equal(t, 2, server.GetRequestCount())
requests := server.GetRequests()
assert.Len(t, requests, 2)
server.Reset()
assert.Equal(t, 0, server.GetRequestCount())
assert.Len(t, server.GetRequests(), 0)
}
func TestRateLimiting(t *testing.T) {
config := RateLimitedConfig(2)
server := NewOIDCServer(config)
defer server.Close()
// First 2 requests should succeed
for i := 0; i < 2; i++ {
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
"grant_type": {"authorization_code"},
"code": {"test-code"},
})
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
// Third request should be rate limited
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
"grant_type": {"authorization_code"},
"code": {"test-code"},
})
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
}
func TestSlowServer(t *testing.T) {
config := SlowServerConfig(100 * time.Millisecond)
server := NewOIDCServer(config)
defer server.Close()
start := time.Now()
resp, err := http.PostForm(server.URL+"/token", map[string][]string{
"grant_type": {"authorization_code"},
"code": {"test-code"},
})
elapsed := time.Since(start)
require.NoError(t, err)
resp.Body.Close()
assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(100))
}
func TestProviderConfigs(t *testing.T) {
t.Run("GoogleConfig", func(t *testing.T) {
config := GoogleConfig()
assert.Equal(t, "https://accounts.google.com", config.Issuer)
assert.NotContains(t, config.ScopesSupported, "offline_access")
})
t.Run("AzureConfig", func(t *testing.T) {
config := AzureConfig()
assert.Contains(t, config.Issuer, "microsoftonline.com")
assert.Contains(t, config.ScopesSupported, "offline_access")
})
t.Run("Auth0Config", func(t *testing.T) {
config := Auth0Config()
assert.Contains(t, config.ScopesSupported, "offline_access")
})
t.Run("KeycloakConfig", func(t *testing.T) {
config := KeycloakConfig()
assert.Contains(t, config.ScopesSupported, "roles")
assert.Contains(t, config.ScopesSupported, "groups")
})
}
func TestTimeoutConfig(t *testing.T) {
config := TimeoutConfig(50 * time.Millisecond)
server := NewOIDCServer(config)
defer server.Close()
client := &http.Client{
Timeout: 100 * time.Millisecond,
}
start := time.Now()
resp, err := client.Get(server.URL + "/.well-known/openid-configuration")
elapsed := time.Since(start)
// Either timeout or empty response
if err == nil {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
// With timeout simulation, response body may be empty
assert.True(t, len(body) == 0 || elapsed >= 50*time.Millisecond)
}
}
+144
View File
@@ -0,0 +1,144 @@
package testutil
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/lukaszraczylo/traefikoidc/internal/testutil/fixtures"
"github.com/lukaszraczylo/traefikoidc/internal/testutil/mocks"
"github.com/lukaszraczylo/traefikoidc/internal/testutil/servers"
"github.com/stretchr/testify/suite"
)
// OIDCSuite is a base test suite for OIDC-related tests
type OIDCSuite struct {
suite.Suite
// Common fixtures
TokenFixture *fixtures.TokenFixture
// Mock OIDC server
OIDCServer *servers.OIDCServer
// Mocks
JWKCacheMock *mocks.JWKCache
TokenExchangerMock *mocks.TokenExchanger
SessionManagerMock *mocks.SessionManager
CacheMock *mocks.Cache
LoggerMock *mocks.Logger
}
// SetupSuite runs once before all tests in the suite
func (s *OIDCSuite) SetupSuite() {
var err error
s.TokenFixture, err = fixtures.NewTokenFixture()
s.Require().NoError(err, "Failed to create token fixture")
}
// SetupTest runs before each test
func (s *OIDCSuite) SetupTest() {
// Create fresh mocks for each test
s.JWKCacheMock = new(mocks.JWKCache)
s.TokenExchangerMock = new(mocks.TokenExchanger)
s.SessionManagerMock = new(mocks.SessionManager)
s.CacheMock = new(mocks.Cache)
s.LoggerMock = new(mocks.Logger)
// Create OIDC server with token fixture
config := servers.DefaultConfig()
config.TokenFixture = s.TokenFixture
s.OIDCServer = servers.NewOIDCServer(config)
}
// TearDownTest runs after each test
func (s *OIDCSuite) TearDownTest() {
if s.OIDCServer != nil {
s.OIDCServer.Close()
}
}
// TearDownSuite runs once after all tests in the suite
func (s *OIDCSuite) TearDownSuite() {
// Cleanup if needed
}
// NewRequest creates a new HTTP request for testing
func (s *OIDCSuite) NewRequest(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
return req
}
// NewRequestWithCookie creates a request with a session cookie
func (s *OIDCSuite) NewRequestWithCookie(method, path, cookieName, cookieValue string) *http.Request {
req := s.NewRequest(method, path)
req.AddCookie(&http.Cookie{
Name: cookieName,
Value: cookieValue,
})
return req
}
// NewRecorder creates a new response recorder
func (s *OIDCSuite) NewRecorder() *httptest.ResponseRecorder {
return httptest.NewRecorder()
}
// AssertMocksCalled verifies all mock expectations were met
func (s *OIDCSuite) AssertMocksCalled() {
s.JWKCacheMock.AssertExpectations(s.T())
s.TokenExchangerMock.AssertExpectations(s.T())
s.SessionManagerMock.AssertExpectations(s.T())
s.CacheMock.AssertExpectations(s.T())
s.LoggerMock.AssertExpectations(s.T())
}
// ValidToken returns a valid JWT token
func (s *OIDCSuite) ValidToken() string {
token, err := s.TokenFixture.ValidToken(nil)
s.Require().NoError(err)
return token
}
// ExpiredToken returns an expired JWT token
func (s *OIDCSuite) ExpiredToken() string {
token, err := s.TokenFixture.ExpiredToken()
s.Require().NoError(err)
return token
}
// TokenWithClaims returns a token with custom claims
func (s *OIDCSuite) TokenWithClaims(claims map[string]interface{}) string {
token, err := s.TokenFixture.ValidToken(claims)
s.Require().NoError(err)
return token
}
// RunSuite runs a test suite
func RunSuite(t *testing.T, s suite.TestingSuite) {
suite.Run(t, s)
}
// MinimalSuite is a lightweight test suite without OIDC server
type MinimalSuite struct {
suite.Suite
// Mocks only
JWKCacheMock *mocks.JWKCache
TokenExchangerMock *mocks.TokenExchanger
CacheMock *mocks.Cache
}
// SetupTest runs before each test
func (s *MinimalSuite) SetupTest() {
s.JWKCacheMock = new(mocks.JWKCache)
s.TokenExchangerMock = new(mocks.TokenExchanger)
s.CacheMock = new(mocks.Cache)
}
// AssertMocksCalled verifies all mock expectations were met
func (s *MinimalSuite) AssertMocksCalled() {
s.JWKCacheMock.AssertExpectations(s.T())
s.TokenExchangerMock.AssertExpectations(s.T())
s.CacheMock.AssertExpectations(s.T())
}