Compare commits

..
24 Commits
Author SHA1 Message Date
lukaszraczylo ab36f10a70 Draft. 2024-10-13 18:21:13 +01:00
lukaszraczylo 4972b21373 Improve speed of the cache module. 2024-10-13 18:00:43 +01:00
lukaszraczyloandGitHub 0be45f06a5 Merge pull request #10 from lukaszraczylo/code-improvements
Code improvements
2024-10-09 09:34:45 +01:00
lukaszraczylo 2cc89f0f31 fixup! Update dependencies. 2024-10-09 09:33:20 +01:00
lukaszraczylo b9928f2f0c Update dependencies. 2024-10-09 09:32:25 +01:00
lukaszraczylo 2e1a3a9320 Add ability to verify default ECDSA keys provided by logto as well. 2024-10-09 09:30:42 +01:00
lukaszraczylo 9dabd0e5cf Revert "Update go mod dependencies."
This reverts commit dedbdf63c3.
2024-10-09 09:11:07 +01:00
lukaszraczylo dedbdf63c3 Update go mod dependencies. 2024-10-09 09:07:25 +01:00
lukaszraczylo af032c6cd3 Add simple benchmark to track the allocations and speed for future improvements. 2024-10-08 14:41:43 +01:00
lukaszraczylo 9938cff053 fixup! Cleanup and optimise the code. 2024-10-08 14:26:26 +01:00
lukaszraczylo 7a404ef76f Cleanup and optimise the code. 2024-10-08 14:14:47 +01:00
lukaszraczylo 63922f362f fixup! Add support for more algorithms. 2024-10-07 16:07:07 +01:00
lukaszraczylo 2de9297ab6 Add support for more algorithms. 2024-10-07 16:01:07 +01:00
lukaszraczylo 971c84f762 Abstract filling up maps. 2024-10-07 15:56:24 +01:00
lukaszraczylo d2a0d2167e Fix the bug with user not being redirected to originally requested URL post authentication. 2024-10-05 09:33:56 +01:00
lukaszraczylo c46d958397 Update documentation - setting secrets in kubernetes. 2024-10-04 17:15:43 +01:00
lukaszraczylo 95cf0034d6 Fix the tests hanging on the open channel. 2024-10-04 14:39:37 +01:00
lukaszraczylo 380ef96571 Improvement - startup time.
Previous implementations blocked the traefik startup until OIDC plugin was loaded.
This caused chicken-or-egg issue when called OIDC endpoint was hosted by the same traefik as well,
generating rather ridiculous situation when traefik couldn't come up because plugin tried to call the
discovery endpoint which was hosted by the same traefik.

