mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-13 09:45:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4977eba39d | ||
|
|
2ceea0f617 |
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -27,6 +28,38 @@ type TokenConfig struct {
|
|||||||
MaxChunkSize int
|
MaxChunkSize int
|
||||||
AllowOpaqueTokens bool
|
AllowOpaqueTokens bool
|
||||||
RequireJWTFormat 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
|
// Global session tracking to prevent memory leaks across all instances
|
||||||
@@ -63,6 +96,7 @@ var (
|
|||||||
MaxChunkSize: maxCookieSize,
|
MaxChunkSize: maxCookieSize,
|
||||||
AllowOpaqueTokens: true,
|
AllowOpaqueTokens: true,
|
||||||
RequireJWTFormat: false,
|
RequireJWTFormat: false,
|
||||||
|
FallbackToOpaque: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
IDTokenConfig = TokenConfig{
|
IDTokenConfig = TokenConfig{
|
||||||
@@ -299,6 +333,9 @@ func (cm *ChunkManager) processSingleToken(token string, compressed bool, config
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - TokenRetrievalResult with the validated token or validation error.
|
// - TokenRetrievalResult with the validated token or validation error.
|
||||||
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
|
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
|
// Content-only checks depend on the token string alone, so a token that
|
||||||
// passed them once skips them. Expiry and freshness always run.
|
// passed them once skips them. Expiry and freshness always run.
|
||||||
key := contentValidKey(token, config)
|
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 {
|
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
|
||||||
return TokenRetrievalResult{Token: "", Error: contentErr}
|
return TokenRetrievalResult{Token: "", Error: contentErr}
|
||||||
}
|
}
|
||||||
|
if !config.AlwaysOpaque {
|
||||||
times = cm.tokenTimesOf(token)
|
times = cm.tokenTimesOf(token)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
|
if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
|
||||||
return TokenRetrievalResult{Token: "", Error: expErr}
|
return TokenRetrievalResult{Token: "", Error: expErr}
|
||||||
@@ -331,8 +370,7 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
|
|||||||
return TokenRetrievalResult{Token: token, Error: nil}
|
return TokenRetrievalResult{Token: token, Error: nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
dotCount := strings.Count(token, ".")
|
isJWT := strings.Count(token, ".") == 2 && !config.AlwaysOpaque
|
||||||
isJWT := dotCount == 2
|
|
||||||
|
|
||||||
if config.RequireJWTFormat && !config.AllowOpaqueTokens {
|
if config.RequireJWTFormat && !config.AllowOpaqueTokens {
|
||||||
// Only accept JWT format tokens
|
// Only accept JWT format tokens
|
||||||
@@ -684,7 +722,7 @@ func (cm *ChunkManager) validateTokenSize(token string, config TokenConfig) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
|
if config.jwtShaped(token) {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
headerLen := len(parts[0])
|
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 {
|
if tokenLen > config.MaxLength {
|
||||||
err := fmt.Errorf("%s opaque token unusually large (%d bytes)", config.Type, tokenLen)
|
err := fmt.Errorf("%s opaque token unusually large (%d bytes)", config.Type, tokenLen)
|
||||||
return err
|
return err
|
||||||
@@ -751,13 +789,13 @@ func (cm *ChunkManager) validateTokenContent(token string, config TokenConfig) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
|
if config.jwtShaped(token) {
|
||||||
if err := cm.validateJWTContent(token, config); err != nil {
|
if err := cm.validateJWTContent(token, config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.AllowOpaqueTokens && !strings.Contains(token, ".") {
|
if config.opaqueShaped(token) {
|
||||||
if err := cm.validateOpaqueTokenContent(token, config); err != nil {
|
if err := cm.validateOpaqueTokenContent(token, config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
|
|||||||
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
||||||
// not the interpreted plugin). This build-stamped constant is therefore the
|
// not the interpreted plugin). This build-stamped constant is therefore the
|
||||||
// single source of truth for the version reported by anonymous usage telemetry.
|
// single source of truth for the version reported by anonymous usage telemetry.
|
||||||
const traefikoidcPluginVersion = "0.0.0-dev"
|
const traefikoidcPluginVersion = "1.0.64"
|
||||||
|
|||||||
Reference in New Issue
Block a user