Compare commits

...
2 Commits
Author SHA1 Message Date
github-actions[bot] 4977eba39d chore(release): v1.0.64 2026-11-11 17:31:36 +00:00
lukaszraczylo 2ceea0f617 fix(session): accept dotted opaque refresh tokens
Entra ID refresh tokens contain two dots but are not JWTs. The session
validator treated them as JWTs and rejected them. The session then lost
the refresh token, and users had to log in again after one hour.

A refresh token now falls back to opaque validation if its first segment
is not a JSON header with an alg member. A token that claims to be a JWT
keeps full JWT validation.

Fixes #167
2026-11-11 17:26:20 +00:00
3 changed files with 100 additions and 8 deletions
+54
View File
@@ -0,0 +1,54 @@
package traefikoidc
import (
"strings"
"testing"
"time"
)
// Entra ID refresh tokens start with "1." and contain two dots, but are not JWTs.
func TestRefreshTokenWithDotsIsOpaque(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
entra := "1.AXwAbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-" +
"." + strings.Repeat("Ab9_-xYz+/", 40) +
"." + strings.Repeat("Qw3rTy7u!~", 40)
t.Run("refresh config accepts dotted opaque token", func(t *testing.T) {
if res := cm.validateToken(entra, RefreshTokenConfig); res.Error != nil {
t.Fatalf("refresh token rejected: %v", res.Error)
}
})
t.Run("access config still validates dotted token as JWT", func(t *testing.T) {
res := cm.validateToken(entra, AccessTokenConfig)
if res.Error == nil {
t.Fatal("malformed JWT-shaped access token was accepted")
}
})
t.Run("refresh config still rejects control characters", func(t *testing.T) {
bad := strings.Replace(entra, "AXwA", "AX\x01A", 1)
if res := cm.validateToken(bad, RefreshTokenConfig); res.Error == nil {
t.Fatal("refresh token with control character was accepted")
}
})
t.Run("valid JWT refresh token keeps JWT validation", func(t *testing.T) {
future := time.Now().Add(time.Hour).Unix()
jwt := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(future + 3600), "iat": float64(future)})
res := cm.validateToken(jwt, RefreshTokenConfig)
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
t.Fatalf("error = %v, want issued-in-future from the JWT path", res.Error)
}
})
t.Run("malformed JWT refresh token stays rejected", func(t *testing.T) {
future := time.Now().Add(time.Hour).Unix()
jwt := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(future + 3600), "iat": float64(time.Now().Unix())})
parts := strings.Split(jwt, ".")
corrupt := parts[0] + "." + parts[1] + ".!!!not-base64!!!"
if res := cm.validateToken(corrupt, RefreshTokenConfig); res.Error == nil {
t.Fatal("JWT with a corrupt signature was accepted as opaque")
}
})
}
+44 -6
View File
@@ -5,6 +5,7 @@ import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"sync"
@@ -27,6 +28,38 @@ type TokenConfig struct {
MaxChunkSize int
AllowOpaqueTokens bool
RequireJWTFormat bool
// AlwaysOpaque skips JWT checks even when the value contains dots.
AlwaysOpaque bool
// FallbackToOpaque validates a dotted token as opaque when it fails JWT
// validation. Entra ID refresh tokens have two dots but are not JWTs.
FallbackToOpaque bool
}
// claimsJWT reports whether the first segment decodes to a JSON header with an
// "alg" member. A token that does so claims to be a JWT, so a malformed one
// must stay rejected rather than fall back to opaque validation.
func claimsJWT(token string) bool {
seg, _, _ := strings.Cut(token, ".")
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(seg, "="))
if err != nil {
return false
}
var header map[string]any
if json.Unmarshal(raw, &header) != nil {
return false
}
_, ok := header["alg"]
return ok
}
// jwtShaped reports whether token must pass JWT validation.
func (c TokenConfig) jwtShaped(token string) bool {
return c.RequireJWTFormat || (c.AllowOpaqueTokens && !c.AlwaysOpaque && strings.Contains(token, "."))
}
// opaqueShaped reports whether token must pass opaque-token validation.
func (c TokenConfig) opaqueShaped(token string) bool {
return c.AllowOpaqueTokens && (c.AlwaysOpaque || !strings.Contains(token, "."))
}
// Global session tracking to prevent memory leaks across all instances
@@ -63,6 +96,7 @@ var (
MaxChunkSize: maxCookieSize,
AllowOpaqueTokens: true,
RequireJWTFormat: false,
FallbackToOpaque: true,
}
IDTokenConfig = TokenConfig{
@@ -299,6 +333,9 @@ func (cm *ChunkManager) processSingleToken(token string, compressed bool, config
// Returns:
// - TokenRetrievalResult with the validated token or validation error.
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
if config.FallbackToOpaque && strings.Contains(token, ".") && !claimsJWT(token) {
config.AlwaysOpaque = true
}
// Content-only checks depend on the token string alone, so a token that
// passed them once skips them. Expiry and freshness always run.
key := contentValidKey(token, config)
@@ -314,8 +351,10 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
return TokenRetrievalResult{Token: "", Error: contentErr}
}
if !config.AlwaysOpaque {
times = cm.tokenTimesOf(token)
}
}
if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
return TokenRetrievalResult{Token: "", Error: expErr}
@@ -331,8 +370,7 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
return TokenRetrievalResult{Token: token, Error: nil}
}
dotCount := strings.Count(token, ".")
isJWT := dotCount == 2
isJWT := strings.Count(token, ".") == 2 && !config.AlwaysOpaque
if config.RequireJWTFormat && !config.AllowOpaqueTokens {
// Only accept JWT format tokens
@@ -684,7 +722,7 @@ func (cm *ChunkManager) validateTokenSize(token string, config TokenConfig) erro
return err
}
if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
if config.jwtShaped(token) {
parts := strings.Split(token, ".")
if len(parts) == 3 {
headerLen := len(parts[0])
@@ -708,7 +746,7 @@ func (cm *ChunkManager) validateTokenSize(token string, config TokenConfig) erro
}
}
if config.AllowOpaqueTokens && !strings.Contains(token, ".") {
if config.opaqueShaped(token) {
if tokenLen > config.MaxLength {
err := fmt.Errorf("%s opaque token unusually large (%d bytes)", config.Type, tokenLen)
return err
@@ -751,13 +789,13 @@ func (cm *ChunkManager) validateTokenContent(token string, config TokenConfig) e
return err
}
if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
if config.jwtShaped(token) {
if err := cm.validateJWTContent(token, config); err != nil {
return err
}
}
if config.AllowOpaqueTokens && !strings.Contains(token, ".") {
if config.opaqueShaped(token) {
if err := cm.validateOpaqueTokenContent(token, config); err != nil {
return err
}
+1 -1
View File
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
// not the interpreted plugin). This build-stamped constant is therefore the
// single source of truth for the version reported by anonymous usage telemetry.
const traefikoidcPluginVersion = "0.0.0-dev"
const traefikoidcPluginVersion = "1.0.64"