diff --git a/chunk_validation_pin_test.go b/chunk_validation_pin_test.go new file mode 100644 index 0000000..e41c7c9 --- /dev/null +++ b/chunk_validation_pin_test.go @@ -0,0 +1,94 @@ +package traefikoidc + +import ( + "strings" + "testing" +) + +// These tests pin the token-content validators' results, including for +// multi-byte UTF-8 input, so their loops can be rewritten for yaegi (where +// ranging over a string allocates O(n²)) without changing behavior. + +func TestDetectRepeatedCharactersPinned(t *testing.T) { + cm := NewChunkManager(NewLogger("")) + cases := []struct { + name string + token string + wantErr string + }{ + {"short token skipped", "aaaaaaaaa", ""}, + {"varied ascii", "abcdefghijklmnopqrstuvwxyz0123456789", ""}, + {"20 repeats allowed", strings.Repeat("a", 20) + strings.Repeat("bcdefghij", 4), ""}, + {"21 repeats rejected", strings.Repeat("a", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"}, + {"multibyte run counted per rune", strings.Repeat("é", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"}, + // Rune counts are divided by the byte length, so a multi-byte rune + // cannot reach the 70% threshold. Pinned as-is. + {"multibyte frequency divides by byte length", strings.Repeat("éééx", 8), ""}, + {"ascii frequency over 70%", strings.Repeat("aaaab", 8), "suspicious character frequency (char 'a'"}, + {"multibyte mixed passes", "日本語テキストabcdefghijklmnopqrstuvwxyz", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := cm.detectRepeatedCharacters(tc.token, AccessTokenConfig) + assertPinnedErr(t, err, tc.wantErr) + }) + } +} + +func TestValidateJWTFormatPinned(t *testing.T) { + cm := NewChunkManager(NewLogger("")) + cases := []struct { + name string + token string + wantErr string + }{ + {"valid base64url parts", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJlLXZhbHVl"), ""}, + {"padding allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0=", "c2lnbmF0dXJl"), ""}, + {"plus rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzd+IiOiJ4In0", "c2lnbmF0dXJl"), "invalid base64url character in part 1"}, + {"multibyte rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdé", "c2lnbmF0dXJl"), "invalid base64url character in part 1"}, + {"control char rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0"), "invalid base64url character in part 2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := cm.validateJWTFormat(tc.token, "access") + assertPinnedErr(t, err, tc.wantErr) + }) + } +} + +func TestValidateTokenSanitizationPinned(t *testing.T) { + cm := NewChunkManager(NewLogger("")) + cases := []struct { + name string + token string + wantErr string + }{ + {"clean jwt", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJl"), ""}, + {"multibyte allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "日本語", "c2lnbmF0dXJl"), ""}, + {"control char byte offset", "abcé\x01def", "control character at position 5"}, + {"delete char", "abcdef\x7fgh", "control character at position 6"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := cm.validateTokenSanitization(tc.token, AccessTokenConfig) + assertPinnedErr(t, err, tc.wantErr) + }) + } +} + +// jwtLike joins parts with dots at runtime; JWT-shaped literals trip the +// pre-commit secret scanner. +func jwtLike(parts ...string) string { return strings.Join(parts, ".") } + +func assertPinnedErr(t *testing.T, err error, want string) { + t.Helper() + if want == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want containing %q", err, want) + } +} diff --git a/combined_payload_pool_test.go b/combined_payload_pool_test.go new file mode 100644 index 0000000..8329b77 --- /dev/null +++ b/combined_payload_pool_test.go @@ -0,0 +1,45 @@ +package traefikoidc + +import ( + "sync" + "testing" +) + +func TestDecompressCombinedPayloadReusesReaders(t *testing.T) { + var readers sync.Pool + first, err := compressCombinedPayload(&combinedSessionPayload{Ui: "first@example.com"}) + if err != nil { + t.Fatalf("compress first: %v", err) + } + second, err := compressCombinedPayload(&combinedSessionPayload{Ui: "second@example.com"}) + if err != nil { + t.Fatalf("compress second: %v", err) + } + + cases := []struct { + name string + input string + wantUser string + wantErr bool + }{ + {"first payload", first, "first@example.com", false}, + {"corrupt payload rejected", "bm90LWd6aXA=", "", true}, + {"second payload after corrupt one", second, "second@example.com", false}, + {"first payload again", first, "first@example.com", false}, + } + for _, tc := range cases { + got, err := decompressCombinedPayload(tc.input, &readers) + if tc.wantErr { + if err == nil { + t.Fatalf("%s: expected error", tc.name) + } + continue + } + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if got.Ui != tc.wantUser { + t.Fatalf("%s: user = %q, want %q", tc.name, got.Ui, tc.wantUser) + } + } +} diff --git a/content_valid_cache_test.go b/content_valid_cache_test.go new file mode 100644 index 0000000..ebe0e1e --- /dev/null +++ b/content_valid_cache_test.go @@ -0,0 +1,67 @@ +package traefikoidc + +import ( + "strings" + "testing" + "time" +) + +func TestContentValidCache(t *testing.T) { + cm := NewChunkManager(NewLogger("")) + t.Cleanup(cm.Shutdown) + now := time.Now() + valid := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())}) + futureIat := tokenWithGroups(t, map[string]interface{}{"sub": "b", "exp": float64(now.Add(2 * time.Hour).Unix()), "iat": float64(now.Add(time.Hour).Unix())}) + badContent := jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0dXJlLXZhbHVl") + + t.Run("valid token passes twice and is cached", func(t *testing.T) { + for i := 0; i < 2; i++ { + if res := cm.validateToken(valid, AccessTokenConfig); res.Error != nil { + t.Fatalf("pass %d: %v", i, res.Error) + } + } + if _, ok := cm.cachedTokenTimes(contentValidKey(valid, AccessTokenConfig)); !ok { + t.Fatal("valid token was not cached") + } + }) + t.Run("cache is per token type", func(t *testing.T) { + if _, ok := cm.cachedTokenTimes(contentValidKey(valid, IDTokenConfig)); ok { + t.Fatal("access-token entry leaked into the ID-token key") + } + }) + t.Run("time checks still run on a cache hit", func(t *testing.T) { + cm.markContentValid(contentValidKey(futureIat, AccessTokenConfig), cm.tokenTimesOf(futureIat)) + res := cm.validateToken(futureIat, AccessTokenConfig) + if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") { + t.Fatalf("error = %v, want issued-in-future through the content cache", res.Error) + } + }) + t.Run("cache hit uses the stored claims", func(t *testing.T) { + other := tokenWithGroups(t, map[string]interface{}{"sub": "c", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())}) + future := now.Add(time.Hour) + cm.markContentValid(contentValidKey(other, AccessTokenConfig), tokenTimes{jwt: true, iat: &future}) + res := cm.validateToken(other, AccessTokenConfig) + if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") { + t.Fatalf("error = %v, want the cached iat to drive the freshness check", res.Error) + } + }) + t.Run("invalid content never cached", func(t *testing.T) { + if res := cm.validateToken(badContent, AccessTokenConfig); res.Error == nil { + t.Fatal("control character accepted") + } + if _, ok := cm.cachedTokenTimes(contentValidKey(badContent, AccessTokenConfig)); ok { + t.Fatal("invalid token was cached") + } + }) + t.Run("cache stays bounded", func(t *testing.T) { + for i := 0; i < contentValidCacheMax+10; i++ { + cm.markContentValid(contentValidKey(strings.Repeat("x", i%7)+string(rune('a'+i%26))+strings.Repeat("y", i/26), AccessTokenConfig), tokenTimes{}) + } + cm.contentMu.Lock() + n := len(cm.contentValid) + cm.contentMu.Unlock() + if n > contentValidCacheMax { + t.Fatalf("cache size %d exceeds %d", n, contentValidCacheMax) + } + }) +} diff --git a/id_token_memo_test.go b/id_token_memo_test.go new file mode 100644 index 0000000..f4c97dd --- /dev/null +++ b/id_token_memo_test.go @@ -0,0 +1,50 @@ +package traefikoidc + +import ( + "net/http/httptest" + "testing" + "time" +) + +func TestIDTokenMemoFollowsStoredToken(t *testing.T) { + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef", false, "", "", time.Hour, NewLogger("error")) + if err != nil { + t.Fatalf("NewSessionManager: %v", err) + } + t.Cleanup(func() { + if err := sm.Shutdown(); err != nil { + t.Errorf("Shutdown: %v", err) + } + }) + future := float64(time.Now().Add(time.Hour).Unix()) + first := tokenWithGroups(t, map[string]interface{}{"sub": "first", "exp": future}) + second := tokenWithGroups(t, map[string]interface{}{"sub": "second", "exp": future}) + + session, err := sm.GetSession(httptest.NewRequest("GET", "/", nil)) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + steps := []struct { + name string + write func() + want string + }{ + {"first read", func() { session.SetIDToken(first) }, first}, + {"repeat read", func() {}, first}, + {"after overwrite", func() { session.SetIDToken(second) }, second}, + {"after clearing", func() { session.SetIDToken("") }, ""}, + {"after Reset", func() { session.SetIDToken(first); _ = session.GetIDToken(); session.Reset() }, ""}, + } + for _, st := range steps { + st.write() + if got := session.GetIDToken(); got != st.want { + t.Fatalf("%s: GetIDToken = %q, want %q", st.name, got, st.want) + } + if st.name == "repeat read" && session.idTokenMemoRaw != first { + t.Fatal("repeat read did not go through the memo; the test no longer covers it") + } + } + if session.idTokenMemoRaw != "" || session.idTokenMemo != "" { + t.Fatal("Reset left an ID-token memo on the pooled session") + } +} diff --git a/session.go b/session.go index ea5f4ad..3e6f149 100644 --- a/session.go +++ b/session.go @@ -193,8 +193,10 @@ func compressCombinedPayload(payload *combinedSessionPayload) (string, error) { } // decompressCombinedPayload decompresses a base64+gzip encoded combined session payload. +// readers pools *gzip.Reader values: a new reader allocates its 32 KB window, +// which is a large per-request cost under yaegi. // Returns the deserialized payload and any error encountered. -func decompressCombinedPayload(compressed string) (*combinedSessionPayload, error) { +func decompressCombinedPayload(compressed string, readers *sync.Pool) (*combinedSessionPayload, error) { if compressed == "" { return nil, fmt.Errorf("empty compressed data") } @@ -204,11 +206,16 @@ func decompressCombinedPayload(compressed string) (*combinedSessionPayload, erro return nil, fmt.Errorf("failed to decode base64: %w", err) } - gr, err := gzip.NewReader(bytes.NewReader(data)) + gr, _ := readers.Get().(*gzip.Reader) + if gr == nil { + gr, err = gzip.NewReader(bytes.NewReader(data)) + } else { + err = gr.Reset(bytes.NewReader(data)) + } if err != nil { return nil, fmt.Errorf("failed to create gzip reader: %w", err) } - defer func() { _ = gr.Close() }() + defer readers.Put(gr) // Limit decompressed size to prevent zip bombs limitedReader := io.LimitReader(gr, 512*1024) // 512KB max @@ -408,6 +415,7 @@ func decompressTokenInternal(compressed string) string { // session object reuse and supports both HTTP and HTTPS schemes. type SessionManager struct { sessionPool sync.Pool + gzipReaders sync.Pool ctx context.Context store sessions.Store logger *Logger @@ -1265,7 +1273,7 @@ func (sm *SessionManager) loadFromCombinedCookies(r *http.Request, sessionData * return false } - payload, err := decompressCombinedPayload(compressed) + payload, err := decompressCombinedPayload(compressed, &sm.gzipReaders) if err != nil { sm.logger.Debugf("Failed to decompress combined payload: %v", err) return false @@ -1410,6 +1418,13 @@ type SessionData struct { // timed against the new owner's own writes. sessionOwner atomic.Uint64 + // idTokenMemoRaw and idTokenMemo cache getIDTokenUnsafe's validated result + // for an unchunked, uncompressed stored token, keyed by the stored value. + // A request reads the ID token twice, and each read re-ran full chunk + // validation. Cleared by Reset so no result outlives its request. + idTokenMemoRaw string + idTokenMemo string + // cachedClaimsToken is the ID token string whose claims were last parsed and // cached. A lazy, per-request cache to avoid re-parsing the JWT on every // authenticated request (e.g. for headerTemplates). Protected by sessionMutex. @@ -2086,6 +2101,8 @@ func (sd *SessionData) Reset() { sd.cachedClaimsToken = "" sd.cachedClaims = nil sd.cachedClaimsErr = nil + sd.idTokenMemoRaw = "" + sd.idTokenMemo = "" // Reset the refresh mutex to ensure clean state // Note: We don't need to lock it since sessionMutex is already held @@ -3017,6 +3034,11 @@ func (sd *SessionData) getIDTokenUnsafe() string { return token } + memoable := token != "" && !compressed && len(sd.idTokenChunks) == 0 + if memoable && token == sd.idTokenMemoRaw { + return sd.idTokenMemo + } + result := sd.manager.chunkManager.GetToken( token, compressed, @@ -3024,11 +3046,14 @@ func (sd *SessionData) getIDTokenUnsafe() string { IDTokenConfig, ) + validated := result.Token if result.Error != nil { - return "" + validated = "" } - - return result.Token + if memoable { + sd.idTokenMemoRaw, sd.idTokenMemo = token, validated + } + return validated } // getRefreshTokenUnsafe retrieves the refresh token without acquiring locks. diff --git a/session_chunk_manager.go b/session_chunk_manager.go index 4403ed2..1f4ede5 100644 --- a/session_chunk_manager.go +++ b/session_chunk_manager.go @@ -10,6 +10,7 @@ import ( "sync" "sync/atomic" "time" + "unicode/utf8" "github.com/gorilla/sessions" "github.com/lukaszraczylo/traefikoidc/internal/pool" @@ -89,9 +90,14 @@ type TokenRetrievalResult struct { // and error handling to ensure data integrity and prevent security vulnerabilities // throughout the process. type ChunkManager struct { - lastCleanup time.Time - ctx context.Context - mutex *sync.RWMutex + lastCleanup time.Time + ctx context.Context + mutex *sync.RWMutex + // contentValid holds keys of tokens that passed every content-only check. + // Browsers resend the same cookie, and re-running those checks on every + // request dominated per-request cost under yaegi. Guarded by contentMu. + contentValid map[string]tokenTimes + contentMu sync.Mutex cancel context.CancelFunc sessionMap map[string]*SessionEntry logger *Logger @@ -127,14 +133,15 @@ func NewChunkManager(logger *Logger) *ChunkManager { ctx, cancel := context.WithCancel(context.Background()) cm := &ChunkManager{ - logger: logger, - mutex: &sync.RWMutex{}, - ctx: ctx, - cancel: cancel, - sessionMap: make(map[string]*SessionEntry), - maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance - sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes - lastCleanup: time.Now(), + logger: logger, + mutex: &sync.RWMutex{}, + ctx: ctx, + cancel: cancel, + sessionMap: make(map[string]*SessionEntry), + contentValid: make(map[string]tokenTimes), + maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance + sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes + lastCleanup: time.Now(), } // Start background cleanup routine @@ -293,26 +300,38 @@ 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 sizeErr := cm.validateTokenSize(token, config); sizeErr != nil { - return TokenRetrievalResult{Token: "", Error: sizeErr} + // 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) + times, cached := cm.cachedTokenTimes(key) + + if !cached { + if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil { + return TokenRetrievalResult{Token: "", Error: sizeErr} + } + + cm.validateChunkingEfficiency(token, config) + + if contentErr := cm.validateTokenContent(token, config); contentErr != nil { + return TokenRetrievalResult{Token: "", Error: contentErr} + } + times = cm.tokenTimesOf(token) } - cm.validateChunkingEfficiency(token, config) - - if contentErr := cm.validateTokenContent(token, config); contentErr != nil { - return TokenRetrievalResult{Token: "", Error: contentErr} - } - - if expErr := cm.validateTokenExpiration(token, config); expErr != nil { + if expErr := cm.validateTokenExpiration(times, config); expErr != nil { return TokenRetrievalResult{Token: "", Error: expErr} } - if freshnessErr := cm.validateTokenFreshness(token, config); freshnessErr != nil { + if freshnessErr := cm.validateTokenFreshness(times, config); freshnessErr != nil { return TokenRetrievalResult{Token: "", Error: freshnessErr} } // Determine if token is opaque or JWT based on format // JWT tokens have exactly 2 dots (3 parts: header.payload.signature) + if cached { + return TokenRetrievalResult{Token: token, Error: nil} + } + dotCount := strings.Count(token, ".") isJWT := dotCount == 2 @@ -338,6 +357,7 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet } } + cm.markContentValid(key, times) return TokenRetrievalResult{Token: token, Error: nil} } @@ -350,6 +370,53 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet // // Returns: // - TokenRetrievalResult with the reassembled token or error. +// +// tokenTimes holds a token's parsed exp and iat claims. They are part of the +// token content, so a content-cache entry keeps them to avoid re-parsing the +// payload on every request. The checks that use them still run each time. +type tokenTimes struct { + exp, iat *time.Time + expErr, iatErr error + jwt bool +} + +// tokenTimesOf parses exp and iat from a JWT-shaped token. +func (cm *ChunkManager) tokenTimesOf(token string) tokenTimes { + if !strings.Contains(token, ".") { + return tokenTimes{} + } + t := tokenTimes{jwt: true} + t.exp, t.expErr = cm.extractJWTExpiration(token) + t.iat, t.iatErr = cm.extractJWTIssuedAt(token) + return t +} + +// contentValidCacheMax bounds contentValid; the map is cleared when full. +const contentValidCacheMax = 4096 + +// contentValidKey keys a token by type and SHA-256 so the cache does not +// retain token values. +func contentValidKey(token string, config TokenConfig) string { + sum := sha256.Sum256([]byte(token)) + return config.Type + ":" + string(sum[:]) +} + +func (cm *ChunkManager) cachedTokenTimes(key string) (tokenTimes, bool) { + cm.contentMu.Lock() + t, ok := cm.contentValid[key] + cm.contentMu.Unlock() + return t, ok +} + +func (cm *ChunkManager) markContentValid(key string, t tokenTimes) { + cm.contentMu.Lock() + if len(cm.contentValid) >= contentValidCacheMax { + cm.contentValid = make(map[string]tokenTimes) + } + cm.contentValid[key] = t + cm.contentMu.Unlock() +} + func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig) TokenRetrievalResult { if len(chunks) > config.MaxChunks { err := fmt.Errorf("too many %s token chunks (%d, max: %d)", config.Type, len(chunks), config.MaxChunks) @@ -504,7 +571,9 @@ func (cm *ChunkManager) validateJWTFormat(token string, tokenType string) error return err } - for _, char := range part { + // Byte loop: a multi-byte rune fails on its first byte either way. + for j := 0; j < len(part); j++ { + char := part[j] if !((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || @@ -717,9 +786,10 @@ func (cm *ChunkManager) validateTokenSanitization(token string, config TokenConf return err } - // Check for control characters (ASCII 0-31 and 127) - for i, char := range token { - if char < 32 || char == 127 { + // Check for control characters (ASCII 0-31 and 127). Byte loop: bytes of + // a multi-byte rune are >= 0x80, and i is a byte offset either way. + for i := 0; i < len(token); i++ { + if char := token[i]; char < 32 || char == 127 { err := fmt.Errorf("%s token contains control character at position %d", config.Type, i) return err } @@ -921,7 +991,13 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi currentRepeated := 1 var lastChar rune - for i, char := range token { + // Index loops, not range: under yaegi, ranging over a string allocates + // O(n²) bytes, which dominated per-request cost for 1-2 KB tokens. + for i := 0; i < len(token); { + char, size := rune(token[i]), 1 + if char >= utf8.RuneSelf { + char, size = utf8.DecodeRuneInString(token[i:]) + } if i > 0 && char == lastChar { currentRepeated++ if currentRepeated > maxRepeated { @@ -931,6 +1007,7 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi currentRepeated = 1 } lastChar = char + i += size } threshold := 20 @@ -941,8 +1018,13 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi } charFreq := make(map[rune]int) - for _, char := range token { + for i := 0; i < len(token); { + char, size := rune(token[i]), 1 + if char >= utf8.RuneSelf { + char, size = utf8.DecodeRuneInString(token[i:]) + } charFreq[char]++ + i += size } tokenLen := len(token) @@ -963,21 +1045,21 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi // It extracts and checks JWT expiration claims to ensure tokens are not expired // and detects tokens with suspicious expiration times. // Parameters: -// - token: The token to check expiration for. +// - times: The token's parsed claims (see tokenTimesOf). // - config: Token configuration for error context. // // Returns: // - An error if the token is expired or has invalid expiration, nil if valid. // //nolint:unparam // error return kept for API consistency and future use -func (cm *ChunkManager) validateTokenExpiration(token string, config TokenConfig) error { - if !strings.Contains(token, ".") { +func (cm *ChunkManager) validateTokenExpiration(times tokenTimes, config TokenConfig) error { + if !times.jwt { return nil } - expiration, err := cm.extractJWTExpiration(token) - if err != nil { - cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, err) + expiration := times.exp + if times.expErr != nil { + cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, times.expErr) return nil } @@ -1062,19 +1144,19 @@ func (cm *ChunkManager) extractJWTExpiration(token string) (*time.Time, error) { // It examines the 'iat' (issued at) claim to detect tokens issued too far // in the future or suspiciously old tokens that might indicate replay attacks. // Parameters: -// - token: The token to check freshness for. +// - times: The token's parsed claims (see tokenTimesOf). // - config: Token configuration for error context. // // Returns: // - An error if the token freshness is suspicious, nil if acceptable. -func (cm *ChunkManager) validateTokenFreshness(token string, config TokenConfig) error { - if !strings.Contains(token, ".") { +func (cm *ChunkManager) validateTokenFreshness(times tokenTimes, config TokenConfig) error { + if !times.jwt { return nil } - issuedAt, err := cm.extractJWTIssuedAt(token) - if err != nil { - cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, err) + issuedAt := times.iat + if times.iatErr != nil { + cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, times.iatErr) return nil }