Merge pull request #18 from lukaszraczylo/additional-improvements

additional improvements
This commit is contained in:
2025-01-18 02:28:29 +00:00
committed by GitHub
5 changed files with 352 additions and 89 deletions
+126 -10
View File
@@ -83,13 +83,38 @@ func parseJWT(tokenString string) (*JWT, error) {
// It checks: // It checks:
// - issuer (iss) matches the expected issuer URL // - issuer (iss) matches the expected issuer URL
// - audience (aud) includes the client ID // - audience (aud) includes the client ID
// - expiration time (exp) is in the future // - expiration time (exp) is in the future (with clock skew tolerance)
// - issued at time (iat) is in the past // - issued at time (iat) 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
// 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
fmt.Printf("Validating token against:\nIssuer: %s\nClient ID: %s\n", issuerURL, clientID)
// Debug logging of token header
fmt.Printf("Token header: %+v\n", j.Header)
// Validate algorithm to prevent algorithm switching attacks
alg, ok := j.Header["alg"].(string)
if !ok {
return fmt.Errorf("missing 'alg' header")
}
// List of supported algorithms - should match those in verifySignature
supportedAlgs := map[string]bool{
"RS256": true, "RS384": true, "RS512": true,
"PS256": true, "PS384": true, "PS512": true,
"ES256": true, "ES384": true, "ES512": true,
}
if !supportedAlgs[alg] {
return fmt.Errorf("unsupported algorithm: %s", alg)
}
claims := j.Claims claims := j.Claims
// Debug logging of all claims
fmt.Printf("Token claims: %+v\n", claims)
iss, ok := claims["iss"].(string) iss, ok := claims["iss"].(string)
if !ok { if !ok {
return fmt.Errorf("missing 'iss' claim") return fmt.Errorf("missing 'iss' claim")
@@ -122,6 +147,19 @@ func (j *JWT) Verify(issuerURL, clientID string) error {
return err return err
} }
// Validate nbf (not before) claim if present
if nbf, ok := claims["nbf"].(float64); ok {
if err := verifyNotBefore(nbf); err != nil {
return err
}
}
// Validate jti (JWT ID) claim if present
if jti, ok := claims["jti"].(string); ok {
// Could add replay detection here if needed
_ = jti
}
sub, ok := claims["sub"].(string) sub, ok := claims["sub"].(string)
if !ok || sub == "" { if !ok || sub == "" {
return fmt.Errorf("missing or empty 'sub' claim") return fmt.Errorf("missing or empty 'sub' claim")
@@ -138,6 +176,10 @@ func (j *JWT) Verify(issuerURL, clientID string) error {
// - 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
fmt.Printf("Verifying audience:\nToken aud: %+v\nExpected: %s\n",
tokenAudience, expectedAudience)
switch aud := tokenAudience.(type) { switch aud := tokenAudience.(type) {
case string: case string:
if aud != expectedAudience { if aud != expectedAudience {
@@ -167,35 +209,106 @@ func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
// - 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
fmt.Printf("Verifying issuer:\nToken iss: %s\nExpected: %s\n",
tokenIssuer, expectedIssuer)
if tokenIssuer != expectedIssuer { if tokenIssuer != expectedIssuer {
return fmt.Errorf("invalid issuer") return fmt.Errorf("invalid issuer (token: %s, expected: %s)",
tokenIssuer, expectedIssuer)
} }
return nil return nil
} }
// Clock skew tolerance for time-based validations
const clockSkewTolerance = 2 * time.Minute
// verifyExpiration checks if the token's expiration time has passed. // verifyExpiration checks if the token's expiration time has passed.
// The expiration time is compared against the current time. // 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)
if time.Now().After(expirationTime) { // Truncate current time to seconds for consistent comparison
return fmt.Errorf("token has expired") now := time.Now().Truncate(time.Second)
skewedNow := now.Add(clockSkewTolerance)
// Debug logging
fmt.Printf("Token exp: %v\nCurrent time: %v\nSkewed time: %v\nSkew: %v\n",
expirationTime.UTC(),
now.UTC(),
skewedNow.UTC(),
clockSkewTolerance)
// Allow tokens that expire exactly now
if expirationTime.Equal(now) {
return nil
}
if skewedNow.After(expirationTime) {
return fmt.Errorf("token has expired (exp: %v, now: %v)",
expirationTime.UTC(), now.UTC())
} }
return nil return nil
} }
// verifyIssuedAt validates the token's issued-at time. // verifyIssuedAt validates the token's issued-at time.
// Ensures the token wasn't issued in the future, which could // Ensures the token wasn't issued in the future, accounting for clock skew.
// indicate clock skew or a malicious token.
// 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)
if time.Now().Before(issuedAtTime) { // Truncate current time to seconds for consistent comparison
return fmt.Errorf("token used before issued") now := time.Now().Truncate(time.Second)
skewedNow := now.Add(-clockSkewTolerance)
// Debug logging
fmt.Printf("Token iat: %v\nCurrent time: %v\nSkewed time: %v\nSkew: %v\n",
issuedAtTime.UTC(),
now.UTC(),
skewedNow.UTC(),
clockSkewTolerance)
// Allow tokens issued in the same second as current time
if issuedAtTime.Equal(now) {
return nil
}
if skewedNow.Before(issuedAtTime) {
return fmt.Errorf("token used before issued (iat: %v, now: %v)",
issuedAtTime.UTC(), now.UTC())
}
return nil
}
// verifyNotBefore validates the token's not-before time if present.
// Ensures the token is not used before its valid time period, accounting for clock skew.
// Parameters:
// - notBefore: The not-before timestamp from the token
// Returns an error if the token is not yet valid.
func verifyNotBefore(notBefore float64) error {
notBeforeTime := time.Unix(int64(notBefore), 0)
// Truncate current time to seconds for consistent comparison
now := time.Now().Truncate(time.Second)
skewedNow := now.Add(-clockSkewTolerance)
// Debug logging
fmt.Printf("Token nbf: %v\nCurrent time: %v\nSkewed time: %v\nSkew: %v\n",
notBeforeTime.UTC(),
now.UTC(),
skewedNow.UTC(),
clockSkewTolerance)
// Allow tokens that become valid exactly now
if notBeforeTime.Equal(now) {
return nil
}
if skewedNow.Before(notBeforeTime) {
return fmt.Errorf("token not yet valid (nbf: %v, now: %v)",
notBeforeTime.UTC(), now.UTC())
} }
return nil return nil
} }
@@ -211,6 +324,9 @@ func verifyIssuedAt(issuedAt float64) error {
// - 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
fmt.Printf("Verifying signature with algorithm: %s\n", alg)
// Split the token into its three parts // Split the token into its three parts
parts := strings.Split(tokenString, ".") parts := strings.Split(tokenString, ".")
if len(parts) != 3 { if len(parts) != 3 {
+40 -16
View File
@@ -67,21 +67,29 @@ func (ts *TestSuite) Setup() {
} }
// Create a test JWT token signed with the RSA private key // Create a test JWT token signed with the RSA private key
// Create timestamps with proper clock skew
now := time.Now()
exp := now.Add(1 * time.Hour).Unix()
iat := now.Add(-2 * time.Minute).Unix() // Account for clock skew
nbf := now.Add(-2 * time.Minute).Unix() // Account for clock skew
ts.token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ ts.token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(), "exp": exp,
"iat": time.Now().Unix(), "iat": iat,
"nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"email": "[email protected]", "email": "[email protected]",
"nonce": "test-nonce", "nonce": "test-nonce",
"jti": generateRandomString(16),
}) })
if err != nil { if err != nil {
ts.t.Fatalf("Failed to create test JWT: %v", err) ts.t.Fatalf("Failed to create test JWT: %v", err)
} }
logger := NewLogger("info") logger := NewLogger("info")
ts.sessionManager = NewSessionManager("test-secret-key", false, logger) ts.sessionManager = NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
// Common TraefikOidc instance // Common TraefikOidc instance
ts.tOidc = &TraefikOidc{ ts.tOidc = &TraefikOidc{
@@ -611,7 +619,7 @@ func TestHandleCallback(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
logger := NewLogger("info") logger := NewLogger("info")
sessionManager := NewSessionManager("test-secret-key", false, logger) sessionManager := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
// Create a new instance for each test to avoid state carryover // Create a new instance for each test to avoid state carryover
tOidc := &TraefikOidc{ tOidc := &TraefikOidc{
@@ -916,7 +924,7 @@ func TestHandleLogout(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
logger := NewLogger("info") logger := NewLogger("info")
sessionManager := NewSessionManager("test-secret-key", false, logger) sessionManager := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
tOidc := &TraefikOidc{ tOidc := &TraefikOidc{
revocationURL: mockRevocationServer.URL, revocationURL: mockRevocationServer.URL,
endSessionURL: tc.endSessionURL, endSessionURL: tc.endSessionURL,
@@ -1205,7 +1213,7 @@ func TestHandleExpiredToken(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
logger := NewLogger("info") logger := NewLogger("info")
sessionManager := NewSessionManager("test-secret-key", false, logger) sessionManager := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
tOidc := &TraefikOidc{ tOidc := &TraefikOidc{
sessionManager: sessionManager, sessionManager: sessionManager,
@@ -1440,6 +1448,12 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
ts := &TestSuite{t: t} ts := &TestSuite{t: t}
ts.Setup() ts.Setup()
// Create consistent timestamps for all test cases
now := time.Now()
exp := now.Add(1 * time.Hour).Unix()
iat := now.Add(-2 * time.Minute).Unix() // Account for clock skew
nbf := now.Add(-2 * time.Minute).Unix() // Account for clock skew
tests := []struct { tests := []struct {
name string name string
allowedRolesAndGroups map[string]struct{} allowedRolesAndGroups map[string]struct{}
@@ -1456,11 +1470,13 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
claims: map[string]interface{}{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(), "exp": exp,
"iat": time.Now().Unix(), "iat": iat,
"nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"roles": []interface{}{"admin", "user"}, "roles": []interface{}{"admin", "user"},
"groups": []interface{}{"group1"}, "groups": []interface{}{"group1"},
"jti": generateRandomString(16),
}, },
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
@@ -1480,11 +1496,13 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
claims: map[string]interface{}{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(), "exp": exp,
"iat": time.Now().Unix(), "iat": iat,
"nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"roles": []interface{}{"user"}, "roles": []interface{}{"user"},
"groups": []interface{}{"allowed-group"}, "groups": []interface{}{"allowed-group"},
"jti": generateRandomString(16),
}, },
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
@@ -1505,11 +1523,13 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
claims: map[string]interface{}{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(), "exp": exp,
"iat": time.Now().Unix(), "iat": iat,
"nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"roles": []interface{}{"user"}, "roles": []interface{}{"user"},
"groups": []interface{}{"regular-group"}, "groups": []interface{}{"regular-group"},
"jti": generateRandomString(16),
}, },
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
@@ -1523,11 +1543,13 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
claims: map[string]interface{}{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(), "exp": exp,
"iat": time.Now().Unix(), "iat": iat,
"nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"roles": []interface{}{"user"}, "roles": []interface{}{"user"},
"groups": []interface{}{"regular-group"}, "groups": []interface{}{"regular-group"},
"jti": generateRandomString(16),
}, },
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
@@ -1545,9 +1567,11 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
claims: map[string]interface{}{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(), "exp": exp,
"iat": time.Now().Unix(), "iat": iat,
"nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"jti": generateRandomString(16),
}, },
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
+64 -18
View File
@@ -3,30 +3,37 @@ package traefikoidc
import ( import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
) )
// generateSecureRandomString creates a cryptographically secure random string of specified length
func generateSecureRandomString(length int) string {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
panic("failed to generate random string")
}
return hex.EncodeToString(bytes)
}
// Cookie names and configuration constants used for session management // Cookie names and configuration constants used for session management
var (
// Using random prefixes to make cookie names less predictable
mainCookieName = "_oidc_m_" + generateSecureRandomString(8)
accessTokenCookie = "_oidc_a_" + generateSecureRandomString(8)
refreshTokenCookie = "_oidc_r_" + generateSecureRandomString(8)
)
const ( const (
// mainCookieName is the name of the main session cookie that stores authentication state
// and basic user information like email and CSRF tokens
mainCookieName = "_raczylo_oidc"
// accessTokenCookie is the name of the cookie that stores the OIDC access token
// This may be split into multiple cookies if the token is large
accessTokenCookie = "_raczylo_oidc_access"
// refreshTokenCookie is the name of the cookie that stores the OIDC refresh token
// This may be split into multiple cookies if the token is large
refreshTokenCookie = "_raczylo_oidc_refresh"
// maxCookieSize is the maximum size for each cookie chunk. // maxCookieSize is the maximum size for each cookie chunk.
// This value is calculated to ensure the final cookie size stays within browser limits: // This value is calculated to ensure the final cookie size stays within browser limits:
// 1. Browser cookie size limit is typically 4096 bytes // 1. Browser cookie size limit is typically 4096 bytes
@@ -39,6 +46,13 @@ const (
// - Solving for x: x ≤ 3044 // - Solving for x: x ≤ 3044
// 4. We use 2000 as a conservative limit to account for cookie metadata // 4. We use 2000 as a conservative limit to account for cookie metadata
maxCookieSize = 2000 maxCookieSize = 2000
// absoluteSessionTimeout defines the maximum lifetime of a session
// regardless of activity (24 hours)
absoluteSessionTimeout = 24 * time.Hour
// minEncryptionKeyLength defines the minimum length for the encryption key
minEncryptionKeyLength = 32
) )
// compressToken compresses a token using gzip and base64 encodes it // compressToken compresses a token using gzip and base64 encodes it
@@ -99,6 +113,11 @@ type SessionManager struct {
// - 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
if len(encryptionKey) < minEncryptionKeyLength {
panic(fmt.Sprintf("encryption key must be at least %d bytes long", minEncryptionKeyLength))
}
sm := &SessionManager{ sm := &SessionManager{
store: sessions.NewCookieStore([]byte(encryptionKey)), store: sessions.NewCookieStore([]byte(encryptionKey)),
forceHTTPS: forceHTTPS, forceHTTPS: forceHTTPS,
@@ -130,7 +149,7 @@ func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
HttpOnly: true, HttpOnly: true,
Secure: isSecure || sm.forceHTTPS, Secure: isSecure || sm.forceHTTPS,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: ConstSessionTimeout, MaxAge: int(absoluteSessionTimeout.Seconds()),
Path: "/", Path: "/",
} }
} }
@@ -151,6 +170,15 @@ func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
return nil, fmt.Errorf("failed to get main session: %w", err) return nil, fmt.Errorf("failed to get main session: %w", err)
} }
// Check for absolute session timeout
if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok {
if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout {
sessionData.Clear(r, nil) // Clear expired session
sm.sessionPool.Put(sessionData)
return nil, fmt.Errorf("session expired")
}
}
sessionData.accessSession, err = sm.store.Get(r, accessTokenCookie) sessionData.accessSession, err = sm.store.Get(r, accessTokenCookie)
if err != nil { if err != nil {
sm.sessionPool.Put(sessionData) sm.sessionPool.Put(sessionData)
@@ -294,7 +322,10 @@ func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error {
sd.clearTokenChunks(r, sd.accessTokenChunks) sd.clearTokenChunks(r, sd.accessTokenChunks)
sd.clearTokenChunks(r, sd.refreshTokenChunks) sd.clearTokenChunks(r, sd.refreshTokenChunks)
err := sd.Save(r, w) var err error
if w != nil {
err = sd.Save(r, w)
}
// Return session to pool // Return session to pool
sd.manager.sessionPool.Put(sd) sd.manager.sessionPool.Put(sd)
@@ -315,16 +346,31 @@ func (sd *SessionData) clearTokenChunks(r *http.Request, chunks map[int]*session
} }
// GetAuthenticated returns whether the current session is authenticated. // GetAuthenticated returns whether the current session is authenticated.
// Returns true if the user has successfully completed OIDC authentication, // Returns true if the user has successfully completed OIDC authentication
// false otherwise or if the authentication status cannot be determined. // and the session hasn't expired, false otherwise.
func (sd *SessionData) GetAuthenticated() bool { func (sd *SessionData) GetAuthenticated() bool {
auth, _ := sd.mainSession.Values["authenticated"].(bool) auth, _ := sd.mainSession.Values["authenticated"].(bool)
return auth if !auth {
return false
}
// Check session expiration
createdAt, ok := sd.mainSession.Values["created_at"].(int64)
if !ok {
return false
}
return time.Since(time.Unix(createdAt, 0)) <= absoluteSessionTimeout
} }
// SetAuthenticated updates the session's authentication status. // SetAuthenticated updates the session's authentication status and rotates session ID.
// This should be called after successful OIDC authentication or during logout. // This should be called after successful OIDC authentication or during logout.
// Session ID rotation helps prevent session fixation attacks.
func (sd *SessionData) SetAuthenticated(value bool) { func (sd *SessionData) SetAuthenticated(value bool) {
if value {
// Generate new session ID and set creation time
sd.mainSession.ID = generateSecureRandomString(32)
sd.mainSession.Values["created_at"] = time.Now().Unix()
}
sd.mainSession.Values["authenticated"] = value sd.mainSession.Values["authenticated"] = value
} }
+73 -31
View File
@@ -10,10 +10,6 @@ import (
"strings" "strings"
) )
const (
cookieName = "_raczylo_oidc"
)
// Config holds the configuration for the OIDC middleware. // Config holds the configuration for the OIDC middleware.
// It provides all necessary settings to configure OpenID Connect authentication // It provides all necessary settings to configure OpenID Connect authentication
// with various providers like Auth0, Logto, or any standard OIDC provider. // with various providers like Auth0, Logto, or any standard OIDC provider.
@@ -85,30 +81,34 @@ type Config struct {
HTTPClient *http.Client HTTPClient *http.Client
} }
// CreateConfig creates a new Config with sensible default values. const (
// DefaultRateLimit defines the default rate limit for requests per second
DefaultRateLimit = 100
// MinRateLimit defines the minimum allowed rate limit to prevent DOS
MinRateLimit = 10
// DefaultLogLevel defines the default logging level
DefaultLogLevel = "info"
// MinSessionEncryptionKeyLength defines the minimum length for session encryption key
MinSessionEncryptionKeyLength = 32
)
// CreateConfig creates a new Config with secure default values.
// Default values are set for optional fields: // Default values are set for optional fields:
// - Scopes: ["openid", "profile", "email"] // - Scopes: ["openid", "profile", "email"]
// - LogLevel: "info" // - LogLevel: "info"
// - LogoutURL: CallbackURL + "/logout" // - LogoutURL: CallbackURL + "/logout"
// - RateLimit: 100 requests per second // - RateLimit: 100 requests per second
// - PostLogoutRedirectURI: "/" // - PostLogoutRedirectURI: "/"
// - ForceHTTPS: true (for security)
func CreateConfig() *Config { func CreateConfig() *Config {
c := &Config{} c := &Config{
Scopes: []string{"openid", "profile", "email"},
if c.Scopes == nil { LogLevel: DefaultLogLevel,
c.Scopes = []string{"openid", "profile", "email"} RateLimit: DefaultRateLimit,
} ForceHTTPS: true, // Secure by default
if c.LogLevel == "" {
c.LogLevel = "info"
}
if c.LogoutURL == "" {
c.LogoutURL = c.CallbackURL + "/logout"
}
if c.RateLimit == 0 {
c.RateLimit = 100
} }
return c return c
@@ -118,43 +118,85 @@ func CreateConfig() *Config {
// It ensures all required fields are set and have valid values. // It ensures all required fields are set and have valid values.
// Returns an error if any validation check fails. // Returns an error if any validation check fails.
func (c *Config) Validate() error { func (c *Config) Validate() error {
// Validate provider URL
if c.ProviderURL == "" { if c.ProviderURL == "" {
return fmt.Errorf("providerURL is required") return fmt.Errorf("providerURL is required")
} }
if !isValidURL(c.ProviderURL) { if !isValidSecureURL(c.ProviderURL) {
return fmt.Errorf("providerURL must be a valid URL") return fmt.Errorf("providerURL must be a valid HTTPS URL")
} }
// Validate callback URL
if c.CallbackURL == "" { if c.CallbackURL == "" {
return fmt.Errorf("callbackURL is required") return fmt.Errorf("callbackURL is required")
} }
if !strings.HasPrefix(c.CallbackURL, "/") { if !strings.HasPrefix(c.CallbackURL, "/") {
return fmt.Errorf("callbackURL must start with /") return fmt.Errorf("callbackURL must start with /")
} }
// Validate client credentials
if c.ClientID == "" { if c.ClientID == "" {
return fmt.Errorf("clientID is required") return fmt.Errorf("clientID is required")
} }
if c.ClientSecret == "" { if c.ClientSecret == "" {
return fmt.Errorf("clientSecret is required") return fmt.Errorf("clientSecret is required")
} }
// Validate session encryption key
if c.SessionEncryptionKey == "" { if c.SessionEncryptionKey == "" {
return fmt.Errorf("sessionEncryptionKey is required") return fmt.Errorf("sessionEncryptionKey is required")
} }
if len(c.SessionEncryptionKey) < 32 { if len(c.SessionEncryptionKey) < MinSessionEncryptionKeyLength {
return fmt.Errorf("sessionEncryptionKey must be at least 32 characters long") return fmt.Errorf("sessionEncryptionKey must be at least %d characters long", MinSessionEncryptionKeyLength)
}
if c.RateLimit < 0 {
return fmt.Errorf("rateLimit must be non-negative")
} }
// Validate log level
if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) { if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) {
return fmt.Errorf("logLevel must be one of: debug, info, error") return fmt.Errorf("logLevel must be one of: debug, info, error")
} }
// Validate excluded URLs
for _, url := range c.ExcludedURLs {
if !strings.HasPrefix(url, "/") {
return fmt.Errorf("excluded URL must start with /: %s", url)
}
if strings.Contains(url, "..") {
return fmt.Errorf("excluded URL must not contain path traversal: %s", url)
}
if strings.Contains(url, "*") {
return fmt.Errorf("excluded URL must not contain wildcards: %s", url)
}
}
// Validate revocation URL if set
if c.RevocationURL != "" && !isValidSecureURL(c.RevocationURL) {
return fmt.Errorf("revocationURL must be a valid HTTPS URL")
}
// Validate end session URL if set
if c.OIDCEndSessionURL != "" && !isValidSecureURL(c.OIDCEndSessionURL) {
return fmt.Errorf("oidcEndSessionURL must be a valid HTTPS URL")
}
// Validate post-logout redirect URI if set
if c.PostLogoutRedirectURI != "" && c.PostLogoutRedirectURI != "/" {
if !isValidSecureURL(c.PostLogoutRedirectURI) && !strings.HasPrefix(c.PostLogoutRedirectURI, "/") {
return fmt.Errorf("postLogoutRedirectURI must be either a valid HTTPS URL or start with /")
}
}
// Validate rate limit
if c.RateLimit < MinRateLimit {
return fmt.Errorf("rateLimit must be at least %d", MinRateLimit)
}
return nil return nil
} }
// isValidURL checks if the provided string is a valid URL // isValidSecureURL checks if the provided string is a valid HTTPS URL
func isValidURL(s string) bool { func isValidSecureURL(s string) bool {
u, err := url.Parse(s) u, err := url.Parse(s)
return err == nil && u.Scheme != "" && u.Host != "" return err == nil && u.Scheme == "https" && u.Host != ""
} }
// isValidLogLevel checks if the provided log level is valid // isValidLogLevel checks if the provided log level is valid
+48 -13
View File
@@ -23,13 +23,18 @@ func TestCreateConfig(t *testing.T) {
} }
// Check default log level // Check default log level
if config.LogLevel != "info" { if config.LogLevel != DefaultLogLevel {
t.Errorf("Expected default log level 'info', got '%s'", config.LogLevel) t.Errorf("Expected default log level '%s', got '%s'", DefaultLogLevel, config.LogLevel)
} }
// Check default rate limit // Check default rate limit
if config.RateLimit != 100 { if config.RateLimit != DefaultRateLimit {
t.Errorf("Expected default rate limit 100, got %d", config.RateLimit) t.Errorf("Expected default rate limit %d, got %d", DefaultRateLimit, config.RateLimit)
}
// Check ForceHTTPS default
if !config.ForceHTTPS {
t.Error("Expected ForceHTTPS to be true by default")
} }
}) })
@@ -38,6 +43,7 @@ func TestCreateConfig(t *testing.T) {
config.Scopes = []string{"custom_scope"} config.Scopes = []string{"custom_scope"}
config.LogLevel = "debug" config.LogLevel = "debug"
config.RateLimit = 50 config.RateLimit = 50
config.ForceHTTPS = false
// Verify custom values are not overwritten // Verify custom values are not overwritten
if len(config.Scopes) != 1 || config.Scopes[0] != "custom_scope" { if len(config.Scopes) != 1 || config.Scopes[0] != "custom_scope" {
@@ -49,6 +55,9 @@ func TestCreateConfig(t *testing.T) {
if config.RateLimit != 50 { if config.RateLimit != 50 {
t.Error("Custom rate limit was overwritten") t.Error("Custom rate limit was overwritten")
} }
if config.ForceHTTPS {
t.Error("Custom ForceHTTPS value was overwritten")
}
}) })
} }
@@ -98,15 +107,15 @@ func TestConfigValidate(t *testing.T) {
expectedError: "sessionEncryptionKey is required", expectedError: "sessionEncryptionKey is required",
}, },
{ {
name: "Invalid ProviderURL", name: "Non-HTTPS ProviderURL",
config: &Config{ config: &Config{
ProviderURL: "not-a-url", ProviderURL: "http://provider.com",
CallbackURL: "/callback", CallbackURL: "/callback",
ClientID: "client-id", ClientID: "client-id",
ClientSecret: "client-secret", ClientSecret: "client-secret",
SessionEncryptionKey: "encryption-key", SessionEncryptionKey: "encryption-key",
}, },
expectedError: "providerURL must be a valid URL", expectedError: "providerURL must be a valid HTTPS URL",
}, },
{ {
name: "Invalid CallbackURL", name: "Invalid CallbackURL",
@@ -131,16 +140,16 @@ func TestConfigValidate(t *testing.T) {
expectedError: "sessionEncryptionKey must be at least 32 characters long", expectedError: "sessionEncryptionKey must be at least 32 characters long",
}, },
{ {
name: "Negative RateLimit", name: "Low RateLimit",
config: &Config{ config: &Config{
ProviderURL: "https://provider.com", ProviderURL: "https://provider.com",
CallbackURL: "/callback", CallbackURL: "/callback",
ClientID: "client-id", ClientID: "client-id",
ClientSecret: "client-secret", ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key", SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
RateLimit: -1, RateLimit: 5,
}, },
expectedError: "rateLimit must be non-negative", expectedError: "rateLimit must be at least 10",
}, },
{ {
name: "Invalid LogLevel", name: "Invalid LogLevel",
@@ -154,6 +163,30 @@ func TestConfigValidate(t *testing.T) {
}, },
expectedError: "logLevel must be one of: debug, info, error", expectedError: "logLevel must be one of: debug, info, error",
}, },
{
name: "Non-HTTPS RevocationURL",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
RevocationURL: "http://revoke.com",
},
expectedError: "revocationURL must be a valid HTTPS URL",
},
{
name: "Non-HTTPS OIDCEndSessionURL",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
OIDCEndSessionURL: "http://endsession.com",
},
expectedError: "oidcEndSessionURL must be a valid HTTPS URL",
},
{ {
name: "Valid Config", name: "Valid Config",
config: &Config{ config: &Config{
@@ -164,6 +197,8 @@ func TestConfigValidate(t *testing.T) {
SessionEncryptionKey: "this-is-a-long-enough-encryption-key", SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
LogLevel: "debug", LogLevel: "debug",
RateLimit: 100, RateLimit: 100,
RevocationURL: "https://revoke.com",
OIDCEndSessionURL: "https://endsession.com",
}, },
expectedError: "", expectedError: "",
}, },
@@ -192,9 +227,9 @@ func TestLogger(t *testing.T) {
var debugBuf, infoBuf, errorBuf bytes.Buffer var debugBuf, infoBuf, errorBuf bytes.Buffer
tests := []struct { tests := []struct {
name string name string
logLevel string logLevel string
testFunc func(*Logger) testFunc func(*Logger)
checkFunc func(t *testing.T, debugOut, infoOut, errorOut string) checkFunc func(t *testing.T, debugOut, infoOut, errorOut string)
}{ }{
{ {