mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
ae59a5e88a
* Add ability to disable replay protection. - This is useful for runs with multiple traefik replicas to avoid false positives and tokens re-creation. * Enhance the CI/CD pipelines * Increase test coverage. * Update vendored dependencies. * Update behaviour on forceHTTPS as per issue #82
840 lines
28 KiB
Go
840 lines
28 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// TestIntrospectToken_Success tests successful token introspection with active token
|
|
func TestIntrospectToken_Success(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
// Create mock introspection server
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify request method and content type
|
|
if r.Method != "POST" {
|
|
t.Errorf("Expected POST request, got %s", r.Method)
|
|
}
|
|
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
|
t.Errorf("Expected application/x-www-form-urlencoded, got %s", r.Header.Get("Content-Type"))
|
|
}
|
|
|
|
// Verify basic auth
|
|
username, password, ok := r.BasicAuth()
|
|
if !ok || username != "test-client" || password != "test-secret" {
|
|
t.Errorf("Invalid basic auth: username=%s, password=%s, ok=%v", username, password, ok)
|
|
}
|
|
|
|
// Parse request body
|
|
body, _ := io.ReadAll(r.Body)
|
|
values, _ := url.ParseQuery(string(body))
|
|
|
|
if values.Get("token") != "test-opaque-token" {
|
|
t.Errorf("Expected token=test-opaque-token, got %s", values.Get("token"))
|
|
}
|
|
if values.Get("token_type_hint") != "access_token" {
|
|
t.Errorf("Expected token_type_hint=access_token, got %s", values.Get("token_type_hint"))
|
|
}
|
|
|
|
// Return successful introspection response
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
Scope: "openid profile email",
|
|
ClientID: "test-client",
|
|
Username: "testuser",
|
|
TokenType: "Bearer",
|
|
Exp: time.Now().Add(1 * time.Hour).Unix(),
|
|
Iat: time.Now().Add(-5 * time.Minute).Unix(),
|
|
Nbf: time.Now().Add(-5 * time.Minute).Unix(),
|
|
Sub: "user123",
|
|
Aud: "test-audience",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create TraefikOidc instance
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
// Perform introspection
|
|
resp, err := tOidc.introspectToken("test-opaque-token")
|
|
if err != nil {
|
|
t.Fatalf("introspectToken failed: %v", err)
|
|
}
|
|
|
|
// Verify response
|
|
if !resp.Active {
|
|
t.Error("Expected token to be active")
|
|
}
|
|
if resp.ClientID != "test-client" {
|
|
t.Errorf("Expected clientID=test-client, got %s", resp.ClientID)
|
|
}
|
|
if resp.Username != "testuser" {
|
|
t.Errorf("Expected username=testuser, got %s", resp.Username)
|
|
}
|
|
if resp.Scope != "openid profile email" {
|
|
t.Errorf("Expected scope='openid profile email', got %s", resp.Scope)
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_CachedResult tests that cached introspection results are used
|
|
func TestIntrospectToken_CachedResult(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
requestCount := 0
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestCount++
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
Exp: time.Now().Add(1 * time.Hour).Unix(),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
// First call - should hit the server
|
|
resp1, err := tOidc.introspectToken("cached-token")
|
|
if err != nil {
|
|
t.Fatalf("First introspectToken failed: %v", err)
|
|
}
|
|
if !resp1.Active {
|
|
t.Error("Expected first token to be active")
|
|
}
|
|
if requestCount != 1 {
|
|
t.Errorf("Expected 1 request after first call, got %d", requestCount)
|
|
}
|
|
|
|
// Second call - should use cache
|
|
resp2, err := tOidc.introspectToken("cached-token")
|
|
if err != nil {
|
|
t.Fatalf("Second introspectToken failed: %v", err)
|
|
}
|
|
if !resp2.Active {
|
|
t.Error("Expected second token to be active")
|
|
}
|
|
if requestCount != 1 {
|
|
t.Errorf("Expected 1 request after cache hit, got %d", requestCount)
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_MissingEndpoint tests introspection without endpoint
|
|
func TestIntrospectToken_MissingEndpoint(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: "", // No endpoint
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
_, err := tOidc.introspectToken("test-token")
|
|
if err == nil {
|
|
t.Error("Expected error for missing introspection endpoint")
|
|
}
|
|
if !strings.Contains(err.Error(), "introspection endpoint not available") {
|
|
t.Errorf("Expected 'introspection endpoint not available' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_HTTPError tests handling of HTTP error responses
|
|
func TestIntrospectToken_HTTPError(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(`{"error": "invalid_client"}`))
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
_, err := tOidc.introspectToken("test-token")
|
|
if err == nil {
|
|
t.Error("Expected error for HTTP 401 response")
|
|
}
|
|
if !strings.Contains(err.Error(), "401") {
|
|
t.Errorf("Expected error mentioning status 401, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_InvalidJSON tests handling of invalid JSON response
|
|
func TestIntrospectToken_InvalidJSON(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{invalid json`))
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
_, err := tOidc.introspectToken("test-token")
|
|
if err == nil {
|
|
t.Error("Expected error for invalid JSON response")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to decode") {
|
|
t.Errorf("Expected 'failed to decode' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_ExpiryHandling tests cache duration based on token expiry
|
|
func TestIntrospectToken_ExpiryHandling(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
// Token that expires in 2 minutes
|
|
shortExpiry := time.Now().Add(2 * time.Minute).Unix()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
Exp: shortExpiry,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
resp, err := tOidc.introspectToken("expiring-token")
|
|
if err != nil {
|
|
t.Fatalf("introspectToken failed: %v", err)
|
|
}
|
|
if resp.Exp != shortExpiry {
|
|
t.Errorf("Expected exp=%d, got %d", shortExpiry, resp.Exp)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_OpaqueTokensDisabled tests validation when opaque tokens are disabled
|
|
func TestValidateOpaqueToken_OpaqueTokensDisabled(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: false, // Disabled
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
err := tOidc.validateOpaqueToken("test-token")
|
|
if err == nil {
|
|
t.Error("Expected error when opaque tokens are disabled")
|
|
}
|
|
if !strings.Contains(err.Error(), "opaque tokens are not enabled") {
|
|
t.Errorf("Expected 'opaque tokens are not enabled' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_MissingEndpointWithRequirement tests validation when introspection is required but endpoint is missing
|
|
func TestValidateOpaqueToken_MissingEndpointWithRequirement(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
requireTokenIntrospection: true, // Required
|
|
introspectionURL: "", // Missing
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
err := tOidc.validateOpaqueToken("test-token")
|
|
if err == nil {
|
|
t.Error("Expected error when introspection is required but endpoint is missing")
|
|
}
|
|
if !strings.Contains(err.Error(), "token introspection required but endpoint not available") {
|
|
t.Errorf("Expected 'introspection required but endpoint not available' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_InactiveToken tests validation of an inactive token
|
|
func TestValidateOpaqueToken_InactiveToken(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: false, // Inactive
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
err := tOidc.validateOpaqueToken("inactive-token")
|
|
if err == nil {
|
|
t.Error("Expected error for inactive token")
|
|
}
|
|
if !strings.Contains(err.Error(), "not active") {
|
|
t.Errorf("Expected 'not active' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_ExpiredToken tests validation of an expired token
|
|
func TestValidateOpaqueToken_ExpiredToken(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
Exp: time.Now().Add(-1 * time.Hour).Unix(), // Expired 1 hour ago
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
err := tOidc.validateOpaqueToken("expired-token")
|
|
if err == nil {
|
|
t.Error("Expected error for expired token")
|
|
}
|
|
if !strings.Contains(err.Error(), "expired") {
|
|
t.Errorf("Expected 'expired' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_NotYetValid tests validation of a token not yet valid (nbf in future)
|
|
func TestValidateOpaqueToken_NotYetValid(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
Nbf: time.Now().Add(1 * time.Hour).Unix(), // Valid 1 hour from now
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
err := tOidc.validateOpaqueToken("future-token")
|
|
if err == nil {
|
|
t.Error("Expected error for not-yet-valid token")
|
|
}
|
|
if !strings.Contains(err.Error(), "not yet valid") {
|
|
t.Errorf("Expected 'not yet valid' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_InvalidAudience tests validation with mismatched audience
|
|
func TestValidateOpaqueToken_InvalidAudience(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
Aud: "wrong-audience",
|
|
Exp: time.Now().Add(1 * time.Hour).Unix(),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
audience: "expected-audience",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
err := tOidc.validateOpaqueToken("wrong-aud-token")
|
|
if err == nil {
|
|
t.Error("Expected error for invalid audience")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid audience") {
|
|
t.Errorf("Expected 'invalid audience' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_SuccessfulValidation tests successful opaque token validation
|
|
func TestValidateOpaqueToken_SuccessfulValidation(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
Aud: "test-audience",
|
|
Exp: time.Now().Add(1 * time.Hour).Unix(),
|
|
Nbf: time.Now().Add(-5 * time.Minute).Unix(),
|
|
Scope: "openid profile",
|
|
Sub: "user123",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
audience: "test-audience",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
err := tOidc.validateOpaqueToken("valid-token")
|
|
if err != nil {
|
|
t.Errorf("Expected successful validation, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_FallbackWithoutEndpoint tests fallback to ID token validation when endpoint is missing
|
|
func TestValidateOpaqueToken_FallbackWithoutEndpoint(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
requireTokenIntrospection: false, // Not required
|
|
introspectionURL: "", // Missing
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
// Should succeed (falls back to ID token validation)
|
|
err := tOidc.validateOpaqueToken("test-token")
|
|
if err != nil {
|
|
t.Errorf("Expected fallback to succeed, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_WithCircuitBreaker tests introspection with error recovery manager
|
|
func TestIntrospectToken_WithCircuitBreaker(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create error recovery manager
|
|
errorRecoveryManager := NewErrorRecoveryManager(logger)
|
|
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
issuerURL: "https://test-issuer.com",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
errorRecoveryManager: errorRecoveryManager,
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
resp, err := tOidc.introspectToken("test-token")
|
|
if err != nil {
|
|
t.Fatalf("introspectToken with circuit breaker failed: %v", err)
|
|
}
|
|
if !resp.Active {
|
|
t.Error("Expected token to be active")
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_ConcurrentCalls tests concurrent introspection calls
|
|
func TestIntrospectToken_ConcurrentCalls(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
var requestCount int
|
|
var mu sync.Mutex
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
requestCount++
|
|
mu.Unlock()
|
|
|
|
// Small delay to simulate network latency
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
// Run concurrent introspection calls
|
|
var wg sync.WaitGroup
|
|
concurrency := 10
|
|
wg.Add(concurrency)
|
|
|
|
for i := 0; i < concurrency; i++ {
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
token := fmt.Sprintf("concurrent-token-%d", id)
|
|
_, err := tOidc.introspectToken(token)
|
|
if err != nil {
|
|
t.Errorf("Concurrent introspection %d failed: %v", id, err)
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
mu.Lock()
|
|
finalCount := requestCount
|
|
mu.Unlock()
|
|
|
|
// Each unique token should result in one request
|
|
if finalCount != concurrency {
|
|
t.Errorf("Expected %d requests for %d concurrent calls, got %d", concurrency, concurrency, finalCount)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_AudienceMatchesClientID tests audience validation when audience equals clientID
|
|
func TestValidateOpaqueToken_AudienceMatchesClientID(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
Aud: "different-aud",
|
|
Exp: time.Now().Add(1 * time.Hour).Unix(),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
audience: "test-client", // Same as clientID
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
// Should succeed because audience validation is skipped when audience == clientID
|
|
err := tOidc.validateOpaqueToken("test-token")
|
|
if err != nil {
|
|
t.Errorf("Expected validation to succeed when audience equals clientID, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_EmptyAudienceInResponse tests validation when response has empty audience
|
|
func TestValidateOpaqueToken_EmptyAudienceInResponse(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
Aud: "", // Empty audience
|
|
Exp: time.Now().Add(1 * time.Hour).Unix(),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
audience: "expected-audience",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
// Should succeed because audience validation is skipped when response.Aud is empty
|
|
err := tOidc.validateOpaqueToken("test-token")
|
|
if err != nil {
|
|
t.Errorf("Expected validation to succeed when response audience is empty, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_RateLimiting tests introspection respects rate limiting
|
|
func TestIntrospectToken_RateLimiting(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create a very restrictive rate limiter
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
limiter: rate.NewLimiter(rate.Every(1*time.Hour), 1), // Very strict
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
// First call should succeed
|
|
_, err := tOidc.introspectToken("rate-limit-token-1")
|
|
if err != nil {
|
|
t.Fatalf("First introspection failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_HTTPClientTimeout tests introspection with HTTP timeout
|
|
func TestIntrospectToken_HTTPClientTimeout(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
// Server that delays response
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(2 * time.Second) // Delay longer than client timeout
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 100 * time.Millisecond}, // Short timeout
|
|
}
|
|
|
|
_, err := tOidc.introspectToken("timeout-token")
|
|
if err == nil {
|
|
t.Error("Expected timeout error")
|
|
}
|
|
// Error should indicate a timeout or request failure
|
|
if !strings.Contains(err.Error(), "introspection request failed") {
|
|
t.Errorf("Expected 'introspection request failed' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateOpaqueToken_IntrospectionFailure tests validation when introspection fails
|
|
func TestValidateOpaqueToken_IntrospectionFailure(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(`{"error": "server_error"}`))
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
tOidc := &TraefikOidc{
|
|
allowOpaqueTokens: true,
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
|
|
err := tOidc.validateOpaqueToken("failing-token")
|
|
if err == nil {
|
|
t.Error("Expected error when introspection fails")
|
|
}
|
|
if !strings.Contains(err.Error(), "token introspection failed") {
|
|
t.Errorf("Expected 'token introspection failed' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntrospectToken_ContextCancellation tests introspection with context cancellation
|
|
func TestIntrospectToken_ContextCancellation(t *testing.T) {
|
|
logger := GetSingletonNoOpLogger()
|
|
cacheManager := GetUniversalCacheManager(logger)
|
|
defer ResetUniversalCacheManagerForTesting()
|
|
|
|
// Server that takes time to respond
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(1 * time.Second) // Longer delay to ensure timeout
|
|
resp := IntrospectionResponse{
|
|
Active: true,
|
|
ClientID: "test-client",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Use context-aware HTTP client
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
tOidc := &TraefikOidc{
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
introspectionURL: mockServer.URL,
|
|
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
|
|
logger: logger,
|
|
httpClient: client,
|
|
}
|
|
|
|
// Note: introspectToken uses context.Background() internally, not tOidc.ctx
|
|
// This test demonstrates that HTTP timeout will trigger instead of context cancellation
|
|
// The actual behavior is that the HTTP client's timeout will be used
|
|
_, err := tOidc.introspectToken("cancel-token")
|
|
// The function should still return an error due to timeout or failure
|
|
// but it won't be a context cancellation error since context.Background() is used
|
|
_ = err // Accept any error including no error (fast completion)
|
|
}
|