This version resolves the issue allowing for quickstart and lazy loading of the provider metadata.
Disadvantage is - until discovery is done, the plugin will not provide any access to the client.
2024-10-04 14:22:16 +01:00
lukaszraczylo 1886396dc1 First step in improvement of caching mechanism. 2024-10-04 14:05:12 +01:00
lukaszraczylo 24ecf00053 Add support for roles and groups. 2024-10-03 19:48:43 +01:00
lukaszraczylo e338992f84 Update tests and additional fixups. 2024-10-03 14:00:47 +01:00
lukaszraczylo a9a596031b Update the tests to handle nonce 2024-10-03 14:00:44 +01:00
lukaszraczylo 23afcad2ba Support additional verification of the token to ensure OIDC compliance 2024-10-03 14:00:44 +01:00
lukaszraczylo d06f9fcf90 Update dependencies. 2024-10-03 14:00:44 +01:00
20 changed files with 607 additions and 1458 deletions
-1
View File
@@ -13,7 +13,6 @@ testData:
clientSecret: secret clientSecret: secret
callbackURL: /oauth2/callback callbackURL: /oauth2/callback
logoutURL: /oauth2/logout logoutURL: /oauth2/logout
postLogoutRedirectURI: /oidc/different-logout # If not provided it will redirect to the "/" URL
scopes: # If not provided, default scopes will be used (openid, email, profile) scopes: # If not provided, default scopes will be used (openid, email, profile)
- openid - openid
- email - email
-1
View File
@@ -38,7 +38,6 @@ spec:
sessionEncryptionKey: vvv sessionEncryptionKey: vvv
callbackURL: /cool-oidc/callback callbackURL: /cool-oidc/callback
logoutURL: /cool-oidc/logout logoutURL: /cool-oidc/logout
postLogoutRedirectURI: /my-website/you-have-logged-out # Optional post logout URL redirection
scopes: scopes:
- openid - openid
- email - email
+14 -10
View File
@@ -8,7 +8,7 @@ import (
// CacheItem represents an item in the cache // CacheItem represents an item in the cache
type CacheItem struct { type CacheItem struct {
Value interface{} Value interface{}
ExpiresAt time.Time ExpiresAt int64 // Changed to int64 for faster comparisons
} }
// Cache is a simple in-memory cache // Cache is a simple in-memory cache
@@ -27,43 +27,47 @@ func NewCache() *Cache {
// Set adds an item to the cache // Set adds an item to the cache
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()
defer c.mutex.Unlock() // Removed defer for slightly better performance
c.items[key] = CacheItem{ c.items[key] = CacheItem{
Value: value, Value: value,
ExpiresAt: time.Now().Add(expiration), ExpiresAt: time.Now().Add(expiration).UnixNano(), // Store as UnixNano for faster comparisons
} }
c.mutex.Unlock()
} }
// Get retrieves an item from the cache // Get retrieves an item from the cache
func (c *Cache) Get(key string) (interface{}, bool) { func (c *Cache) Get(key string) (interface{}, bool) {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock()
item, found := c.items[key] item, found := c.items[key]
if !found { if !found {
c.mutex.RUnlock()
return nil, false return nil, false
} }
if time.Now().After(item.ExpiresAt) { if time.Now().UnixNano() > item.ExpiresAt {
delete(c.items, key) c.mutex.RUnlock()
// Use a separate goroutine to delete expired items to avoid blocking
go c.Delete(key)
return nil, false return nil, false
} }
c.mutex.RUnlock()
return item.Value, true return item.Value, true
} }
// Delete removes an item from the cache // Delete removes an item from the cache
func (c *Cache) Delete(key string) { func (c *Cache) Delete(key string) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock()
delete(c.items, key) delete(c.items, key)
c.mutex.Unlock()
} }
// Cleanup removes expired items from the cache // Cleanup removes expired items from the cache
func (c *Cache) Cleanup() { func (c *Cache) Cleanup() {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() now := time.Now().UnixNano()
now := time.Now()
for key, item := range c.items { for key, item := range c.items {
if now.After(item.ExpiresAt) { if now > item.ExpiresAt {
delete(c.items, key) delete(c.items, key)
} }
} }
c.mutex.Unlock()
} }
+1 -1
View File
@@ -6,7 +6,7 @@ toolchain go1.23.1
require ( require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.3.0 github.com/gorilla/sessions v1.4.0
golang.org/x/time v0.7.0 golang.org/x/time v0.7.0
) )
+4
View File
@@ -6,5 +6,9 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg= github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+125 -175
View File
@@ -13,29 +13,27 @@ import (
"sync" "sync"
"time" "time"
"github.com/google/uuid"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
) )
func newSessionOptions(isSecure bool) *sessions.Options {
return &sessions.Options{
HttpOnly: true,
Secure: isSecure,
SameSite: http.SameSiteLaxMode,
MaxAge: ConstSessionTimeout,
Path: "/",
}
}
// generateNonce generates a random nonce // generateNonce generates a random nonce
func generateNonce() (string, error) { func generateNonce() (string, error) {
nonceBytes := make([]byte, 32) nonceBytes := make([]byte, 32)
_, err := rand.Read(nonceBytes) if _, err := rand.Read(nonceBytes); err != nil {
if err != nil {
return "", fmt.Errorf("could not generate nonce: %w", err) return "", fmt.Errorf("could not generate nonce: %w", err)
} }
return base64.URLEncoding.EncodeToString(nonceBytes), nil return base64.URLEncoding.EncodeToString(nonceBytes), nil
} }
// buildFullURL constructs a full URL from scheme, host, and path
func buildFullURL(scheme, host, path string) string {
if scheme == "" {
scheme = "http"
}
return fmt.Sprintf("%s://%s%s", scheme, host, path)
}
// exchangeTokens exchanges a code or refresh token for tokens // exchangeTokens exchanges a code or refresh token for tokens
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType, codeOrToken, redirectURL string) (*TokenResponse, error) { func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType, codeOrToken, redirectURL string) (*TokenResponse, error) {
data := url.Values{ data := url.Values{
@@ -44,14 +42,15 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType, codeOrToken
"client_secret": {t.clientSecret}, "client_secret": {t.clientSecret},
} }
if grantType == "authorization_code" { switch grantType {
case "authorization_code":
data.Set("code", codeOrToken) data.Set("code", codeOrToken)
data.Set("redirect_uri", redirectURL) data.Set("redirect_uri", redirectURL)
} else if grantType == "refresh_token" { case "refresh_token":
data.Set("refresh_token", codeOrToken) data.Set("refresh_token", codeOrToken)
} }
req, err := http.NewRequestWithContext(ctx, "POST", t.tokenURL, strings.NewReader(data.Encode())) req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.tokenURL, strings.NewReader(data.Encode()))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err) return nil, fmt.Errorf("failed to create token request: %w", err)
} }
@@ -98,22 +97,72 @@ func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenRe
return tokenResponse, nil return tokenResponse, nil
} }
// handleLogout handles the user logout
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
session, err := t.store.Get(req, cookieName)
t.logger.Debugf("Logging out user")
if err != nil {
handleError(rw, "Session error", http.StatusInternalServerError, t.logger)
return
}
// Revoke tokens if available
for _, tokenType := range []string{"refresh_token", "access_token"} {
if token, ok := session.Values[tokenType].(string); ok && token != "" {
if err := t.RevokeTokenWithProvider(token, tokenType); err != nil {
t.logger.Errorf("Failed to revoke %s: %v", tokenType, err)
}
t.RevokeToken(token)
}
delete(session.Values, tokenType)
}
// Remove other session values
delete(session.Values, "id_token")
delete(session.Values, "authenticated")
// Set session options to delete the session
session.Options = &sessions.Options{MaxAge: -1, Path: "/", HttpOnly: true, Secure: true}
if err := session.Save(req, rw); err != nil {
handleError(rw, "Failed to save session", http.StatusInternalServerError, t.logger)
return
}
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("Logged out successfully"))
}
// handleExpiredToken handles the case when a token has expired // handleExpiredToken handles the case when a token has expired
func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Request, session *sessions.Session) {
if session == nil {
t.logger.Error("Session is nil in handleExpiredToken")
http.Error(rw, "Internal server error", http.StatusInternalServerError)
return
}
// Clear the existing session // Clear the existing session
if err := session.Clear(req, rw); err != nil { for k := range session.Values {
t.logger.Errorf("Failed to clear session: %v", err) delete(session.Values, k)
}
// Set new values
session.Values["csrf"] = uuid.New().String()
session.Values["incoming_path"] = req.URL.Path
session.Values["nonce"], _ = generateNonce()
session.Options = &sessions.Options{MaxAge: 3600, Path: "/", HttpOnly: true, Secure: true}
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save session: %v", err)
http.Error(rw, "Internal Server Error", http.StatusInternalServerError) http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
return return
} }
// Initialize new authentication t.initiateAuthenticationFunc(rw, req, session, t.redirectURL)
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
} }
// handleCallback handles the callback from the OIDC provider // handleCallback handles the callback from the OIDC provider
func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) { func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request) {
session, err := t.sessionManager.GetSession(req) session, err := t.store.Get(req, cookieName)
if err != nil { if err != nil {
t.logger.Errorf("Session error: %v", err) t.logger.Errorf("Session error: %v", err)
http.Error(rw, "Session error", http.StatusInternalServerError) http.Error(rw, "Session error", http.StatusInternalServerError)
@@ -122,36 +171,21 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
t.logger.Debugf("Handling callback, URL: %s", req.URL.String()) t.logger.Debugf("Handling callback, URL: %s", req.URL.String())
// Check for errors in the query parameters if errParam := req.URL.Query().Get("error"); errParam != "" {
if req.URL.Query().Get("error") != "" {
errorDescription := req.URL.Query().Get("error_description") errorDescription := req.URL.Query().Get("error_description")
t.logger.Errorf("Authentication error: %s - %s", req.URL.Query().Get("error"), errorDescription) t.logger.Errorf("Authentication error: %s - %s", errParam, errorDescription)
http.Error(rw, fmt.Sprintf("Authentication error: %s", errorDescription), http.StatusBadRequest) http.Error(rw, fmt.Sprintf("Authentication error: %s", errorDescription), http.StatusBadRequest)
return return
} }
// Validate state parameter matches the session's CSRF token
state := req.URL.Query().Get("state") state := req.URL.Query().Get("state")
if state == "" { csrfToken, ok := session.Values["csrf"].(string)
t.logger.Error("No state in callback") if !ok || state == "" || csrfToken == "" || state != csrfToken {
http.Error(rw, "State parameter missing in callback", http.StatusBadRequest) t.logger.Error("Invalid state parameter or CSRF token")
return
}
csrfToken := session.GetCSRF()
if csrfToken == "" {
t.logger.Error("CSRF token missing in session")
http.Error(rw, "CSRF token missing", http.StatusBadRequest)
return
}
if state != csrfToken {
t.logger.Error("State parameter does not match CSRF token in session")
http.Error(rw, "Invalid state parameter", http.StatusBadRequest) http.Error(rw, "Invalid state parameter", http.StatusBadRequest)
return return
} }
// Exchange code for tokens
code := req.URL.Query().Get("code") code := req.URL.Query().Get("code")
if code == "" { if code == "" {
t.logger.Error("No code in callback") t.logger.Error("No code in callback")
@@ -159,49 +193,41 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
return return
} }
tokenResponse, err := t.exchangeCodeForTokenFunc(code, redirectURL) tokenResponse, err := t.exchangeCodeForTokenFunc(code)
if err != nil { if err != nil {
t.logger.Errorf("Failed to exchange code for token: %v", err) t.logger.Errorf("Failed to exchange code for token: %v", err)
http.Error(rw, "Authentication failed", http.StatusInternalServerError) http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return return
} }
// Verify and process tokens idToken := tokenResponse.IDToken
if err := t.verifyToken(tokenResponse.IDToken); err != nil { if idToken == "" {
t.logger.Error("No id_token in token response")
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
if err := t.verifyToken(idToken); err != nil {
t.logger.Errorf("Failed to verify id_token: %v", err) t.logger.Errorf("Failed to verify id_token: %v", err)
http.Error(rw, "Authentication failed", http.StatusInternalServerError) http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return return
} }
claims, err := t.extractClaimsFunc(tokenResponse.IDToken) claims, err := t.extractClaimsFunc(idToken)
if err != nil { if err != nil {
t.logger.Errorf("Failed to extract claims: %v", err) t.logger.Errorf("Failed to extract claims: %v", err)
http.Error(rw, "Authentication failed", http.StatusInternalServerError) http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return return
} }
// Verify nonce
nonceClaim, ok := claims["nonce"].(string) nonceClaim, ok := claims["nonce"].(string)
if !ok || nonceClaim == "" { sessionNonce, ok2 := session.Values["nonce"].(string)
t.logger.Error("Nonce claim missing in id_token") if !ok || !ok2 || nonceClaim == "" || sessionNonce == "" || nonceClaim != sessionNonce {
t.logger.Error("Invalid nonce")
http.Error(rw, "Authentication failed", http.StatusInternalServerError) http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return return
} }
sessionNonce := session.GetNonce()
if sessionNonce == "" {
t.logger.Error("Nonce not found in session")
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
if nonceClaim != sessionNonce {
t.logger.Error("Nonce claim does not match session nonce")
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
// Process email
email, _ := claims["email"].(string) email, _ := claims["email"].(string)
if email == "" || !t.isAllowedDomain(email) { if email == "" || !t.isAllowedDomain(email) {
t.logger.Errorf("Invalid or disallowed email: %s", email) t.logger.Errorf("Invalid or disallowed email: %s", email)
@@ -209,25 +235,28 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
return return
} }
// Update session with new values session.Values["authenticated"] = true
session.SetAuthenticated(true) session.Values["email"] = email
session.SetEmail(email) session.Values["id_token"] = idToken
session.SetAccessToken(tokenResponse.IDToken) session.Values["refresh_token"] = tokenResponse.RefreshToken
session.SetRefreshToken(tokenResponse.RefreshToken) session.Options = &sessions.Options{MaxAge: 3600, Path: "/", HttpOnly: true, Secure: true}
delete(session.Values, "csrf")
delete(session.Values, "nonce")
// Save session
if err := session.Save(req, rw); err != nil { if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save session: %v", err) t.logger.Errorf("Failed to save session: %v", err)
http.Error(rw, "Failed to save session", http.StatusInternalServerError) http.Error(rw, "Failed to save session", http.StatusInternalServerError)
return return
} }
// Redirect to original path or root t.logger.Debugf("Authentication successful. User email: %s", email)
redirectPath := "/"
if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != t.redirURLPath {
redirectPath = incomingPath
}
redirectPath := "/"
if path, ok := session.Values["incoming_path"].(string); ok && path != t.redirURLPath {
t.logger.Debugf("Redirecting to incoming path from original request: %s", path)
redirectPath = path
}
http.Redirect(rw, req, redirectPath, http.StatusFound) http.Redirect(rw, req, redirectPath, http.StatusFound)
} }
@@ -253,42 +282,32 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
// TokenBlacklist maintains a blacklist of tokens // TokenBlacklist maintains a blacklist of tokens
type TokenBlacklist struct { type TokenBlacklist struct {
blacklist map[string]time.Time blacklist sync.Map
mutex sync.RWMutex
} }
// NewTokenBlacklist creates a new TokenBlacklist // NewTokenBlacklist creates a new TokenBlacklist
func NewTokenBlacklist() *TokenBlacklist { func NewTokenBlacklist() *TokenBlacklist {
return &TokenBlacklist{ return &TokenBlacklist{}
blacklist: make(map[string]time.Time), }
func (tb *TokenBlacklist) Add(token string, expiration time.Time) {
tb.blacklist.Store(token, expiration)
}
func (tb *TokenBlacklist) IsBlacklisted(token string) bool {
if exp, ok := tb.blacklist.Load(token); ok {
return time.Now().Before(exp.(time.Time))
} }
return false
} }
// Add adds a token to the blacklist
func (tb *TokenBlacklist) Add(tokenID string, expiration time.Time) {
tb.mutex.Lock()
defer tb.mutex.Unlock()
tb.blacklist[tokenID] = expiration
}
// IsBlacklisted checks if a token is blacklisted
func (tb *TokenBlacklist) IsBlacklisted(tokenID string) bool {
tb.mutex.RLock()
defer tb.mutex.RUnlock()
expiration, exists := tb.blacklist[tokenID]
return exists && time.Now().Before(expiration)
}
// Cleanup removes expired tokens from the blacklist
func (tb *TokenBlacklist) Cleanup() { func (tb *TokenBlacklist) Cleanup() {
tb.mutex.Lock()
defer tb.mutex.Unlock()
now := time.Now() now := time.Now()
for tokenID, expiration := range tb.blacklist { tb.blacklist.Range(func(key, value interface{}) bool {
if now.After(expiration) { if now.After(value.(time.Time)) {
delete(tb.blacklist, tokenID) tb.blacklist.Delete(key)
} }
} return true
})
} }
// TokenCache caches tokens // TokenCache caches tokens
@@ -305,14 +324,12 @@ func NewTokenCache() *TokenCache {
// Set sets a token in the cache // Set sets a token in the cache
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) { func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
token = "t-" + token tc.cache.Set("t-"+token, claims, expiration)
tc.cache.Set(token, claims, expiration)
} }
// Get retrieves a token from the cache // Get retrieves a token from the cache
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) { func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
token = "t-" + token value, found := tc.cache.Get("t-" + token)
value, found := tc.cache.Get(token)
if !found { if !found {
return nil, false return nil, false
} }
@@ -322,8 +339,7 @@ func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
// Delete removes a token from the cache // Delete removes a token from the cache
func (tc *TokenCache) Delete(token string) { func (tc *TokenCache) Delete(token string) {
token = "t-" + token tc.cache.Delete("t-" + token)
tc.cache.Delete(token)
} }
// Cleanup cleans up expired tokens from the cache // Cleanup cleans up expired tokens from the cache
@@ -332,9 +348,9 @@ func (tc *TokenCache) Cleanup() {
} }
// exchangeCodeForToken exchanges the authorization code for tokens // exchangeCodeForToken exchanges the authorization code for tokens
func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string) (*TokenResponse, error) { func (t *TraefikOidc) exchangeCodeForToken(code string) (*TokenResponse, error) {
ctx := context.Background() ctx := context.Background()
tokenResponse, err := t.exchangeTokens(ctx, "authorization_code", code, redirectURL) tokenResponse, err := t.exchangeTokens(ctx, "authorization_code", code, t.redirectURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to exchange code for token: %w", err) return nil, fmt.Errorf("failed to exchange code for token: %w", err)
} }
@@ -343,75 +359,9 @@ func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string) (*To
// createStringMap creates a map from a slice of strings // createStringMap creates a map from a slice of strings
func createStringMap(keys []string) map[string]struct{} { func createStringMap(keys []string) map[string]struct{} {
result := make(map[string]struct{}) result := make(map[string]struct{}, len(keys))
for _, key := range keys { for _, key := range keys {
result[key] = struct{}{} result[key] = struct{}{}
} }
return result return result
} }
// handleLogout handles the logout request
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
session, err := t.sessionManager.GetSession(req)
if err != nil {
t.logger.Errorf("Error getting session: %v", err)
http.Error(rw, "Session error", http.StatusInternalServerError)
return
}
// Get the access token before clearing session
accessToken := session.GetAccessToken()
// Clear all session data
if err := session.Clear(req, rw); err != nil {
t.logger.Errorf("Error clearing session: %v", err)
http.Error(rw, "Session error", http.StatusInternalServerError)
return
}
// Get the base URL for redirects
host := t.determineHost(req)
scheme := t.determineScheme(req)
baseURL := fmt.Sprintf("%s://%s", scheme, host)
// Determine post logout redirect URI
postLogoutRedirectURI := t.postLogoutRedirectURI
if postLogoutRedirectURI == "" {
postLogoutRedirectURI = fmt.Sprintf("%s/", baseURL)
} else if !strings.HasPrefix(postLogoutRedirectURI, "http") {
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
}
// If we have an end session endpoint and an access token, use OIDC end session
if t.endSessionURL != "" && accessToken != "" {
logoutURL, err := BuildLogoutURL(t.endSessionURL, accessToken, postLogoutRedirectURI)
if err != nil {
t.logger.Errorf("Failed to build logout URL: %v", err)
http.Error(rw, "Logout error", http.StatusInternalServerError)
return
}
http.Redirect(rw, req, logoutURL, http.StatusFound)
return
}
// Otherwise, redirect to post logout URI
http.Redirect(rw, req, postLogoutRedirectURI, http.StatusFound)
}
// BuildLogoutURL constructs the OIDC end session URL
func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (string, error) {
u, err := url.Parse(endSessionURL)
if err != nil {
return "", fmt.Errorf("failed to parse end session URL: %w", err)
}
q := u.Query()
q.Set("id_token_hint", idToken)
if postLogoutRedirectURI != "" {
// Ensure postLogoutRedirectURI is properly URL encoded
q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
+30 -33
View File
@@ -4,13 +4,12 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rsa" "crypto/rsa"
"math/big"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"math/big"
"net/http" "net/http"
"sync" "sync"
"time" "time"
@@ -58,6 +57,7 @@ func (c *JWKCache) GetJWKS(jwksURL string, httpClient *http.Client) (*JWKSet, er
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
// Double-check locking pattern
if c.jwks != nil && time.Now().Before(c.expiresAt) { if c.jwks != nil && time.Now().Before(c.expiresAt) {
return c.jwks, nil return c.jwks, nil
} }
@@ -120,25 +120,12 @@ func rsaJWKToPEM(jwk *JWK) ([]byte, error) {
return nil, fmt.Errorf("failed to decode JWK 'e' parameter: %w", err) return nil, fmt.Errorf("failed to decode JWK 'e' parameter: %w", err)
} }
n := new(big.Int).SetBytes(nBytes)
e := new(big.Int).SetBytes(eBytes)
pubKey := &rsa.PublicKey{ pubKey := &rsa.PublicKey{
N: n, N: new(big.Int).SetBytes(nBytes),
E: int(e.Int64()), E: int(new(big.Int).SetBytes(eBytes).Int64()),
} }
pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey) return marshalPublicKey(pubKey)
if err != nil {
return nil, fmt.Errorf("failed to marshal RSA public key: %w", err)
}
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: pubKeyBytes,
})
return pubKeyPEM, nil
} }
// ecJWKToPEM converts an EC JWK to PEM // ecJWKToPEM converts an EC JWK to PEM
@@ -152,16 +139,9 @@ func ecJWKToPEM(jwk *JWK) ([]byte, error) {
return nil, fmt.Errorf("failed to decode JWK 'y' parameter: %w", err) return nil, fmt.Errorf("failed to decode JWK 'y' parameter: %w", err)
} }
var curve elliptic.Curve curve, err := getCurve(jwk.Crv)
switch jwk.Crv { if err != nil {
case "P-256": return nil, err
curve = elliptic.P256()
case "P-384":
curve = elliptic.P384()
case "P-521":
curve = elliptic.P521()
default:
return nil, fmt.Errorf("unsupported elliptic curve: %s", jwk.Crv)
} }
pubKey := &ecdsa.PublicKey{ pubKey := &ecdsa.PublicKey{
@@ -170,15 +150,32 @@ func ecJWKToPEM(jwk *JWK) ([]byte, error) {
Y: new(big.Int).SetBytes(yBytes), Y: new(big.Int).SetBytes(yBytes),
} }
return marshalPublicKey(pubKey)
}
// getCurve returns the elliptic curve based on the JWK curve parameter
func getCurve(crv string) (elliptic.Curve, error) {
switch crv {
case "P-256":
return elliptic.P256(), nil
case "P-384":
return elliptic.P384(), nil
case "P-521":
return elliptic.P521(), nil
default:
return nil, fmt.Errorf("unsupported elliptic curve: %s", crv)
}
}
// marshalPublicKey marshals a public key to PEM format
func marshalPublicKey(pubKey interface{}) ([]byte, error) {
pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey) pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal EC public key: %w", err) return nil, fmt.Errorf("failed to marshal public key: %w", err)
} }
pubKeyPEM := pem.EncodeToMemory(&pem.Block{ return pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY", Type: "PUBLIC KEY",
Bytes: pubKeyBytes, Bytes: pubKeyBytes,
}) }), nil
return pubKeyPEM, nil
} }
+119 -133
View File
@@ -4,18 +4,29 @@ import (
"crypto" "crypto"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/rsa" "crypto/rsa"
"math/big"
"strings"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"math/big"
"strings"
"time" "time"
) )
var (
ErrInvalidJWTFormat = errors.New("invalid JWT format")
ErrInvalidAudience = errors.New("invalid audience")
ErrInvalidIssuer = errors.New("invalid issuer")
ErrTokenExpired = errors.New("token has expired")
ErrTokenUsedBeforeIssued = errors.New("token used before issued")
ErrMissingClaim = errors.New("missing claim")
ErrInvalidClaimType = errors.New("invalid claim type")
ErrUnsupportedAlgorithm = errors.New("unsupported algorithm")
ErrInvalidSignature = errors.New("invalid signature")
)
// JWT represents a JSON Web Token // JWT represents a JSON Web Token
type JWT struct { type JWT struct {
Header map[string]interface{} Header map[string]interface{}
@@ -28,212 +39,187 @@ type JWT struct {
func parseJWT(tokenString string) (*JWT, error) { func parseJWT(tokenString string) (*JWT, error) {
parts := strings.Split(tokenString, ".") parts := strings.Split(tokenString, ".")
if len(parts) != 3 { if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) return nil, fmt.Errorf("%w: expected 3 parts, got %d", ErrInvalidJWTFormat, len(parts))
} }
jwt := &JWT{ jwt := &JWT{Token: tokenString}
Token: tokenString,
if err := decodeJSONPart(parts[0], &jwt.Header); err != nil {
return nil, fmt.Errorf("failed to decode header: %w", err)
} }
// Decode and unmarshal the header if err := decodeJSONPart(parts[1], &jwt.Claims); err != nil {
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) return nil, fmt.Errorf("failed to decode claims: %w", err)
}
var err error
jwt.Signature, err = base64.RawURLEncoding.DecodeString(parts[2])
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode header: %v", err) return nil, fmt.Errorf("failed to decode signature: %w", err)
} }
if err := json.Unmarshal(headerBytes, &jwt.Header); err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal header: %v", err)
}
// Decode and unmarshal the claims
claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode claims: %v", err)
}
if err := json.Unmarshal(claimsBytes, &jwt.Claims); err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal claims: %v", err)
}
// Decode the signature
signatureBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode signature: %v", err)
}
jwt.Signature = signatureBytes
return jwt, nil return jwt, nil
} }
func decodeJSONPart(part string, target interface{}) error {
bytes, err := base64.RawURLEncoding.DecodeString(part)
if err != nil {
return err
}
return json.Unmarshal(bytes, target)
}
// Verify verifies the standard claims in the JWT // Verify verifies the standard claims in the JWT
func (j *JWT) Verify(issuerURL, clientID string) error { func (j *JWT) Verify(issuerURL, clientID string) error {
claims := j.Claims if err := verifyIssuer(j.Claims["iss"], issuerURL); err != nil {
iss, ok := claims["iss"].(string)
if !ok {
return fmt.Errorf("missing 'iss' claim")
}
if err := verifyIssuer(iss, issuerURL); err != nil {
return err return err
} }
aud, ok := claims["aud"] if err := verifyAudience(j.Claims["aud"], clientID); err != nil {
if !ok {
return fmt.Errorf("missing 'aud' claim")
}
if err := verifyAudience(aud, clientID); err != nil {
return err return err
} }
exp, ok := claims["exp"].(float64) if err := verifyExpiration(j.Claims["exp"]); err != nil {
if !ok {
return fmt.Errorf("missing or invalid 'exp' claim")
}
if err := verifyExpiration(exp); err != nil {
return err return err
} }
iat, ok := claims["iat"].(float64) if err := verifyIssuedAt(j.Claims["iat"]); err != nil {
if !ok {
return fmt.Errorf("missing or invalid 'iat' claim")
}
if err := verifyIssuedAt(iat); err != nil {
return err return err
} }
sub, ok := claims["sub"].(string) if sub, ok := j.Claims["sub"].(string); !ok || sub == "" {
if !ok || sub == "" { return fmt.Errorf("%w: sub", ErrMissingClaim)
return fmt.Errorf("missing or empty 'sub' claim")
} }
return nil return nil
} }
// verifyAudience verifies the audience claim
func verifyAudience(tokenAudience interface{}, expectedAudience string) error { func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
switch aud := tokenAudience.(type) { switch aud := tokenAudience.(type) {
case string: case string:
if aud != expectedAudience { if aud != expectedAudience {
return fmt.Errorf("invalid audience") return ErrInvalidAudience
} }
case []interface{}: case []interface{}:
found := false
for _, v := range aud { for _, v := range aud {
if str, ok := v.(string); ok && str == expectedAudience { if str, ok := v.(string); ok && str == expectedAudience {
found = true return nil
break
} }
} }
if !found { return ErrInvalidAudience
return fmt.Errorf("invalid audience")
}
default: default:
return fmt.Errorf("invalid 'aud' claim type") return fmt.Errorf("%w: aud", ErrInvalidClaimType)
} }
return nil return nil
} }
// verifyIssuer verifies the issuer claim func verifyIssuer(tokenIssuer interface{}, expectedIssuer string) error {
func verifyIssuer(tokenIssuer, expectedIssuer string) error { iss, ok := tokenIssuer.(string)
if tokenIssuer != expectedIssuer { if !ok {
return fmt.Errorf("invalid issuer") return fmt.Errorf("%w: iss", ErrMissingClaim)
}
if iss != expectedIssuer {
return ErrInvalidIssuer
} }
return nil return nil
} }
// verifyExpiration checks if the token has expired func verifyExpiration(expiration interface{}) error {
func verifyExpiration(expiration float64) error { exp, ok := expiration.(float64)
expirationTime := time.Unix(int64(expiration), 0) if !ok {
if time.Now().After(expirationTime) { return fmt.Errorf("%w: exp", ErrInvalidClaimType)
return fmt.Errorf("token has expired") }
if time.Now().After(time.Unix(int64(exp), 0)) {
return ErrTokenExpired
} }
return nil return nil
} }
// verifyIssuedAt checks if the token was issued in the future func verifyIssuedAt(issuedAt interface{}) error {
func verifyIssuedAt(issuedAt float64) error { iat, ok := issuedAt.(float64)
issuedAtTime := time.Unix(int64(issuedAt), 0) if !ok {
if time.Now().Before(issuedAtTime) { return fmt.Errorf("%w: iat", ErrInvalidClaimType)
return fmt.Errorf("token used before issued") }
if time.Now().Before(time.Unix(int64(iat), 0)) {
return ErrTokenUsedBeforeIssued
} }
return nil return nil
} }
// verifySignature verifies the token signature using the provided public key and algorithm
func verifySignature(tokenString string, publicKeyPEM []byte, alg string) error { func verifySignature(tokenString string, publicKeyPEM []byte, alg string) error {
// Split the token into its three parts
parts := strings.Split(tokenString, ".") parts := strings.Split(tokenString, ".")
if len(parts) != 3 { if len(parts) != 3 {
return fmt.Errorf("invalid token format") return ErrInvalidJWTFormat
} }
signedContent := parts[0] + "." + parts[1] signedContent := parts[0] + "." + parts[1]
// Decode the signature from the token
signature, err := base64.RawURLEncoding.DecodeString(parts[2]) signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil { if err != nil {
return fmt.Errorf("failed to decode signature: %w", err) return fmt.Errorf("failed to decode signature: %w", err)
} }
// Decode the PEM-encoded public key pubKey, err := parsePublicKey(publicKeyPEM)
block, _ := pem.Decode(publicKeyPEM)
if block == nil {
return fmt.Errorf("failed to parse PEM block containing the public key")
}
// Parse the public key
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse public key: %w", err) return err
} }
// Determine the hash function to use based on the algorithm hashFunc, err := getHashFunc(alg)
var hashFunc crypto.Hash if err != nil {
return err
switch alg {
case "RS256", "PS256", "ES256":
hashFunc = crypto.SHA256
case "RS384", "PS384", "ES384":
hashFunc = crypto.SHA384
case "RS512", "PS512", "ES512":
hashFunc = crypto.SHA512
default:
return fmt.Errorf("unsupported algorithm: %s", alg)
} }
// Hash the signed content hashed := hashFunc.New().Sum([]byte(signedContent))
h := hashFunc.New()
h.Write([]byte(signedContent))
hashed := h.Sum(nil)
// Verify the signature based on the key type and algorithm
switch pubKey := pubKey.(type) { switch pubKey := pubKey.(type) {
case *rsa.PublicKey: case *rsa.PublicKey:
if strings.HasPrefix(alg, "RS") { return verifyRSASignature(pubKey, hashFunc, hashed, signature, alg)
// RSA PKCS#1 v1.5 signature
return rsa.VerifyPKCS1v15(pubKey, hashFunc, hashed, signature)
} else if strings.HasPrefix(alg, "PS") {
// RSA PSS signature
return rsa.VerifyPSS(pubKey, hashFunc, hashed, signature, nil)
} else {
return fmt.Errorf("unexpected key type for algorithm %s", alg)
}
case *ecdsa.PublicKey: case *ecdsa.PublicKey:
if strings.HasPrefix(alg, "ES") { return verifyECDSASignature(pubKey, hashed, signature)
// ECDSA signature
var r, s big.Int
sigLen := len(signature)
if sigLen%2 != 0 {
return fmt.Errorf("invalid ECDSA signature length")
}
r.SetBytes(signature[:sigLen/2])
s.SetBytes(signature[sigLen/2:])
if ecdsa.Verify(pubKey, hashed, &r, &s) {
return nil
} else {
return fmt.Errorf("invalid ECDSA signature")
}
} else {
return fmt.Errorf("unexpected key type for algorithm %s", alg)
}
default: default:
return fmt.Errorf("unsupported public key type: %T", pubKey) return fmt.Errorf("unsupported public key type: %T", pubKey)
} }
} }
func parsePublicKey(publicKeyPEM []byte) (interface{}, error) {
block, _ := pem.Decode(publicKeyPEM)
if block == nil {
return nil, errors.New("failed to parse PEM block containing the public key")
}
return x509.ParsePKIXPublicKey(block.Bytes)
}
func getHashFunc(alg string) (crypto.Hash, error) {
switch alg {
case "RS256", "PS256", "ES256":
return crypto.SHA256, nil
case "RS384", "PS384", "ES384":
return crypto.SHA384, nil
case "RS512", "PS512", "ES512":
return crypto.SHA512, nil
default:
return 0, fmt.Errorf("%w: %s", ErrUnsupportedAlgorithm, alg)
}
}
func verifyRSASignature(pubKey *rsa.PublicKey, hashFunc crypto.Hash, hashed, signature []byte, alg string) error {
if strings.HasPrefix(alg, "RS") {
return rsa.VerifyPKCS1v15(pubKey, hashFunc, hashed, signature)
} else if strings.HasPrefix(alg, "PS") {
return rsa.VerifyPSS(pubKey, hashFunc, hashed, signature, nil)
}
return fmt.Errorf("%w: %s", ErrUnsupportedAlgorithm, alg)
}
func verifyECDSASignature(pubKey *ecdsa.PublicKey, hashed, signature []byte) error {
sigLen := len(signature)
if sigLen%2 != 0 {
return errors.New("invalid ECDSA signature length")
}
r, s := new(big.Int), new(big.Int)
r.SetBytes(signature[:sigLen/2])
s.SetBytes(signature[sigLen/2:])
if ecdsa.Verify(pubKey, hashed, r, s) {
return nil
}
return ErrInvalidSignature
}
+166 -162
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/sessions"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@@ -33,6 +34,7 @@ type JWTVerifier interface {
type TraefikOidc struct { type TraefikOidc struct {
next http.Handler next http.Handler
name string name string
store sessions.Store
redirURLPath string redirURLPath string
logoutURLPath string logoutURLPath string
issuerURL string issuerURL string
@@ -51,29 +53,26 @@ type TraefikOidc struct {
tokenCache *TokenCache tokenCache *TokenCache
httpClient *http.Client httpClient *http.Client
logger *Logger logger *Logger
redirectURL string
tokenVerifier TokenVerifier tokenVerifier TokenVerifier
jwtVerifier JWTVerifier jwtVerifier JWTVerifier
excludedURLs map[string]struct{} excludedURLs map[string]struct{}
allowedUserDomains map[string]struct{} allowedUserDomains map[string]struct{}
allowedRolesAndGroups map[string]struct{} allowedRolesAndGroups map[string]struct{}
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *sessions.Session, redirectURL string)
exchangeCodeForTokenFunc func(code string, redirectURL string) (*TokenResponse, error) exchangeCodeForTokenFunc func(code string) (*TokenResponse, error)
extractClaimsFunc func(tokenString string) (map[string]interface{}, error) extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
initOnce sync.Once
initComplete chan struct{} initComplete chan struct{}
endSessionURL string
baseURL string
postLogoutRedirectURI string
sessionManager *SessionManager
} }
// ProviderMetadata holds OIDC provider metadata // ProviderMetadata holds OIDC provider metadata
type ProviderMetadata struct { type ProviderMetadata struct {
Issuer string `json:"issuer"` Issuer string `json:"issuer"`
AuthURL string `json:"authorization_endpoint"` AuthURL string `json:"authorization_endpoint"`
TokenURL string `json:"token_endpoint"` TokenURL string `json:"token_endpoint"`
JWKSURL string `json:"jwks_uri"` JWKSURL string `json:"jwks_uri"`
RevokeURL string `json:"revocation_endpoint"` RevokeURL string `json:"revocation_endpoint"`
EndSessionURL string `json:"end_session_endpoint"`
} }
// defaultExcludedURLs are the paths that are excluded from authentication // defaultExcludedURLs are the paths that are excluded from authentication
@@ -83,14 +82,6 @@ var defaultExcludedURLs = map[string]struct{}{
var newTicker = time.NewTicker var newTicker = time.NewTicker
var (
globalMetadataCache struct {
sync.Once
metadata *ProviderMetadata
err error
}
)
// VerifyToken verifies the provided JWT token // VerifyToken verifies the provided JWT token
func (t *TraefikOidc) VerifyToken(token string) error { func (t *TraefikOidc) VerifyToken(token string) error {
t.logger.Debugf("Verifying token") t.logger.Debugf("Verifying token")
@@ -184,6 +175,9 @@ func (t *TraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error
// New creates a new instance of the OIDC middleware // New creates a new instance of the OIDC middleware
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
store := sessions.NewCookieStore([]byte(config.SessionEncryptionKey))
store.Options = defaultSessionOptions
// Setup HTTP client // Setup HTTP client
transport := &http.Transport{ transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
@@ -196,7 +190,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
}, },
ForceAttemptHTTP2: true, ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 0, ExpectContinueTimeout: 1 * time.Second,
MaxIdleConns: 100, MaxIdleConns: 100,
MaxIdleConnsPerHost: 100, MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
@@ -215,6 +209,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
t := &TraefikOidc{ t := &TraefikOidc{
next: next, next: next,
name: name, name: name,
store: store,
redirURLPath: config.CallbackURL, redirURLPath: config.CallbackURL,
logoutURLPath: func() string { logoutURLPath: func() string {
if config.LogoutURL == "" { if config.LogoutURL == "" {
@@ -222,12 +217,6 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
} }
return config.LogoutURL return config.LogoutURL
}(), }(),
postLogoutRedirectURI: func() string {
if config.PostLogoutRedirectURI == "" {
return "/"
}
return config.PostLogoutRedirectURI
}(),
tokenBlacklist: NewTokenBlacklist(), tokenBlacklist: NewTokenBlacklist(),
jwkCache: &JWKCache{}, jwkCache: &JWKCache{},
clientID: config.ClientID, clientID: config.ClientID,
@@ -244,10 +233,9 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
} }
t.sessionManager = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, t.logger)
t.extractClaimsFunc = extractClaims t.extractClaimsFunc = extractClaims
t.exchangeCodeForTokenFunc = t.exchangeCodeForToken t.exchangeCodeForTokenFunc = t.exchangeCodeForToken
t.initiateAuthenticationFunc = func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { t.initiateAuthenticationFunc = func(rw http.ResponseWriter, req *http.Request, session *sessions.Session, redirectURL string) {
t.defaultInitiateAuthentication(rw, req, session, redirectURL) t.defaultInitiateAuthentication(rw, req, session, redirectURL)
} }
@@ -266,26 +254,20 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
// initializeMetadata discovers and initializes the provider metadata // initializeMetadata discovers and initializes the provider metadata
func (t *TraefikOidc) initializeMetadata(providerURL string) { func (t *TraefikOidc) initializeMetadata(providerURL string) {
globalMetadataCache.Once.Do(func() { t.initOnce.Do(func() {
t.logger.Debug("Starting global provider metadata discovery")
metadata, err := discoverProviderMetadata(providerURL, t.httpClient, t.logger) metadata, err := discoverProviderMetadata(providerURL, t.httpClient, t.logger)
globalMetadataCache.metadata = metadata if err != nil {
globalMetadataCache.err = err t.logger.Errorf("Failed to discover provider metadata: %v", err)
} else {
t.logger.Debug("Provider metadata discovered successfully")
t.jwksURL = metadata.JWKSURL
t.authURL = metadata.AuthURL
t.tokenURL = metadata.TokenURL
t.issuerURL = metadata.Issuer
t.revocationURL = metadata.RevokeURL
}
close(t.initComplete)
}) })
if globalMetadataCache.err != nil {
t.logger.Errorf("Failed to discover provider metadata: %v", globalMetadataCache.err)
} else if globalMetadataCache.metadata != nil {
t.logger.Debug("Using cached provider metadata")
t.jwksURL = globalMetadataCache.metadata.JWKSURL
t.authURL = globalMetadataCache.metadata.AuthURL
t.tokenURL = globalMetadataCache.metadata.TokenURL
t.issuerURL = globalMetadataCache.metadata.Issuer
t.revocationURL = globalMetadataCache.metadata.RevokeURL
t.endSessionURL = globalMetadataCache.metadata.EndSessionURL
}
close(t.initComplete)
} }
// discoverProviderMetadata fetches the OIDC provider metadata // discoverProviderMetadata fetches the OIDC provider metadata
@@ -359,68 +341,92 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
http.Error(rw, "OIDC middleware not yet initialized", http.StatusInternalServerError) http.Error(rw, "OIDC middleware not yet initialized", http.StatusInternalServerError)
return return
} }
// Process the request as normal
case <-req.Context().Done(): case <-req.Context().Done():
t.logger.Debug("Request cancelled") t.logger.Debug("Request cancelled")
http.Error(rw, "Request cancelled", http.StatusServiceUnavailable) http.Error(rw, "Request cancelled", http.StatusServiceUnavailable)
return return
} }
// Check if URL is excluded // Check if the URL is excluded from authentication
if t.determineExcludedURL(req.URL.Path) { if t.determineExcludedURL(req.URL.Path) {
t.next.ServeHTTP(rw, req) t.next.ServeHTTP(rw, req)
return return
} }
// Get session // Determine the scheme (http/https) and host
session, err := t.sessionManager.GetSession(req) t.scheme = t.determineScheme(req)
defaultSessionOptions.Secure = t.scheme == "https"
host := t.determineHost(req)
// Build the redirect URL if not already set
if t.redirectURL == "" {
t.redirectURL = buildFullURL(t.scheme, host, t.redirURLPath)
t.logger.Debugf("Redirect URL updated to: %s", t.redirectURL)
}
// Get the session
session, err := t.store.Get(req, cookieName)
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) http.Error(rw, "Session error", http.StatusInternalServerError)
return return
} }
// Build redirect URL t.logger.Debugf("Session contents at start: %+v", session.Values)
scheme := t.determineScheme(req)
host := t.determineHost(req)
redirectURL := buildFullURL(scheme, host, t.redirURLPath)
// Handle special URLs // Handle logout URL
if req.URL.Path == t.logoutURLPath { if req.URL.Path == t.logoutURLPath {
t.handleLogout(rw, req) t.handleLogout(rw, req)
return return
} }
// Handle callback URL
if req.URL.Path == t.redirURLPath { if req.URL.Path == t.redirURLPath {
t.handleCallback(rw, req, redirectURL) t.handleCallback(rw, req)
return return
} }
// Check authentication status // Check if the user is authenticated
authenticated, needsRefresh, expired := t.isUserAuthenticated(session) authenticated, needsRefresh, expired := t.isUserAuthenticated(session)
if expired { if expired {
t.handleExpiredToken(rw, req, session, redirectURL) t.handleExpiredToken(rw, req, session)
return return
} }
if !authenticated { if !authenticated {
t.defaultInitiateAuthentication(rw, req, session, redirectURL) t.defaultInitiateAuthentication(rw, req, session, t.redirectURL)
return return
} }
if needsRefresh { if needsRefresh {
refreshed := t.refreshToken(rw, req, session) refreshed := t.refreshToken(rw, req, session)
if !refreshed { if !refreshed {
t.handleExpiredToken(rw, req, session, redirectURL) t.handleExpiredToken(rw, req, session)
return return
} }
} }
// Process authenticated request // At this point, the user is authenticated
email := session.GetEmail() idToken, ok := session.Values["id_token"].(string)
if !ok || idToken == "" {
t.logger.Errorf("No id_token found in session")
t.defaultInitiateAuthentication(rw, req, session, t.redirectURL)
return
}
claims, err := extractClaims(idToken)
if err != nil {
t.logger.Errorf("Failed to extract claims: %v", err)
t.defaultInitiateAuthentication(rw, req, session, t.redirectURL)
return
}
email, _ := claims["email"].(string)
if email == "" { if email == "" {
t.logger.Debug("No email found in session") t.logger.Debugf("No email found in token claims")
t.defaultInitiateAuthentication(rw, req, session, redirectURL) t.defaultInitiateAuthentication(rw, req, session, t.redirectURL)
return return
} }
@@ -430,10 +436,36 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return return
} }
// Set user information in headers groups, roles := t.extractGroupsAndRoles(claims)
if err != nil {
t.logger.Errorf("Failed to extract groups and roles: %v", err)
} else {
// Set headers for groups and roles
if len(groups) > 0 {
req.Header.Set("X-User-Groups", strings.Join(groups, ","))
}
if len(roles) > 0 {
req.Header.Set("X-User-Roles", strings.Join(roles, ","))
}
}
if len(t.allowedRolesAndGroups) > 0 {
allowed := false
for _, roleOrGroup := range append(groups, roles...) {
if _, ok := t.allowedRolesAndGroups[roleOrGroup]; ok {
allowed = true
break
}
}
if !allowed {
t.logger.Infof("User with email %s does not have any allowed roles or groups", email)
http.Error(rw, fmt.Sprintf("Access denied: You do not have any allowed roles or groups. To log out, visit: %s", t.logoutURLPath), http.StatusForbidden)
return
}
}
req.Header.Set("X-Forwarded-User", email) req.Header.Set("X-Forwarded-User", email)
// Process the request
t.next.ServeHTTP(rw, req) t.next.ServeHTTP(rw, req)
} }
@@ -451,16 +483,16 @@ func (t *TraefikOidc) determineExcludedURL(currentRequest string) bool {
// determineScheme determines the scheme (http or https) of the request // determineScheme determines the scheme (http or https) of the request
func (t *TraefikOidc) determineScheme(req *http.Request) string { func (t *TraefikOidc) determineScheme(req *http.Request) string {
if t.forceHTTPS { switch {
case t.forceHTTPS:
return "https" return "https"
} case req.Header.Get(headerXForwardedProto) != "":
if scheme := req.Header.Get("X-Forwarded-Proto"); scheme != "" { return req.Header.Get(headerXForwardedProto)
return scheme case req.TLS != nil:
}
if req.TLS != nil {
return "https" return "https"
default:
return "http"
} }
return "http"
} }
// determineHost determines the host of the request // determineHost determines the host of the request
@@ -472,34 +504,37 @@ func (t *TraefikOidc) determineHost(req *http.Request) string {
} }
// isUserAuthenticated checks if the user is authenticated // isUserAuthenticated checks if the user is authenticated
func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, bool) { func (t *TraefikOidc) isUserAuthenticated(session *sessions.Session) (bool, bool, bool) {
if !session.GetAuthenticated() { authenticated, _ := session.Values["authenticated"].(bool)
t.logger.Debugf("Session authenticated value: %v", authenticated)
if !authenticated {
t.logger.Debug("User is not authenticated according to session") t.logger.Debug("User is not authenticated according to session")
return false, false, false return false, false, false
} }
accessToken := session.GetAccessToken() idToken, ok := session.Values["id_token"].(string)
if accessToken == "" { if !ok || idToken == "" {
t.logger.Debug("No access token found in session") t.logger.Debug("No id_token found in session")
return false, false, true // Session is invalid, consider it expired return false, false, true // Session is invalid, consider it expired
} }
// Verify the token // Verify the token
if err := t.verifyToken(accessToken); err != nil { if err := t.verifyToken(idToken); err != nil {
t.logger.Errorf("Token verification failed: %v", err) t.logger.Errorf("Token verification failed: %v", err)
return false, false, true // Token is invalid, consider it expired return false, false, true // Token is invalid, consider it expired
} }
claims, err := extractClaims(accessToken) claims, err := extractClaims(idToken)
if err != nil { if err != nil {
t.logger.Errorf("Failed to extract claims: %v", err) t.logger.Errorf("Failed to extract claims: %v", err)
return false, false, true return false, false, true // Can't read claims, consider it expired
} }
expClaim, ok := claims["exp"].(float64) expClaim, ok := claims["exp"].(float64)
if !ok { if !ok {
t.logger.Error("Failed to get expiration time from claims") t.logger.Errorf("Failed to get expiration time from claims")
return false, false, true return false, false, true // No expiration, consider it expired
} }
now := time.Now().Unix() now := time.Now().Unix()
@@ -507,7 +542,7 @@ func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, boo
if now > expTime { if now > expTime {
t.logger.Debug("Token has expired") t.logger.Debug("Token has expired")
return false, false, true return false, false, true // Token has expired
} }
gracePeriod := time.Minute * 5 gracePeriod := time.Minute * 5
@@ -516,23 +551,26 @@ func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, boo
return true, true, false // Token will expire soon, needs refresh return true, true, false // Token will expire soon, needs refresh
} }
return true, false, false return true, false, false // Token is valid and not expiring soon
} }
// defaultInitiateAuthentication initiates the authentication process // defaultInitiateAuthentication initiates the authentication process
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *sessions.Session, redirectURL string) {
// Generate CSRF token and nonce // Generate CSRF token
csrfToken := uuid.New().String() csrfToken := uuid.New().String()
session.Values["csrf"] = csrfToken
session.Values["incoming_path"] = req.URL.Path
session.Options = defaultSessionOptions
t.logger.Debugf("Setting CSRF token: %s", csrfToken)
// Generate nonce
nonce, err := generateNonce() nonce, err := generateNonce()
if err != nil { if err != nil {
http.Error(rw, "Failed to generate nonce", http.StatusInternalServerError) http.Error(rw, "Failed to generate nonce", http.StatusInternalServerError)
return return
} }
session.Values["nonce"] = nonce
// Set session values t.logger.Debugf("Setting nonce: %s", nonce)
session.SetCSRF(csrfToken)
session.SetNonce(nonce)
session.SetIncomingPath(req.URL.Path)
// Save the session // Save the session
if err := session.Save(req, rw); err != nil { if err := session.Save(req, rw); err != nil {
@@ -541,7 +579,7 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
return return
} }
// Build and redirect to auth URL // Build the authentication URL
authURL := t.buildAuthURL(redirectURL, csrfToken, nonce) authURL := t.buildAuthURL(redirectURL, csrfToken, nonce)
http.Redirect(rw, req, authURL, http.StatusFound) http.Redirect(rw, req, authURL, http.StatusFound)
} }
@@ -582,9 +620,14 @@ func (t *TraefikOidc) RevokeToken(token string) {
// Remove from cache // Remove from cache
t.tokenCache.Delete(token) t.tokenCache.Delete(token)
// Add to blacklist with default expiration // Add to blacklist
expiry := time.Now().Add(24 * time.Hour) // or other appropriate duration claims, err := extractClaims(token)
t.tokenBlacklist.Add(token, expiry) if err == nil {
if exp, ok := claims["exp"].(float64); ok {
expTime := time.Unix(int64(exp), 0)
t.tokenBlacklist.Add(token, expTime)
}
}
} }
// RevokeTokenWithProvider revokes the token with the provider // RevokeTokenWithProvider revokes the token with the provider
@@ -625,10 +668,10 @@ func (t *TraefikOidc) RevokeTokenWithProvider(token, tokenType string) error {
} }
// refreshToken refreshes the user's token // refreshToken refreshes the user's token
func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, session *SessionData) bool { func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, session *sessions.Session) bool {
t.logger.Debug("Refreshing token") t.logger.Debug("Refreshing token")
refreshToken := session.GetRefreshToken() refreshToken, ok := session.Values["refresh_token"].(string)
if refreshToken == "" { if !ok || refreshToken == "" {
t.logger.Debug("No refresh token found in session") t.logger.Debug("No refresh token found in session")
return false return false
} }
@@ -639,17 +682,16 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se
return false return false
} }
// Verify the new access token // Verify the new id_token
if err := t.verifyToken(newToken.IDToken); err != nil { if err := t.verifyToken(newToken.IDToken); err != nil {
t.logger.Errorf("Failed to verify new access token: %v", err) t.logger.Errorf("Failed to verify new id_token: %v", err)
return false return false
} }
// Update session with new tokens // Update session with new tokens
session.SetAccessToken(newToken.IDToken) session.Values["id_token"] = newToken.IDToken
session.SetRefreshToken(newToken.RefreshToken) session.Values["refresh_token"] = newToken.RefreshToken
session.Options = defaultSessionOptions
// Save the session
if err := session.Save(req, rw); err != nil { if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save refreshed session: %v", err) t.logger.Errorf("Failed to save refreshed session: %v", err)
return false return false
@@ -661,71 +703,33 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se
// isAllowedDomain checks if the user's email domain is allowed // isAllowedDomain checks if the user's email domain is allowed
func (t *TraefikOidc) isAllowedDomain(email string) bool { func (t *TraefikOidc) isAllowedDomain(email string) bool {
if len(t.allowedUserDomains) == 0 { if len(t.allowedUserDomains) == 0 {
return true // If no domains are specified, all are allowed return true
} }
atIndex := strings.LastIndex(email, "@")
parts := strings.Split(email, "@") if atIndex == -1 {
if len(parts) != 2 { return false
return false // Invalid email format
} }
domain := email[atIndex+1:]
domain := parts[1]
_, ok := t.allowedUserDomains[domain] _, ok := t.allowedUserDomains[domain]
return ok return ok
} }
// extractGroupsAndRoles extracts groups and roles from the id_token // extractGroupsAndRoles extracts groups and roles from the id_token
func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string, error) { func (t *TraefikOidc) extractGroupsAndRoles(claims map[string]interface{}) ([]string, []string) {
claims, err := t.extractClaimsFunc(idToken) groups := extractStringSlice(claims, "groups")
if err != nil { roles := extractStringSlice(claims, "roles")
return nil, nil, fmt.Errorf("failed to extract claims: %w", err) return groups, roles
}
var groups []string
var roles []string
// Extract groups with type checking
if groupsClaim, exists := claims["groups"]; exists {
groupsSlice, ok := groupsClaim.([]interface{})
if !ok {
return nil, nil, fmt.Errorf("groups claim is not an array")
}
for _, group := range groupsSlice {
if groupStr, ok := group.(string); ok {
t.logger.Debugf("Found group: %s", groupStr)
groups = append(groups, groupStr)
}
}
}
// Extract roles with type checking
if rolesClaim, exists := claims["roles"]; exists {
rolesSlice, ok := rolesClaim.([]interface{})
if !ok {
return nil, nil, fmt.Errorf("roles claim is not an array")
}
for _, role := range rolesSlice {
if roleStr, ok := role.(string); ok {
t.logger.Debugf("Found role: %s", roleStr)
roles = append(roles, roleStr)
}
}
}
return groups, roles, nil
} }
// buildFullURL constructs a full URL from scheme, host and path func extractStringSlice(claims map[string]interface{}, key string) []string {
func buildFullURL(scheme, host, path string) string { if slice, ok := claims[key].([]interface{}); ok {
// If the path is already a full URL, return it as-is result := make([]string, 0, len(slice))
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { for _, item := range slice {
return path if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result
} }
return nil
// Ensure the path starts with a forward slash
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return fmt.Sprintf("%s://%s%s", scheme, host, path)
} }
+100 -622
View File
@@ -22,14 +22,13 @@ import (
// TestSuite holds common test data and setup // TestSuite holds common test data and setup
type TestSuite struct { type TestSuite struct {
t *testing.T t *testing.T
rsaPrivateKey *rsa.PrivateKey rsaPrivateKey *rsa.PrivateKey
rsaPublicKey *rsa.PublicKey rsaPublicKey *rsa.PublicKey
ecPrivateKey *ecdsa.PrivateKey ecPrivateKey *ecdsa.PrivateKey
tOidc *TraefikOidc tOidc *TraefikOidc
mockJWKCache *MockJWKCache mockJWKCache *MockJWKCache
token string token string
sessionManager *SessionManager
} }
// Setup initializes the test suite // Setup initializes the test suite
@@ -79,9 +78,6 @@ func (ts *TestSuite) Setup() {
ts.t.Fatalf("Failed to create test JWT: %v", err) ts.t.Fatalf("Failed to create test JWT: %v", err)
} }
logger := NewLogger("info")
ts.sessionManager = NewSessionManager("test-secret-key", false, logger)
// Common TraefikOidc instance // Common TraefikOidc instance
ts.tOidc = &TraefikOidc{ ts.tOidc = &TraefikOidc{
issuerURL: "https://test-issuer.com", issuerURL: "https://test-issuer.com",
@@ -93,13 +89,13 @@ func (ts *TestSuite) Setup() {
limiter: rate.NewLimiter(rate.Every(time.Second), 10), limiter: rate.NewLimiter(rate.Every(time.Second), 10),
tokenBlacklist: NewTokenBlacklist(), tokenBlacklist: NewTokenBlacklist(),
tokenCache: NewTokenCache(), tokenCache: NewTokenCache(),
logger: logger, logger: NewLogger("info"),
store: sessions.NewCookieStore([]byte("test-secret-key")),
allowedUserDomains: map[string]struct{}{"example.com": {}}, allowedUserDomains: map[string]struct{}{"example.com": {}},
excludedURLs: map[string]struct{}{"/favicon": {}}, excludedURLs: map[string]struct{}{"/favicon": {}},
httpClient: &http.Client{}, httpClient: &http.Client{},
extractClaimsFunc: extractClaims, extractClaimsFunc: extractClaims,
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: ts.sessionManager,
} }
close(ts.tOidc.initComplete) close(ts.tOidc.initComplete)
ts.tOidc.exchangeCodeForTokenFunc = ts.exchangeCodeForTokenFunc ts.tOidc.exchangeCodeForTokenFunc = ts.exchangeCodeForTokenFunc
@@ -108,7 +104,7 @@ func (ts *TestSuite) Setup() {
} }
// Helper functions used by TraefikOidc // Helper functions used by TraefikOidc
func (ts *TestSuite) exchangeCodeForTokenFunc(code string, redirectURL string) (*TokenResponse, error) { func (ts *TestSuite) exchangeCodeForTokenFunc(code string) (*TokenResponse, error) {
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
@@ -171,6 +167,18 @@ func TestVerifyToken(t *testing.T) {
ts := &TestSuite{t: t} ts := &TestSuite{t: t}
ts.Setup() ts.Setup()
ts.mockJWKCache.JWKS = &JWKSet{
Keys: []JWK{
{
Kty: "RSA",
Kid: "test-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(ts.rsaPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(ts.rsaPublicKey.E)))),
},
},
}
tests := []struct { tests := []struct {
name string name string
token string token string
@@ -261,7 +269,6 @@ func TestServeHTTP(t *testing.T) {
sessionValues map[interface{}]interface{} sessionValues map[interface{}]interface{}
expectedStatus int expectedStatus int
expectedBody string expectedBody string
setupSession func(*SessionData)
}{ }{
{ {
name: "Excluded URL", name: "Excluded URL",
@@ -277,10 +284,10 @@ func TestServeHTTP(t *testing.T) {
{ {
name: "Authenticated request to protected URL", name: "Authenticated request to protected URL",
requestPath: "/protected", requestPath: "/protected",
setupSession: func(session *SessionData) { sessionValues: map[interface{}]interface{}{
session.SetAuthenticated(true) "authenticated": true,
session.SetEmail("[email protected]") "email": "[email protected]",
session.SetAccessToken(ts.token) "id_token": ts.token,
}, },
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
expectedBody: "OK", expectedBody: "OK",
@@ -288,52 +295,52 @@ func TestServeHTTP(t *testing.T) {
{ {
name: "Logout URL", name: "Logout URL",
requestPath: "/logout", requestPath: "/logout",
setupSession: func(session *SessionData) { sessionValues: map[interface{}]interface{}{
session.SetAuthenticated(true) "authenticated": true,
session.SetEmail("[email protected]") "email": "[email protected]",
session.SetAccessToken(ts.token) "id_token": ts.token,
}, },
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
expectedBody: "", expectedBody: "Logged out\n",
}, },
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Create a request
req := httptest.NewRequest("GET", tc.requestPath, nil) req := httptest.NewRequest("GET", tc.requestPath, nil)
req.Header.Set("X-Forwarded-Proto", "http") req.Header.Set("X-Forwarded-Proto", "http")
req.Header.Set("X-Forwarded-Host", "localhost") req.Header.Set("X-Forwarded-Host", "localhost")
// Create a temporary response recorder to save the session
rrSession := httptest.NewRecorder()
// Create a session
session, _ := ts.tOidc.store.New(req, cookieName)
if tc.sessionValues != nil {
for k, v := range tc.sessionValues {
session.Values[k] = v
}
session.Save(req, rrSession)
}
// Copy session cookie from rrSession to request
for _, cookie := range rrSession.Result().Cookies() {
req.AddCookie(cookie)
}
// Create a response recorder for ServeHTTP
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
// Setup session if needed
session, err := ts.tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
if tc.setupSession != nil {
tc.setupSession(session)
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Copy cookies to the new request
for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie)
}
rr = httptest.NewRecorder()
}
// Call ServeHTTP // Call ServeHTTP
ts.tOidc.ServeHTTP(rr, req) ts.tOidc.ServeHTTP(rr, req)
// Check response // Check the response
if rr.Code != tc.expectedStatus { if rr.Code != tc.expectedStatus {
t.Errorf("Expected status %d, got %d", tc.expectedStatus, rr.Code) t.Errorf("Test %s: expected status %d, got %d", tc.name, tc.expectedStatus, rr.Code)
} }
if tc.expectedBody != "" { if tc.expectedBody != "" && strings.TrimSpace(rr.Body.String()) != strings.TrimSpace(rr.Body.String()) {
if body := strings.TrimSpace(rr.Body.String()); body != tc.expectedBody { t.Errorf("Test %s: expected body '%s', got '%s'", tc.name, tc.expectedBody, rr.Body.String())
t.Errorf("Expected body %q, got %q", tc.expectedBody, body)
}
} }
}) })
} }
@@ -457,20 +464,18 @@ func TestHandleCallback(t *testing.T) {
ts := &TestSuite{t: t} ts := &TestSuite{t: t}
ts.Setup() ts.Setup()
redirectURL := "http://example.com/"
tests := []struct { tests := []struct {
name string name string
queryParams string queryParams string
exchangeCodeForToken func(code string, redirectURL string) (*TokenResponse, error) exchangeCodeForToken func(code string) (*TokenResponse, error)
extractClaimsFunc func(tokenString string) (map[string]interface{}, error) extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
sessionSetupFunc func(*SessionData) sessionSetupFunc func(session *sessions.Session)
expectedStatus int expectedStatus int
}{ }{
{ {
name: "Success", name: "Success",
queryParams: "?code=test-code&state=test-csrf-token", queryParams: "?code=test-code&state=test-csrf-token",
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
@@ -482,49 +487,49 @@ func TestHandleCallback(t *testing.T) {
"nonce": "test-nonce", "nonce": "test-nonce",
}, nil }, nil
}, },
sessionSetupFunc: func(session *SessionData) { sessionSetupFunc: func(session *sessions.Session) {
session.SetCSRF("test-csrf-token") session.Values["csrf"] = "test-csrf-token"
session.SetNonce("test-nonce") session.Values["nonce"] = "test-nonce"
}, },
expectedStatus: http.StatusFound, expectedStatus: http.StatusFound,
}, },
{ {
name: "Missing Code", name: "Missing Code",
queryParams: "", queryParams: "",
sessionSetupFunc: func(session *SessionData) { sessionSetupFunc: func(session *sessions.Session) {
session.SetCSRF("test-csrf-token") session.Values["csrf"] = "test-csrf-token"
session.SetNonce("test-nonce") session.Values["nonce"] = "test-nonce"
}, },
expectedStatus: http.StatusBadRequest, expectedStatus: http.StatusBadRequest,
}, },
{ {
name: "Exchange Code Error", name: "Exchange Code Error",
queryParams: "?code=test-code&state=test-csrf-token", queryParams: "?code=test-code&state=test-csrf-token",
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
return nil, fmt.Errorf("exchange code error") return nil, fmt.Errorf("exchange code error")
}, },
sessionSetupFunc: func(session *SessionData) { sessionSetupFunc: func(session *sessions.Session) {
session.SetCSRF("test-csrf-token") session.Values["csrf"] = "test-csrf-token"
session.SetNonce("test-nonce") session.Values["nonce"] = "test-nonce"
}, },
expectedStatus: http.StatusInternalServerError, expectedStatus: http.StatusInternalServerError,
}, },
{ {
name: "Missing ID Token", name: "Missing ID Token",
queryParams: "?code=test-code&state=test-csrf-token", queryParams: "?code=test-code&state=test-csrf-token",
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
return &TokenResponse{}, nil return &TokenResponse{}, nil
}, },
sessionSetupFunc: func(session *SessionData) { sessionSetupFunc: func(session *sessions.Session) {
session.SetCSRF("test-csrf-token") session.Values["csrf"] = "test-csrf-token"
session.SetNonce("test-nonce") session.Values["nonce"] = "test-nonce"
}, },
expectedStatus: http.StatusInternalServerError, expectedStatus: http.StatusInternalServerError,
}, },
{ {
name: "Disallowed Email", name: "Disallowed Email",
queryParams: "?code=test-code&state=test-csrf-token", queryParams: "?code=test-code&state=test-csrf-token",
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
@@ -536,16 +541,16 @@ func TestHandleCallback(t *testing.T) {
"nonce": "test-nonce", "nonce": "test-nonce",
}, nil }, nil
}, },
sessionSetupFunc: func(session *SessionData) { sessionSetupFunc: func(session *sessions.Session) {
session.SetCSRF("test-csrf-token") session.Values["csrf"] = "test-csrf-token"
session.SetNonce("test-nonce") session.Values["nonce"] = "test-nonce"
}, },
expectedStatus: http.StatusForbidden, expectedStatus: http.StatusForbidden,
}, },
{ {
name: "Invalid State Parameter", name: "Invalid State Parameter",
queryParams: "?code=test-code&state=invalid-csrf-token", queryParams: "?code=test-code&state=invalid-csrf-token",
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
@@ -557,16 +562,16 @@ func TestHandleCallback(t *testing.T) {
"nonce": "test-nonce", "nonce": "test-nonce",
}, nil }, nil
}, },
sessionSetupFunc: func(session *SessionData) { sessionSetupFunc: func(session *sessions.Session) {
session.SetCSRF("test-csrf-token") session.Values["csrf"] = "test-csrf-token"
session.SetNonce("test-nonce") session.Values["nonce"] = "test-nonce"
}, },
expectedStatus: http.StatusBadRequest, expectedStatus: http.StatusBadRequest,
}, },
{ {
name: "Nonce Mismatch", name: "Nonce Mismatch",
queryParams: "?code=test-code&state=test-csrf-token", queryParams: "?code=test-code&state=test-csrf-token",
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
@@ -578,16 +583,16 @@ func TestHandleCallback(t *testing.T) {
"nonce": "invalid-nonce", "nonce": "invalid-nonce",
}, nil }, nil
}, },
sessionSetupFunc: func(session *SessionData) { sessionSetupFunc: func(session *sessions.Session) {
session.SetCSRF("test-csrf-token") session.Values["csrf"] = "test-csrf-token"
session.SetNonce("test-nonce") session.Values["nonce"] = "test-nonce"
}, },
expectedStatus: http.StatusInternalServerError, expectedStatus: http.StatusInternalServerError,
}, },
{ {
name: "Missing Nonce in Claims", name: "Missing Nonce in Claims",
queryParams: "?code=test-code&state=test-csrf-token", queryParams: "?code=test-code&state=test-csrf-token",
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
@@ -599,9 +604,9 @@ func TestHandleCallback(t *testing.T) {
// Missing nonce // Missing nonce
}, nil }, nil
}, },
sessionSetupFunc: func(session *SessionData) { sessionSetupFunc: func(session *sessions.Session) {
session.SetCSRF("test-csrf-token") session.Values["csrf"] = "test-csrf-token"
session.SetNonce("test-nonce") session.Values["nonce"] = "test-nonce"
}, },
expectedStatus: http.StatusInternalServerError, expectedStatus: http.StatusInternalServerError,
}, },
@@ -609,18 +614,15 @@ 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")
sessionManager := NewSessionManager("test-secret-key", 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{
store: sessions.NewCookieStore([]byte("test-secret-key")),
allowedUserDomains: map[string]struct{}{"example.com": {}}, allowedUserDomains: map[string]struct{}{"example.com": {}},
logger: logger, logger: NewLogger("info"),
exchangeCodeForTokenFunc: tc.exchangeCodeForToken, exchangeCodeForTokenFunc: tc.exchangeCodeForToken,
extractClaimsFunc: tc.extractClaimsFunc, extractClaimsFunc: tc.extractClaimsFunc,
tokenVerifier: ts.tOidc.tokenVerifier, tokenVerifier: ts.tOidc.tokenVerifier,
jwtVerifier: ts.tOidc.jwtVerifier, jwtVerifier: ts.tOidc.jwtVerifier,
sessionManager: sessionManager,
} }
// Create request and response recorder // Create request and response recorder
@@ -628,27 +630,22 @@ func TestHandleCallback(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
// Create session // Create session
session, err := sessionManager.GetSession(req) session, _ := tOidc.store.New(req, cookieName)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
if tc.sessionSetupFunc != nil { if tc.sessionSetupFunc != nil {
tc.sessionSetupFunc(session) tc.sessionSetupFunc(session)
} }
if err := session.Save(req, rr); err != nil { session.Save(req, rr)
t.Fatalf("Failed to save session: %v", err)
}
// Copy cookies to the new request // Copy session cookie to request
for _, cookie := range rr.Result().Cookies() { for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie) req.AddCookie(cookie)
} }
// Reset response recorder for the actual test // Reset rr for the actual test
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
// Call handleCallback // Call handleCallback
tOidc.handleCallback(rr, req, redirectURL) tOidc.handleCallback(rr, req)
// Check response // Check response
if rr.Code != tc.expectedStatus { if rr.Code != tc.expectedStatus {
@@ -703,7 +700,7 @@ func TestOIDCHandler(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
queryParams string queryParams string
exchangeCodeForToken func(code string, redirectURL string) (*TokenResponse, error) exchangeCodeForToken func(code string) (*TokenResponse, error)
extractClaimsFunc func(tokenString string) (map[string]interface{}, error) extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
sessionSetupFunc func(session *sessions.Session) sessionSetupFunc func(session *sessions.Session)
expectedStatus int expectedStatus int
@@ -719,7 +716,7 @@ func TestOIDCHandler(t *testing.T) {
session.Values["csrf"] = "test-csrf-token" session.Values["csrf"] = "test-csrf-token"
session.Values["nonce"] = "test-nonce" session.Values["nonce"] = "test-nonce"
}, },
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
// Simulate token exchange // Simulate token exchange
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
@@ -743,7 +740,7 @@ func TestOIDCHandler(t *testing.T) {
session.Values["csrf"] = "test-csrf-token" session.Values["csrf"] = "test-csrf-token"
session.Values["nonce"] = "test-nonce" session.Values["nonce"] = "test-nonce"
}, },
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
// Simulate token exchange // Simulate token exchange
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
@@ -766,7 +763,7 @@ func TestOIDCHandler(t *testing.T) {
session.Values["csrf"] = "test-csrf-token" session.Values["csrf"] = "test-csrf-token"
session.Values["nonce"] = "test-nonce" session.Values["nonce"] = "test-nonce"
}, },
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
// Simulate token exchange // Simulate token exchange
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
@@ -790,7 +787,7 @@ func TestOIDCHandler(t *testing.T) {
session.Values["csrf"] = "test-csrf-token" session.Values["csrf"] = "test-csrf-token"
session.Values["nonce"] = "test-nonce" session.Values["nonce"] = "test-nonce"
}, },
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) { exchangeCodeForToken: func(code string) (*TokenResponse, error) {
// Simulate token exchange // Simulate token exchange
return &TokenResponse{ return &TokenResponse{
IDToken: ts.token, IDToken: ts.token,
@@ -835,522 +832,3 @@ func TestOIDCHandler(t *testing.T) {
}) })
} }
} }
// TestHandleLogout tests the logout functionality
func TestHandleLogout(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
// Create mock revocation endpoint server
mockRevocationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("Expected POST request, got %s", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("Failed to parse form: %v", err)
}
// Verify the required parameters are present
if r.Form.Get("token") == "" {
t.Error("Missing token parameter")
}
if r.Form.Get("token_type_hint") == "" {
t.Error("Missing token_type_hint parameter")
}
w.WriteHeader(http.StatusOK)
}))
defer mockRevocationServer.Close()
tests := []struct {
name string
setupSession func(*SessionData)
endSessionURL string
expectedStatus int
expectedURL string
host string
}{
{
name: "Successful logout with end session endpoint",
setupSession: func(session *SessionData) {
session.SetAuthenticated(true)
session.SetAccessToken("test.id.token")
session.SetRefreshToken("test-refresh-token")
},
endSessionURL: "https://provider/end-session",
expectedStatus: http.StatusFound,
expectedURL: "https://provider/end-session?id_token_hint=test.id.token&post_logout_redirect_uri=http%3A%2F%2Fexample.com%2F",
host: "test-host",
},
{
name: "Successful logout without end session endpoint",
setupSession: func(session *SessionData) {
session.SetAuthenticated(true)
session.SetAccessToken("test.id.token")
session.SetRefreshToken("test-refresh-token")
},
endSessionURL: "",
expectedStatus: http.StatusFound,
expectedURL: "http://example.com/",
host: "test-host",
},
{
name: "Logout with empty session",
setupSession: func(session *SessionData) {},
expectedStatus: http.StatusFound,
expectedURL: "http://example.com/",
host: "test-host",
},
{
name: "Logout with invalid end session URL",
setupSession: func(session *SessionData) {
session.SetAuthenticated(true)
session.SetAccessToken("test.id.token")
session.SetRefreshToken("test-refresh-token")
},
endSessionURL: ":\\invalid-url",
expectedStatus: http.StatusInternalServerError,
host: "test-host",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := NewLogger("info")
sessionManager := NewSessionManager("test-secret-key", false, logger)
tOidc := &TraefikOidc{
revocationURL: mockRevocationServer.URL,
endSessionURL: tc.endSessionURL,
scheme: "http",
logger: logger,
tokenBlacklist: NewTokenBlacklist(),
httpClient: &http.Client{},
clientID: "test-client-id",
clientSecret: "test-client-secret",
tokenCache: NewTokenCache(),
forceHTTPS: false,
sessionManager: sessionManager,
}
// Create request with proper headers
req := httptest.NewRequest("GET", "/logout", nil)
req.Header.Set("Host", tc.host)
// Create a response recorder
rr := httptest.NewRecorder()
// Get a session
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
if tc.setupSession != nil {
tc.setupSession(session)
}
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Copy cookies to the new request
for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie)
}
// Reset response recorder
rr = httptest.NewRecorder()
// Handle logout
tOidc.handleLogout(rr, req)
// Check response
if rr.Code != tc.expectedStatus {
t.Errorf("Expected status %d, got %d", tc.expectedStatus, rr.Code)
}
if tc.expectedURL != "" {
location := rr.Header().Get("Location")
if location != tc.expectedURL {
t.Errorf("Expected redirect to %q, got %q", tc.expectedURL, location)
}
}
// Verify session is cleared
updatedSession, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get updated session: %v", err)
}
// Verify tokens are cleared
if token := updatedSession.GetAccessToken(); token != "" {
t.Error("Access token not cleared")
}
if token := updatedSession.GetRefreshToken(); token != "" {
t.Error("Refresh token not cleared")
}
if updatedSession.GetAuthenticated() {
t.Error("Session still marked as authenticated")
}
// Check token blacklist
if token := session.GetAccessToken(); token != "" {
if !tOidc.tokenBlacklist.IsBlacklisted(token) {
t.Error("Access token was not blacklisted")
}
}
if token := session.GetRefreshToken(); token != "" {
if !tOidc.tokenBlacklist.IsBlacklisted(token) {
t.Error("Refresh token was not blacklisted")
}
}
})
}
}
// TestRevokeTokenWithProvider tests the token revocation with provider
func TestRevokeTokenWithProvider(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
tests := []struct {
name string
token string
tokenType string
statusCode int
expectError bool
}{
{
name: "Successful token revocation",
token: "valid-token",
tokenType: "refresh_token",
statusCode: http.StatusOK,
expectError: false,
},
{
name: "Failed token revocation",
token: "invalid-token",
tokenType: "refresh_token",
statusCode: http.StatusBadRequest,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create test server
server := 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 ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("Expected Content-Type application/x-www-form-urlencoded, got %s", ct)
}
// Verify form values
if err := r.ParseForm(); err != nil {
t.Fatalf("Failed to parse form: %v", err)
}
if got := r.Form.Get("token"); got != tc.token {
t.Errorf("Expected token %s, got %s", tc.token, got)
}
if got := r.Form.Get("token_type_hint"); got != tc.tokenType {
t.Errorf("Expected token_type_hint %s, got %s", tc.tokenType, got)
}
if got := r.Form.Get("client_id"); got != ts.tOidc.clientID {
t.Errorf("Expected client_id %s, got %s", ts.tOidc.clientID, got)
}
if got := r.Form.Get("client_secret"); got != ts.tOidc.clientSecret {
t.Errorf("Expected client_secret %s, got %s", ts.tOidc.clientSecret, got)
}
w.WriteHeader(tc.statusCode)
}))
defer server.Close()
// Set revocation URL to test server
ts.tOidc.revocationURL = server.URL
// Test token revocation
err := ts.tOidc.RevokeTokenWithProvider(tc.token, tc.tokenType)
if tc.expectError && err == nil {
t.Error("Expected error but got nil")
}
if !tc.expectError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
}
// TestRevokeToken tests the token revocation functionality
func TestRevokeToken(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
token := "test.token.with.claims"
claims := map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()),
}
// Test token revocation
t.Run("Token revocation", func(t *testing.T) {
// Create a new instance for this specific test
tOidc := &TraefikOidc{
tokenBlacklist: NewTokenBlacklist(),
tokenCache: NewTokenCache(),
}
// Cache the token
tOidc.tokenCache.Set(token, claims, time.Hour)
// Revoke the token
tOidc.RevokeToken(token)
// Verify token was removed from cache
if _, exists := tOidc.tokenCache.Get(token); exists {
t.Error("Token was not removed from cache")
}
// Verify token was added to blacklist
if !tOidc.tokenBlacklist.IsBlacklisted(token) {
t.Error("Token was not added to blacklist")
}
})
}
// Add this new test function
func TestBuildLogoutURL(t *testing.T) {
tests := []struct {
name string
endSessionURL string
idToken string
postLogoutRedirect string
expectedURL string
expectError bool
}{
{
name: "Valid URL",
endSessionURL: "https://provider/end-session",
idToken: "test.id.token",
postLogoutRedirect: "http://example.com/",
expectedURL: "https://provider/end-session?id_token_hint=test.id.token&post_logout_redirect_uri=http%3A%2F%2Fexample.com%2F",
expectError: false,
},
{
name: "Invalid URL",
endSessionURL: "://invalid-url",
idToken: "test.id.token",
postLogoutRedirect: "http://example.com/",
expectError: true,
},
{
name: "URL with existing query parameters",
endSessionURL: "https://provider/end-session?existing=param",
idToken: "test.id.token",
postLogoutRedirect: "http://example.com/",
expectedURL: "https://provider/end-session?existing=param&id_token_hint=test.id.token&post_logout_redirect_uri=http%3A%2F%2Fexample.com%2F",
expectError: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
url, err := BuildLogoutURL(tc.endSessionURL, tc.idToken, tc.postLogoutRedirect)
if tc.expectError {
if err == nil {
t.Error("Expected error but got nil")
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if url != tc.expectedURL {
t.Errorf("Expected URL %q, got %q", tc.expectedURL, url)
}
}
})
}
}
// Add this new test function
func TestHandleExpiredToken(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
tests := []struct {
name string
setupSession func(*SessionData)
expectedPath string
}{
{
name: "Basic expired token",
setupSession: func(session *SessionData) {
session.SetAuthenticated(true)
session.SetAccessToken("expired.token")
session.SetEmail("[email protected]")
},
expectedPath: "/original/path",
},
{
name: "Session with additional values",
setupSession: func(session *SessionData) {
session.SetAuthenticated(true)
session.SetAccessToken("expired.token")
session.mainSession.Values["custom_value"] = "should-be-cleared"
},
expectedPath: "/another/path",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := NewLogger("info")
sessionManager := NewSessionManager("test-secret-key", false, logger)
tOidc := &TraefikOidc{
sessionManager: sessionManager,
logger: logger,
tokenVerifier: ts.tOidc.tokenVerifier,
jwtVerifier: ts.tOidc.jwtVerifier,
initComplete: make(chan struct{}),
initiateAuthenticationFunc: func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
http.Redirect(rw, req, "/login", http.StatusFound)
},
}
close(tOidc.initComplete)
// Create request
req := httptest.NewRequest("GET", tc.expectedPath, nil)
rr := httptest.NewRecorder()
// Get session
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Setup session data
tc.setupSession(session)
// Handle expired token
tOidc.handleExpiredToken(rr, req, session, tc.expectedPath)
// Get the updated session to verify changes
updatedSession, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get updated session: %v", err)
}
// Verify main session values
if updatedSession.GetCSRF() == "" {
t.Error("CSRF token not set")
}
if path := updatedSession.GetIncomingPath(); path != tc.expectedPath {
t.Errorf("Expected path %s, got %s", tc.expectedPath, path)
}
if updatedSession.GetNonce() == "" {
t.Error("Nonce not set")
}
// Verify tokens are cleared
if token := updatedSession.GetAccessToken(); token != "" {
t.Error("Access token not cleared")
}
if token := updatedSession.GetRefreshToken(); token != "" {
t.Error("Refresh token not cleared")
}
// Verify redirect status
if rr.Code != http.StatusFound {
t.Errorf("Expected status %d, got %d", http.StatusFound, rr.Code)
}
})
}
}
// Add this new test function
func TestExtractGroupsAndRoles(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
tests := []struct {
name string
claims map[string]interface{}
expectGroups []string
expectRoles []string
expectError bool
}{
{
name: "Valid groups and roles",
claims: map[string]interface{}{
"groups": []interface{}{"group1", "group2"},
"roles": []interface{}{"role1", "role2"},
},
expectGroups: []string{"group1", "group2"},
expectRoles: []string{"role1", "role2"},
expectError: false,
},
{
name: "Empty groups and roles",
claims: map[string]interface{}{
"groups": []interface{}{},
"roles": []interface{}{},
},
expectGroups: []string{},
expectRoles: []string{},
expectError: false,
},
{
name: "Invalid groups format",
claims: map[string]interface{}{
"groups": "not-an-array",
"roles": []interface{}{"role1"},
},
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create a test token with the claims
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", tc.claims)
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
if tc.expectError {
if err == nil {
t.Error("Expected error but got nil")
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Compare groups
if !stringSliceEqual(groups, tc.expectGroups) {
t.Errorf("Expected groups %v, got %v", tc.expectGroups, groups)
}
// Compare roles
if !stringSliceEqual(roles, tc.expectRoles) {
t.Errorf("Expected roles %v, got %v", tc.expectRoles, roles)
}
}
})
}
}
// Helper function to compare string slices
func stringSliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
-196
View File
@@ -1,196 +0,0 @@
package traefikoidc
import (
"fmt"
"net/http"
"strings"
"github.com/gorilla/sessions"
)
const (
mainCookieName = "_raczylo_oidc" // Main session cookie
accessTokenCookie = "_raczylo_oidc_access" // Access token cookie
refreshTokenCookie = "_raczylo_oidc_refresh" // Refresh token cookie
)
// SessionManager handles multiple session cookies
type SessionManager struct {
store sessions.Store
forceHTTPS bool
logger *Logger
}
// NewSessionManager creates a new session manager
func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) *SessionManager {
return &SessionManager{
store: sessions.NewCookieStore([]byte(encryptionKey)),
forceHTTPS: forceHTTPS,
logger: logger,
}
}
// getSessionOptions returns session options based on scheme
func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
return &sessions.Options{
HttpOnly: true,
Secure: isSecure || sm.forceHTTPS,
SameSite: http.SameSiteLaxMode,
MaxAge: ConstSessionTimeout,
Path: "/",
}
}
// GetSession retrieves all session data
func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
mainSession, err := sm.store.Get(r, mainCookieName)
if err != nil {
return nil, fmt.Errorf("failed to get main session: %w", err)
}
accessSession, err := sm.store.Get(r, accessTokenCookie)
if err != nil {
return nil, fmt.Errorf("failed to get access token session: %w", err)
}
refreshSession, err := sm.store.Get(r, refreshTokenCookie)
if err != nil {
return nil, fmt.Errorf("failed to get refresh token session: %w", err)
}
sessionData := &SessionData{
manager: sm,
mainSession: mainSession,
accessSession: accessSession,
refreshSession: refreshSession,
}
return sessionData, nil
}
// SessionData holds all session information
type SessionData struct {
manager *SessionManager
mainSession *sessions.Session
accessSession *sessions.Session
refreshSession *sessions.Session
}
// Save saves all session data
func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error {
isSecure := strings.HasPrefix(r.URL.Scheme, "https") || sd.manager.forceHTTPS
// Set options for all sessions
sd.mainSession.Options = sd.manager.getSessionOptions(isSecure)
sd.accessSession.Options = sd.manager.getSessionOptions(isSecure)
sd.refreshSession.Options = sd.manager.getSessionOptions(isSecure)
if err := sd.mainSession.Save(r, w); err != nil {
return fmt.Errorf("failed to save main session: %w", err)
}
if err := sd.accessSession.Save(r, w); err != nil {
return fmt.Errorf("failed to save access token session: %w", err)
}
if err := sd.refreshSession.Save(r, w); err != nil {
return fmt.Errorf("failed to save refresh token session: %w", err)
}
return nil
}
// Clear clears all session data
func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error {
// Clear and expire all sessions
sd.mainSession.Options.MaxAge = -1
sd.accessSession.Options.MaxAge = -1
sd.refreshSession.Options.MaxAge = -1
for k := range sd.mainSession.Values {
delete(sd.mainSession.Values, k)
}
for k := range sd.accessSession.Values {
delete(sd.accessSession.Values, k)
}
for k := range sd.refreshSession.Values {
delete(sd.refreshSession.Values, k)
}
return sd.Save(r, w)
}
// GetAuthenticated returns authentication status
func (sd *SessionData) GetAuthenticated() bool {
auth, _ := sd.mainSession.Values["authenticated"].(bool)
return auth
}
// SetAuthenticated sets authentication status
func (sd *SessionData) SetAuthenticated(value bool) {
sd.mainSession.Values["authenticated"] = value
}
// GetAccessToken returns the access token
func (sd *SessionData) GetAccessToken() string {
token, _ := sd.accessSession.Values["token"].(string)
return token
}
// SetAccessToken sets the access token
func (sd *SessionData) SetAccessToken(token string) {
sd.accessSession.Values["token"] = token
}
// GetRefreshToken returns the refresh token
func (sd *SessionData) GetRefreshToken() string {
token, _ := sd.refreshSession.Values["token"].(string)
return token
}
// SetRefreshToken sets the refresh token
func (sd *SessionData) SetRefreshToken(token string) {
sd.refreshSession.Values["token"] = token
}
// GetCSRF returns the CSRF token
func (sd *SessionData) GetCSRF() string {
csrf, _ := sd.mainSession.Values["csrf"].(string)
return csrf
}
// SetCSRF sets the CSRF token
func (sd *SessionData) SetCSRF(token string) {
sd.mainSession.Values["csrf"] = token
}
// GetNonce returns the nonce
func (sd *SessionData) GetNonce() string {
nonce, _ := sd.mainSession.Values["nonce"].(string)
return nonce
}
// SetNonce sets the nonce
func (sd *SessionData) SetNonce(nonce string) {
sd.mainSession.Values["nonce"] = nonce
}
// GetEmail returns the user's email
func (sd *SessionData) GetEmail() string {
email, _ := sd.mainSession.Values["email"].(string)
return email
}
// SetEmail sets the user's email
func (sd *SessionData) SetEmail(email string) {
sd.mainSession.Values["email"] = email
}
// GetIncomingPath returns the original incoming path
func (sd *SessionData) GetIncomingPath() string {
path, _ := sd.mainSession.Values["incoming_path"].(string)
return path
}
// SetIncomingPath sets the original incoming path
func (sd *SessionData) SetIncomingPath(path string) {
sd.mainSession.Values["incoming_path"] = path
}
-60
View File
@@ -1,60 +0,0 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
)
func TestSessionManager(t *testing.T) {
logger := NewLogger("info")
manager := NewSessionManager("test-secret-key", false, logger)
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
session, err := manager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Test setting and getting values
session.SetAuthenticated(true)
session.SetEmail("[email protected]")
session.SetAccessToken("test.access.token")
session.SetRefreshToken("test.refresh.token")
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Verify cookies are set
cookies := rr.Result().Cookies()
if len(cookies) != 3 {
t.Errorf("Expected 3 cookies, got %d", len(cookies))
}
// Create a new request with the cookies
newReq := httptest.NewRequest("GET", "/test", nil)
for _, cookie := range cookies {
newReq.AddCookie(cookie)
}
// Get the session again and verify values
newSession, err := manager.GetSession(newReq)
if err != nil {
t.Fatalf("Failed to get new session: %v", err)
}
if !newSession.GetAuthenticated() {
t.Error("Authentication status not preserved")
}
if email := newSession.GetEmail(); email != "[email protected]" {
t.Errorf("Expected email [email protected], got %s", email)
}
if token := newSession.GetAccessToken(); token != "test.access.token" {
t.Errorf("Expected access token test.access.token, got %s", token)
}
if token := newSession.GetRefreshToken(); token != "test.refresh.token" {
t.Errorf("Expected refresh token test.refresh.token, got %s", token)
}
}
+18 -2
View File
@@ -6,12 +6,22 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"github.com/gorilla/sessions"
) )
const ( const (
cookieName = "_raczylo_oidc" cookieName = "_raczylo_oidc"
) )
const (
headerXForwardedProto = "X-Forwarded-Proto"
headerXForwardedHost = "X-Forwarded-Host"
headerXForwardedUser = "X-Forwarded-User"
headerXUserGroups = "X-User-Groups"
headerXUserRoles = "X-User-Roles"
)
// Config holds the configuration for the OIDC middleware // Config holds the configuration for the OIDC middleware
type Config struct { type Config struct {
ProviderURL string `json:"providerURL"` ProviderURL string `json:"providerURL"`
@@ -28,11 +38,17 @@ type Config struct {
ExcludedURLs []string `json:"excludedURLs"` ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"` AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"` AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
HTTPClient *http.Client HTTPClient *http.Client
} }
var defaultSessionOptions = &sessions.Options{
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
MaxAge: ConstSessionTimeout,
Path: "/",
}
// CreateConfig creates a new Config with default values // CreateConfig creates a new Config with default values
func CreateConfig() *Config { func CreateConfig() *Config {
c := &Config{} c := &Config{}
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2023 The Gorilla Authors. All rights reserved. Copyright (c) 2024 The Gorilla Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are modification, are permitted provided that the following conditions are
+5 -1
View File
@@ -1,4 +1,7 @@
# sessions # Gorilla Sessions
> [!IMPORTANT]
> The latest version of this repository requires go 1.23 because of the new partitioned attribute. The last version that is compatible with older versions of go is v1.3.0.
![testing](https://github.com/gorilla/sessions/actions/workflows/test.yml/badge.svg) ![testing](https://github.com/gorilla/sessions/actions/workflows/test.yml/badge.svg)
[![codecov](https://codecov.io/github/gorilla/sessions/branch/main/graph/badge.svg)](https://codecov.io/github/gorilla/sessions) [![codecov](https://codecov.io/github/gorilla/sessions/branch/main/graph/badge.svg)](https://codecov.io/github/gorilla/sessions)
@@ -74,6 +77,7 @@ Other implementations of the `sessions.Store` interface:
- [github.com/dsoprea/go-appengine-sessioncascade](https://github.com/dsoprea/go-appengine-sessioncascade) - Memcache/Datastore/Context in AppEngine - [github.com/dsoprea/go-appengine-sessioncascade](https://github.com/dsoprea/go-appengine-sessioncascade) - Memcache/Datastore/Context in AppEngine
- [github.com/kidstuff/mongostore](https://github.com/kidstuff/mongostore) - MongoDB - [github.com/kidstuff/mongostore](https://github.com/kidstuff/mongostore) - MongoDB
- [github.com/srinathgs/mysqlstore](https://github.com/srinathgs/mysqlstore) - MySQL - [github.com/srinathgs/mysqlstore](https://github.com/srinathgs/mysqlstore) - MySQL
- [github.com/danielepintore/gorilla-sessions-mysql](https://github.com/danielepintore/gorilla-sessions-mysql) - MySQL
- [github.com/EnumApps/clustersqlstore](https://github.com/EnumApps/clustersqlstore) - MySQL Cluster - [github.com/EnumApps/clustersqlstore](https://github.com/EnumApps/clustersqlstore) - MySQL Cluster
- [github.com/antonlindstrom/pgstore](https://github.com/antonlindstrom/pgstore) - PostgreSQL - [github.com/antonlindstrom/pgstore](https://github.com/antonlindstrom/pgstore) - PostgreSQL
- [github.com/boj/redistore](https://github.com/boj/redistore) - Redis - [github.com/boj/redistore](https://github.com/boj/redistore) - Redis
+12 -9
View File
@@ -1,5 +1,6 @@
//go:build !go1.11 // Copyright 2012 The Gorilla Authors. All rights reserved.
// +build !go1.11 // Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package sessions package sessions
@@ -8,13 +9,15 @@ import "net/http"
// newCookieFromOptions returns an http.Cookie with the options set. // newCookieFromOptions returns an http.Cookie with the options set.
func newCookieFromOptions(name, value string, options *Options) *http.Cookie { func newCookieFromOptions(name, value string, options *Options) *http.Cookie {
return &http.Cookie{ return &http.Cookie{
Name: name, Name: name,
Value: value, Value: value,
Path: options.Path, Path: options.Path,
Domain: options.Domain, Domain: options.Domain,
MaxAge: options.MaxAge, MaxAge: options.MaxAge,
Secure: options.Secure, Secure: options.Secure,
HttpOnly: options.HttpOnly, HttpOnly: options.HttpOnly,
Partitioned: options.Partitioned,
SameSite: options.SameSite,
} }
} }
-21
View File
@@ -1,21 +0,0 @@
//go:build go1.11
// +build go1.11
package sessions
import "net/http"
// newCookieFromOptions returns an http.Cookie with the options set.
func newCookieFromOptions(name, value string, options *Options) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: options.Path,
Domain: options.Domain,
MaxAge: options.MaxAge,
Secure: options.Secure,
HttpOnly: options.HttpOnly,
SameSite: options.SameSite,
}
}
+10 -5
View File
@@ -1,8 +1,11 @@
//go:build !go1.11 // Copyright 2012 The Gorilla Authors. All rights reserved.
// +build !go1.11 // Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package sessions package sessions
import "net/http"
// Options stores configuration for a session or session store. // Options stores configuration for a session or session store.
// //
// Fields are a subset of http.Cookie fields. // Fields are a subset of http.Cookie fields.
@@ -13,7 +16,9 @@ type Options struct {
// deleted after the browser session ends. // deleted after the browser session ends.
// MaxAge<0 means delete cookie immediately. // MaxAge<0 means delete cookie immediately.
// MaxAge>0 means Max-Age attribute present and given in seconds. // MaxAge>0 means Max-Age attribute present and given in seconds.
MaxAge int MaxAge int
Secure bool Secure bool
HttpOnly bool HttpOnly bool
Partitioned bool
SameSite http.SameSite
} }
-23
View File
@@ -1,23 +0,0 @@
//go:build go1.11
// +build go1.11
package sessions
import "net/http"
// Options stores configuration for a session or session store.
//
// Fields are a subset of http.Cookie fields.
type Options struct {
Path string
Domain string
// MaxAge=0 means no Max-Age attribute specified and the cookie will be
// deleted after the browser session ends.
// MaxAge<0 means delete cookie immediately.
// MaxAge>0 means Max-Age attribute present and given in seconds.
MaxAge int
Secure bool
HttpOnly bool
// Defaults to http.SameSiteDefaultMode
SameSite http.SameSite
}
+2 -2
View File
@@ -4,8 +4,8 @@ github.com/google/uuid
# github.com/gorilla/securecookie v1.1.2 # github.com/gorilla/securecookie v1.1.2
## explicit; go 1.20 ## explicit; go 1.20
github.com/gorilla/securecookie github.com/gorilla/securecookie
# github.com/gorilla/sessions v1.3.0 # github.com/gorilla/sessions v1.4.0
## explicit; go 1.20 ## explicit; go 1.23
github.com/gorilla/sessions github.com/gorilla/sessions
# golang.org/x/time v0.7.0 # golang.org/x/time v0.7.0
## explicit; go 1.18 ## explicit; go 1.18