mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
Cleanup [dec2025] (#101)
* Cleanup excessive comments. * Remove leftovers hanging around from previous refactor * Improve test coverage
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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[:]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user