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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user