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 582 additions and 1874 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
}
+141 -165
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,11 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return return
} }
groups, roles, err := t.extractGroupsAndRoles(session.GetAccessToken()) groups, roles := t.extractGroupsAndRoles(claims)
if err != nil { if err != nil {
t.logger.Errorf("Failed to extract groups and roles: %v", err) t.logger.Errorf("Failed to extract groups and roles: %v", err)
} else { } else {
// Set headers for groups and roles
if len(groups) > 0 { if len(groups) > 0 {
req.Header.Set("X-User-Groups", strings.Join(groups, ",")) req.Header.Set("X-User-Groups", strings.Join(groups, ","))
} }
@@ -442,7 +449,6 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
} }
// Check allowed roles and groups
if len(t.allowedRolesAndGroups) > 0 { if len(t.allowedRolesAndGroups) > 0 {
allowed := false allowed := false
for _, roleOrGroup := range append(groups, roles...) { for _, roleOrGroup := range append(groups, roles...) {
@@ -453,15 +459,13 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
if !allowed { if !allowed {
t.logger.Infof("User with email %s does not have any allowed roles or groups", email) 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 of the allowed roles or groups. To log out, visit: %s", t.logoutURLPath), http.StatusForbidden) 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 return
} }
} }
// Set user information in headers
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)
} }
@@ -479,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
@@ -500,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()
@@ -535,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
@@ -544,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 {
@@ -569,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)
} }
@@ -610,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
@@ -653,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
} }
@@ -667,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
@@ -689,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 -807
View File
File diff suppressed because it is too large Load Diff
-355
View File
@@ -1,355 +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
maxCookieSize = 2000 // Max size for each chunk to stay within 4096-byte cookie limit
// REASON:
// Let x be the maximum size of the chunk (maxCookieSize).
// Encrypted size = x + 28 bytes
// Base64-encoded size = ((x + 28) * 4) / 3 bytes
// ((x + 28) * 4) / 3 <= 4096
// Multiply both sides by 3:
// 4 * (x + 28) <= 4096 * 3
// 4 * (x + 28) <= 12288
// Divide both sides by 4:
// x + 28 <= 3072
// Subtract 28 from both sides:
// x <= 3044
)
// 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,
request: r,
mainSession: mainSession,
accessSession: accessSession,
refreshSession: refreshSession,
}
// Retrieve chunked access token sessions
sessionData.accessTokenChunks = sm.getTokenChunkSessions(r, accessTokenCookie)
// Retrieve chunked refresh token sessions
sessionData.refreshTokenChunks = sm.getTokenChunkSessions(r, refreshTokenCookie)
return sessionData, nil
}
// getTokenChunkSessions retrieves sessions for token chunks
func (sm *SessionManager) getTokenChunkSessions(r *http.Request, baseName string) map[int]*sessions.Session {
chunks := make(map[int]*sessions.Session)
for i := 0; ; i++ {
sessionName := fmt.Sprintf("%s_%d", baseName, i)
session, err := sm.store.Get(r, sessionName)
if err != nil || session.IsNew {
// No more sessions
break
}
chunks[i] = session
}
return chunks
}
// SessionData holds all session information
type SessionData struct {
manager *SessionManager
request *http.Request
mainSession *sessions.Session
accessSession *sessions.Session
refreshSession *sessions.Session
accessTokenChunks map[int]*sessions.Session
refreshTokenChunks map[int]*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
options := sd.manager.getSessionOptions(isSecure)
sd.mainSession.Options = options
sd.accessSession.Options = options
sd.refreshSession.Options = options
// Save main session
if err := sd.mainSession.Save(r, w); err != nil {
return fmt.Errorf("failed to save main session: %w", err)
}
// Save access token session
if err := sd.accessSession.Save(r, w); err != nil {
return fmt.Errorf("failed to save access token session: %w", err)
}
// Save refresh token session
if err := sd.refreshSession.Save(r, w); err != nil {
return fmt.Errorf("failed to save refresh token session: %w", err)
}
// Save access token chunks
for _, session := range sd.accessTokenChunks {
session.Options = options
if err := session.Save(r, w); err != nil {
return fmt.Errorf("failed to save access token chunk session: %w", err)
}
}
// Save refresh token chunks
for _, session := range sd.refreshTokenChunks {
session.Options = options
if err := session.Save(r, w); err != nil {
return fmt.Errorf("failed to save refresh token chunk 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)
}
// Clear chunk sessions
sd.clearTokenChunks(r, sd.accessTokenChunks)
sd.clearTokenChunks(r, sd.refreshTokenChunks)
return sd.Save(r, w)
}
// clearTokenChunks clears chunked token sessions
func (sd *SessionData) clearTokenChunks(r *http.Request, chunks map[int]*sessions.Session) {
for _, session := range chunks {
session.Options.MaxAge = -1
for k := range session.Values {
delete(session.Values, k)
}
}
}
// 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)
if token != "" {
return token
}
// Reassemble token from chunks
if len(sd.accessTokenChunks) == 0 {
return ""
}
var chunks []string
for i := 0; ; i++ {
session, ok := sd.accessTokenChunks[i]
if !ok {
break
}
chunk, _ := session.Values["token_chunk"].(string)
chunks = append(chunks, chunk)
}
return strings.Join(chunks, "")
}
// SetAccessToken sets the access token
func (sd *SessionData) SetAccessToken(token string) {
// Clear existing chunks
sd.clearTokenChunks(sd.request, sd.accessTokenChunks)
sd.accessTokenChunks = make(map[int]*sessions.Session)
if len(token) <= maxCookieSize {
sd.accessSession.Values["token"] = token
} else {
// Split token into chunks
sd.accessSession.Values["token"] = ""
chunks := splitIntoChunks(token, maxCookieSize)
for i, chunk := range chunks {
sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i)
session, _ := sd.manager.store.Get(sd.request, sessionName)
session.Values["token_chunk"] = chunk
sd.accessTokenChunks[i] = session
}
}
}
// GetRefreshToken returns the refresh token
func (sd *SessionData) GetRefreshToken() string {
token, _ := sd.refreshSession.Values["token"].(string)
if token != "" {
return token
}
// Reassemble token from chunks
if len(sd.refreshTokenChunks) == 0 {
return ""
}
var chunks []string
for i := 0; ; i++ {
session, ok := sd.refreshTokenChunks[i]
if !ok {
break
}
chunk, _ := session.Values["token_chunk"].(string)
chunks = append(chunks, chunk)
}
return strings.Join(chunks, "")
}
// SetRefreshToken sets the refresh token
func (sd *SessionData) SetRefreshToken(token string) {
// Clear existing chunks
sd.clearTokenChunks(sd.request, sd.refreshTokenChunks)
sd.refreshTokenChunks = make(map[int]*sessions.Session)
if len(token) <= maxCookieSize {
sd.refreshSession.Values["token"] = token
} else {
// Split token into chunks
sd.refreshSession.Values["token"] = ""
chunks := splitIntoChunks(token, maxCookieSize)
for i, chunk := range chunks {
sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i)
session, _ := sd.manager.store.Get(sd.request, sessionName)
session.Values["token_chunk"] = chunk
sd.refreshTokenChunks[i] = session
}
}
}
// splitIntoChunks splits a string into chunks of specified size
func splitIntoChunks(s string, chunkSize int) []string {
var chunks []string
for len(s) > 0 {
if len(s) > chunkSize {
chunks = append(chunks, s[:chunkSize])
s = s[chunkSize:]
} else {
chunks = append(chunks, s)
break
}
}
return chunks
}
// 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
}
-129
View File
@@ -1,129 +0,0 @@
package traefikoidc
import (
"net/http/httptest"
"strings"
"testing"
)
// TestSessionManager tests the SessionManager functionality
func TestSessionManager(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
tests := []struct {
name string
authenticated bool
email string
accessToken string
refreshToken string
expectedCookieCount int
}{
{
name: "Short tokens",
authenticated: true,
email: "[email protected]",
accessToken: "shortaccesstoken",
refreshToken: "shortrefreshtoken",
expectedCookieCount: 3, // main, access, refresh
},
{
name: "Long tokens exceeding 4096 bytes",
authenticated: true,
email: "[email protected]",
accessToken: strings.Repeat("x", 5000),
refreshToken: strings.Repeat("y", 6000),
// Recalculate expected cookies based on new maxCookieSize
expectedCookieCount: calculateExpectedCookieCount(strings.Repeat("x", 5000), strings.Repeat("y", 6000)),
},
{
name: "REALLY long tokens, exceeding 25000 bytes",
authenticated: true,
email: "[email protected]",
accessToken: strings.Repeat("x", 25000),
refreshToken: strings.Repeat("y", 25000),
expectedCookieCount: calculateExpectedCookieCount(strings.Repeat("x", 25000), strings.Repeat("y", 25000)),
},
{
name: "Unauthenticated session",
authenticated: false,
email: "",
accessToken: "",
refreshToken: "",
expectedCookieCount: 3, // main, access, refresh
},
}
for _, tc := range tests {
tc := tc // Capture range variable
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
session, err := ts.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Set session values
session.SetAuthenticated(tc.authenticated)
session.SetEmail(tc.email)
session.SetAccessToken(tc.accessToken)
session.SetRefreshToken(tc.refreshToken)
// Save session
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) != tc.expectedCookieCount {
t.Errorf("Expected %d cookies, got %d", tc.expectedCookieCount, 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 := ts.sessionManager.GetSession(newReq)
if err != nil {
t.Fatalf("Failed to get new session: %v", err)
}
if newSession.GetAuthenticated() != tc.authenticated {
t.Errorf("Authentication status not preserved")
}
if email := newSession.GetEmail(); email != tc.email {
t.Errorf("Expected email %s, got %s", tc.email, email)
}
if token := newSession.GetAccessToken(); token != tc.accessToken {
t.Errorf("Access token not preserved")
}
if token := newSession.GetRefreshToken(); token != tc.refreshToken {
t.Errorf("Refresh token not preserved")
}
})
}
}
func calculateExpectedCookieCount(accessToken, refreshToken string) int {
count := 3 // main, access, refresh
// Calculate number of chunks for access token
accessChunks := len(splitIntoChunks(accessToken, maxCookieSize))
if accessChunks > 1 {
count += accessChunks
}
// Calculate number of chunks for refresh token
refreshChunks := len(splitIntoChunks(refreshToken, maxCookieSize))
if refreshChunks > 1 {
count += refreshChunks
}
return count
}
+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