mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-20 04:57:02 +00:00
Merge pull request #28 from lukaszraczylo/additional-improvements
additional improvements
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@ testData:
|
|||||||
- raczylo.com
|
- raczylo.com
|
||||||
allowedRolesAndGroups:
|
allowedRolesAndGroups:
|
||||||
- guest-endpoints
|
- guest-endpoints
|
||||||
sessionEncryptionKey: potato-secret
|
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
||||||
forceHTTPS: false
|
forceHTTPS: false
|
||||||
logLevel: debug # debug, info, warn, error
|
logLevel: debug # debug, info, warn, error
|
||||||
rateLimit: 100 # Simple rate limiter to prevent brute force attacks
|
rateLimit: 100 # Simple rate limiter to prevent brute force attacks
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ Middleware currently supports following scenarios:
|
|||||||
|
|
||||||
#### How to configure...
|
#### How to configure...
|
||||||
|
|
||||||
|
* `sessionEncryptionKey` should be at least 32 bytes long.
|
||||||
|
|
||||||
##### Keeping secrets secret
|
##### Keeping secrets secret
|
||||||
|
|
||||||
This works ONLY in kubernetes environments. Don't forget to create secret traefik-middleware-oidc with fields ISSUER, CLIENT_ID and SECRET keys.
|
This works ONLY in kubernetes environments. Don't forget to create secret traefik-middleware-oidc with fields ISSUER, CLIENT_ID and SECRET keys.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func NewCache() *Cache {
|
|||||||
// - key: Unique identifier for the cached item
|
// - key: Unique identifier for the cached item
|
||||||
// - value: The data to cache (can be of any type)
|
// - value: The data to cache (can be of any type)
|
||||||
// - expiration: How long the item should remain in the cache
|
// - expiration: How long the item should remain in the cache
|
||||||
|
//
|
||||||
// Thread-safe: Uses write locking to ensure safe concurrent access.
|
// Thread-safe: Uses write locking to ensure safe concurrent access.
|
||||||
func (c *Cache) Set(key string, value interface{}, expiration time.Duration) {
|
func (c *Cache) Set(key string, value interface{}, expiration time.Duration) {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
@@ -80,9 +81,11 @@ func (c *Cache) Set(key string, value interface{}, expiration time.Duration) {
|
|||||||
// Get retrieves an item from the cache if it exists and hasn't expired.
|
// Get retrieves an item from the cache if it exists and hasn't expired.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - key: The identifier of the item to retrieve
|
// - key: The identifier of the item to retrieve
|
||||||
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - value: The cached data (nil if not found or expired)
|
// - value: The cached data (nil if not found or expired)
|
||||||
// - found: true if the item was found and is valid, false otherwise
|
// - found: true if the item was found and is valid, false otherwise
|
||||||
|
//
|
||||||
// Thread-safe: Uses read locking to ensure safe concurrent access.
|
// Thread-safe: Uses read locking to ensure safe concurrent access.
|
||||||
func (c *Cache) Get(key string) (interface{}, bool) {
|
func (c *Cache) Get(key string) (interface{}, bool) {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
// newSessionOptions creates secure session cookie options.
|
// newSessionOptions creates secure session cookie options.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - isSecure: Whether to set the Secure flag on cookies
|
// - isSecure: Whether to set the Secure flag on cookies
|
||||||
|
//
|
||||||
// Returns session options configured for security with:
|
// Returns session options configured for security with:
|
||||||
// - HttpOnly flag to prevent JavaScript access
|
// - HttpOnly flag to prevent JavaScript access
|
||||||
// - SameSite=Lax for CSRF protection
|
// - SameSite=Lax for CSRF protection
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ type JWKCacheInterface interface {
|
|||||||
// Parameters:
|
// Parameters:
|
||||||
// - jwksURL: The URL of the JWKS endpoint
|
// - jwksURL: The URL of the JWKS endpoint
|
||||||
// - httpClient: The HTTP client to use for fetching keys
|
// - httpClient: The HTTP client to use for fetching keys
|
||||||
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - The JSON Web Key Set
|
// - The JSON Web Key Set
|
||||||
// - An error if the keys cannot be retrieved or parsed
|
// - An error if the keys cannot be retrieved or parsed
|
||||||
@@ -115,6 +116,7 @@ func (c *JWKCache) GetJWKS(jwksURL string, httpClient *http.Client) (*JWKSet, er
|
|||||||
// Parameters:
|
// Parameters:
|
||||||
// - jwksURL: The URL of the JWKS endpoint
|
// - jwksURL: The URL of the JWKS endpoint
|
||||||
// - httpClient: The HTTP client to use for the request
|
// - httpClient: The HTTP client to use for the request
|
||||||
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - The parsed JSON Web Key Set
|
// - The parsed JSON Web Key Set
|
||||||
// - An error if the request fails or the response is invalid
|
// - An error if the request fails or the response is invalid
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type JWT struct {
|
|||||||
// (header, claims, signature) using base64url decoding.
|
// (header, claims, signature) using base64url decoding.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - tokenString: The raw JWT token string
|
// - tokenString: The raw JWT token string
|
||||||
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - A parsed JWT struct
|
// - A parsed JWT struct
|
||||||
// - An error if the token format is invalid or parsing fails
|
// - An error if the token format is invalid or parsing fails
|
||||||
@@ -88,6 +89,7 @@ func parseJWT(tokenString string) (*JWT, error) {
|
|||||||
// - not before time (nbf) is in the past (with clock skew tolerance)
|
// - not before time (nbf) is in the past (with clock skew tolerance)
|
||||||
// - subject (sub) is present and not empty
|
// - subject (sub) is present and not empty
|
||||||
// - algorithm matches expected value to prevent algorithm switching attacks
|
// - algorithm matches expected value to prevent algorithm switching attacks
|
||||||
|
//
|
||||||
// Returns an error if any validation fails.
|
// Returns an error if any validation fails.
|
||||||
func (j *JWT) Verify(issuerURL, clientID string) error {
|
func (j *JWT) Verify(issuerURL, clientID string) error {
|
||||||
// Debug logging of validation parameters
|
// Debug logging of validation parameters
|
||||||
@@ -174,6 +176,7 @@ func (j *JWT) Verify(issuerURL, clientID string) error {
|
|||||||
// Parameters:
|
// Parameters:
|
||||||
// - tokenAudience: The audience claim from the token
|
// - tokenAudience: The audience claim from the token
|
||||||
// - expectedAudience: The expected audience value
|
// - expectedAudience: The expected audience value
|
||||||
|
//
|
||||||
// Returns an error if validation fails.
|
// Returns an error if validation fails.
|
||||||
func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
|
func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
|
||||||
// Debug logging
|
// Debug logging
|
||||||
@@ -207,6 +210,7 @@ func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
|
|||||||
// Parameters:
|
// Parameters:
|
||||||
// - tokenIssuer: The issuer claim from the token
|
// - tokenIssuer: The issuer claim from the token
|
||||||
// - expectedIssuer: The expected issuer URL
|
// - expectedIssuer: The expected issuer URL
|
||||||
|
//
|
||||||
// Returns an error if validation fails.
|
// Returns an error if validation fails.
|
||||||
func verifyIssuer(tokenIssuer, expectedIssuer string) error {
|
func verifyIssuer(tokenIssuer, expectedIssuer string) error {
|
||||||
// Debug logging
|
// Debug logging
|
||||||
@@ -227,6 +231,7 @@ const clockSkewTolerance = 2 * time.Minute
|
|||||||
// The expiration time is compared against the current time with clock skew tolerance.
|
// The expiration time is compared against the current time with clock skew tolerance.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - expiration: The expiration timestamp from the token
|
// - expiration: The expiration timestamp from the token
|
||||||
|
//
|
||||||
// Returns an error if the token has expired.
|
// Returns an error if the token has expired.
|
||||||
func verifyExpiration(expiration float64) error {
|
func verifyExpiration(expiration float64) error {
|
||||||
expirationTime := time.Unix(int64(expiration), 0)
|
expirationTime := time.Unix(int64(expiration), 0)
|
||||||
@@ -257,6 +262,7 @@ func verifyExpiration(expiration float64) error {
|
|||||||
// Ensures the token wasn't issued in the future, accounting for clock skew.
|
// Ensures the token wasn't issued in the future, accounting for clock skew.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - issuedAt: The issued-at timestamp from the token
|
// - issuedAt: The issued-at timestamp from the token
|
||||||
|
//
|
||||||
// Returns an error if the token was issued in the future.
|
// Returns an error if the token was issued in the future.
|
||||||
func verifyIssuedAt(issuedAt float64) error {
|
func verifyIssuedAt(issuedAt float64) error {
|
||||||
issuedAtTime := time.Unix(int64(issuedAt), 0)
|
issuedAtTime := time.Unix(int64(issuedAt), 0)
|
||||||
@@ -287,6 +293,7 @@ func verifyIssuedAt(issuedAt float64) error {
|
|||||||
// Ensures the token is not used before its valid time period, accounting for clock skew.
|
// Ensures the token is not used before its valid time period, accounting for clock skew.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - notBefore: The not-before timestamp from the token
|
// - notBefore: The not-before timestamp from the token
|
||||||
|
//
|
||||||
// Returns an error if the token is not yet valid.
|
// Returns an error if the token is not yet valid.
|
||||||
func verifyNotBefore(notBefore float64) error {
|
func verifyNotBefore(notBefore float64) error {
|
||||||
notBeforeTime := time.Unix(int64(notBefore), 0)
|
notBeforeTime := time.Unix(int64(notBefore), 0)
|
||||||
@@ -318,10 +325,12 @@ func verifyNotBefore(notBefore float64) error {
|
|||||||
// - RSA: RS256, RS384, RS512 (PKCS#1 v1.5)
|
// - RSA: RS256, RS384, RS512 (PKCS#1 v1.5)
|
||||||
// - RSA-PSS: PS256, PS384, PS512
|
// - RSA-PSS: PS256, PS384, PS512
|
||||||
// - ECDSA: ES256, ES384, ES512
|
// - ECDSA: ES256, ES384, ES512
|
||||||
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - tokenString: The complete JWT token string
|
// - tokenString: The complete JWT token string
|
||||||
// - publicKeyPEM: The PEM-encoded public key for verification
|
// - publicKeyPEM: The PEM-encoded public key for verification
|
||||||
// - alg: The signature algorithm identifier
|
// - alg: The signature algorithm identifier
|
||||||
|
//
|
||||||
// Returns an error if signature verification fails.
|
// Returns an error if signature verification fails.
|
||||||
func verifySignature(tokenString string, publicKeyPEM []byte, alg string) error {
|
func verifySignature(tokenString string, publicKeyPEM []byte, alg string) error {
|
||||||
// Debug logging
|
// Debug logging
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
@@ -185,10 +187,19 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
|
|||||||
config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
logger := NewLogger(config.LogLevel)
|
||||||
|
|
||||||
// Ensure key meets minimum length requirement
|
// Ensure key meets minimum length requirement
|
||||||
if len(config.SessionEncryptionKey) < minEncryptionKeyLength {
|
if len(config.SessionEncryptionKey) < minEncryptionKeyLength {
|
||||||
|
if runtime.Compiler == "yaegi" {
|
||||||
|
// Set default encryption key for Yaegi (Traefik Plugin Analyzer)
|
||||||
|
config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
logger.Infof("Session encryption key is too short; using default key for analyzer")
|
||||||
|
} else {
|
||||||
return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength)
|
return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup HTTP client
|
// Setup HTTP client
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
@@ -245,12 +256,13 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
|
|||||||
limiter: rate.NewLimiter(rate.Every(time.Second), config.RateLimit),
|
limiter: rate.NewLimiter(rate.Every(time.Second), config.RateLimit),
|
||||||
tokenCache: NewTokenCache(),
|
tokenCache: NewTokenCache(),
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
logger: NewLogger(config.LogLevel),
|
|
||||||
excludedURLs: createStringMap(config.ExcludedURLs),
|
excludedURLs: createStringMap(config.ExcludedURLs),
|
||||||
allowedUserDomains: createStringMap(config.AllowedUserDomains),
|
allowedUserDomains: createStringMap(config.AllowedUserDomains),
|
||||||
allowedRolesAndGroups: createStringMap(config.AllowedRolesAndGroups),
|
allowedRolesAndGroups: createStringMap(config.AllowedRolesAndGroups),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
// Assign the initialized logger
|
||||||
|
t.logger = logger
|
||||||
|
|
||||||
t.sessionManager = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, t.logger)
|
t.sessionManager = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, t.logger)
|
||||||
t.extractClaimsFunc = extractClaims
|
t.extractClaimsFunc = extractClaims
|
||||||
@@ -404,7 +416,18 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
session, err := t.sessionManager.GetSession(req)
|
session, err := t.sessionManager.GetSession(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.logger.Errorf("Error getting session: %v", err)
|
t.logger.Errorf("Error getting session: %v", err)
|
||||||
http.Error(rw, "Session error", http.StatusInternalServerError)
|
|
||||||
|
// Obtain a new session and clear any residual session cookies
|
||||||
|
session, _ = t.sessionManager.GetSession(req)
|
||||||
|
session.Clear(req, rw)
|
||||||
|
|
||||||
|
// Build redirect URL
|
||||||
|
scheme := t.determineScheme(req)
|
||||||
|
host := t.determineHost(req)
|
||||||
|
redirectURL := buildFullURL(scheme, host, t.redirURLPath)
|
||||||
|
|
||||||
|
// Initiate authentication
|
||||||
|
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +612,7 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
|||||||
// Set session values
|
// Set session values
|
||||||
session.SetCSRF(csrfToken)
|
session.SetCSRF(csrfToken)
|
||||||
session.SetNonce(nonce)
|
session.SetNonce(nonce)
|
||||||
session.SetIncomingPath(req.URL.Path)
|
session.SetIncomingPath(req.URL.RequestURI())
|
||||||
|
|
||||||
// Save the session
|
// Save the session
|
||||||
if err := session.Save(req, rw); err != nil {
|
if err := session.Save(req, rw); err != nil {
|
||||||
|
|||||||
@@ -1646,6 +1646,7 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to compare string slices
|
// Helper function to compare string slices
|
||||||
|
|
||||||
func stringSliceEqual(a, b []string) bool {
|
func stringSliceEqual(a, b []string) bool {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
return false
|
return false
|
||||||
@@ -1657,3 +1658,30 @@ func stringSliceEqual(a, b []string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultInitiateAuthentication_PreservesQueryParameters tests that defaultInitiateAuthentication preserves query parameters in the incoming path.
|
||||||
|
func TestDefaultInitiateAuthentication_PreservesQueryParameters(t *testing.T) {
|
||||||
|
ts := &TestSuite{t: t}
|
||||||
|
ts.Setup()
|
||||||
|
|
||||||
|
// Create a request with query parameters
|
||||||
|
req := httptest.NewRequest("GET", "/protected/resource?param1=value1¶m2=value2", nil)
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Get session
|
||||||
|
session, err := ts.sessionManager.GetSession(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call defaultInitiateAuthentication
|
||||||
|
redirectURL := "http://example.com/callback"
|
||||||
|
ts.tOidc.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
||||||
|
|
||||||
|
// Verify that the incoming path includes query parameters
|
||||||
|
incomingPath := session.GetIncomingPath()
|
||||||
|
expectedPath := "/protected/resource?param1=value1¶m2=value2"
|
||||||
|
if incomingPath != expectedPath {
|
||||||
|
t.Errorf("Expected incoming path to be '%s', got '%s'", expectedPath, incomingPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -111,6 +111,7 @@ type SessionManager struct {
|
|||||||
// - encryptionKey: Key used to encrypt session data (must be at least 32 bytes)
|
// - encryptionKey: Key used to encrypt session data (must be at least 32 bytes)
|
||||||
// - forceHTTPS: When true, forces secure cookie attributes regardless of request scheme
|
// - forceHTTPS: When true, forces secure cookie attributes regardless of request scheme
|
||||||
// - logger: Logger instance for recording session-related events
|
// - logger: Logger instance for recording session-related events
|
||||||
|
//
|
||||||
// The manager handles session creation, storage, and cookie security settings.
|
// The manager handles session creation, storage, and cookie security settings.
|
||||||
func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) *SessionManager {
|
func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) *SessionManager {
|
||||||
// Validate encryption key length
|
// Validate encryption key length
|
||||||
@@ -139,6 +140,7 @@ func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) *S
|
|||||||
// getSessionOptions returns secure session options configured for the current request.
|
// getSessionOptions returns secure session options configured for the current request.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - isSecure: Whether the current request is using HTTPS
|
// - isSecure: Whether the current request is using HTTPS
|
||||||
|
//
|
||||||
// The options ensure cookies are:
|
// The options ensure cookies are:
|
||||||
// - HTTP-only (not accessible via JavaScript)
|
// - HTTP-only (not accessible via JavaScript)
|
||||||
// - Secure when using HTTPS or when forceHTTPS is enabled
|
// - Secure when using HTTPS or when forceHTTPS is enabled
|
||||||
@@ -173,7 +175,7 @@ func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
|
|||||||
// Check for absolute session timeout
|
// Check for absolute session timeout
|
||||||
if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok {
|
if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok {
|
||||||
if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout {
|
if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout {
|
||||||
sessionData.Clear(r, nil) // Clear expired session
|
// Session has expired
|
||||||
sm.sessionPool.Put(sessionData)
|
sm.sessionPool.Put(sessionData)
|
||||||
return nil, fmt.Errorf("session expired")
|
return nil, fmt.Errorf("session expired")
|
||||||
}
|
}
|
||||||
@@ -557,6 +559,7 @@ func (sd *SessionData) SetRefreshToken(token string) {
|
|||||||
// Parameters:
|
// Parameters:
|
||||||
// - s: The string to split
|
// - s: The string to split
|
||||||
// - chunkSize: Maximum size of each chunk
|
// - chunkSize: Maximum size of each chunk
|
||||||
|
//
|
||||||
// Returns an array of string chunks, each no larger than chunkSize.
|
// Returns an array of string chunks, each no larger than chunkSize.
|
||||||
func splitIntoChunks(s string, chunkSize int) []string {
|
func splitIntoChunks(s string, chunkSize int) []string {
|
||||||
var chunks []string
|
var chunks []string
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ type Logger struct {
|
|||||||
// - "debug": Outputs all messages (debug, info, error)
|
// - "debug": Outputs all messages (debug, info, error)
|
||||||
// - "info": Outputs info and error messages
|
// - "info": Outputs info and error messages
|
||||||
// - "error": Outputs only error messages
|
// - "error": Outputs only error messages
|
||||||
|
//
|
||||||
// Error messages are always written to stderr, while info and debug
|
// Error messages are always written to stderr, while info and debug
|
||||||
// messages are written to stdout when enabled.
|
// messages are written to stdout when enabled.
|
||||||
func NewLogger(logLevel string) *Logger {
|
func NewLogger(logLevel string) *Logger {
|
||||||
|
|||||||
Reference in New Issue
Block a user