fix(auth): harden OIDC flow, token validation, session, and bearer handling

- replay entry covers exp + clock-skew window; callback GET-only (RFC 6749)
- callback rate-limit returns 429 + Retry-After; panic-recovery sets no-store
- cross-chunk integrity for chunked tokens; bounded chunked-token read scan
- HTTPError classification so 5xx/429 token-endpoint responses retry
- redaction of token/code/state in logs; per-source external auth gate
This commit is contained in:
2026-08-14 16:16:26 +01:00
parent 3eb11b07e9
commit 8640451877
19 changed files with 2002 additions and 508 deletions
+394 -179
View File
@@ -140,12 +140,14 @@ type combinedSessionPayload struct {
X map[string]interface{} `json:"x,omitempty"`
A string `json:"a,omitempty"`
R string `json:"r,omitempty"`
Ri int64 `json:"ri,omitempty"` // refresh-token issued_at (unix), preserved so maxRefreshTokenAge survives combined round-trip
I string `json:"i,omitempty"`
Ui string `json:"ui,omitempty"`
Cs string `json:"cs,omitempty"`
N string `json:"n,omitempty"`
Cv string `json:"cv,omitempty"`
Ip string `json:"ip,omitempty"`
Ru string `json:"ru,omitempty"` // redirect_uri used at initiate; reused at callback so a per-request rebuild can't drift (R136)
Ca int64 `json:"ca,omitempty"`
Rc int `json:"rc,omitempty"`
Au bool `json:"au,omitempty"`
@@ -163,6 +165,7 @@ var knownSessionKeys = map[string]bool{
"nonce": true,
"code_verifier": true,
"incoming_path": true,
"redirect_url": true,
"created_at": true,
"redirect_count": true,
}
@@ -381,16 +384,6 @@ func decompressTokenInternal(compressed string) string {
limitedReader := io.LimitReader(gz, 500*1024)
if cap(readerBuf) >= 512*1024 {
readerBuf = readerBuf[:cap(readerBuf)]
n, err := limitedReader.Read(readerBuf)
if err != nil && err != io.EOF {
return compressed
}
decompressed := readerBuf[:n]
return string(decompressed)
}
decompressed, err := io.ReadAll(limitedReader)
if err != nil {
return compressed
@@ -912,6 +905,14 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h
sm.logger.Debugf("Using configured cookie domain: %s", sm.cookieDomain)
} else if options.Domain == "" && r != nil {
host := r.Host
// Strip the port before the subdomain-bounding check below; a ported
// r.Host (e.g. app.example.com:8080) would otherwise fail
// HasSuffix(host, "."+fh) and wrongly fall back to the strict
// parent X-Forwarded-Host, scoping the token-bearing session cookie
// to every sibling subdomain.
if ci := strings.Index(host, ":"); ci != -1 {
host = host[:ci]
}
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
// Only use the forwarded host when it is the serving host or a
@@ -1029,7 +1030,7 @@ func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Reque
Domain: domain,
MaxAge: -1,
HttpOnly: true,
Secure: r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil || sm.forceHTTPS,
Secure: strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") || r.TLS != nil || sm.forceHTTPS,
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, deleteCookie)
@@ -1059,7 +1060,44 @@ func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Reque
//
// Returns:
// - The loaded SessionData instance.
// - An error if session loading or validation fails.
//
// newSession returns a clean, pooled session with no cookie state loaded. It
// is used when the request's session cookie is expired: GetSession keeps
// re-reading that same expired cookie and returns nil, so the caller needs a
// fresh session to (re-)initiate authentication rather than erroring out.
// Mirrors GetSession's pool/activeSessions accounting.
func (sm *SessionManager) newSession(r *http.Request) *SessionData {
sessionData, _ := sm.sessionPool.Get().(*SessionData) // Safe to ignore: pool return is best-effort
if sessionData == nil {
return nil
}
sessionData.Reset() // clear any stale state from a previous pooled user
sessionData.request = r
sessionData.inUse.Store(true)
sessionData.dirty = false
atomic.AddInt64(&sm.poolHits, 1)
atomic.AddInt64(&sm.activeSessions, 1)
// GetSession normally populates the four legacy sub-sessions from the
// request; newSession runs where that read already failed (expired
// cookie), so initialize non-nil containers here. Downstream auth
// flow (SetCSRF/SetNonce/... then Save) writes through these.
if sessionData.mainSession == nil {
sessionData.mainSession, _ = sm.store.Get(r, sm.mainCookieName())
}
if sessionData.accessSession == nil {
sessionData.accessSession, _ = sm.store.Get(r, sm.accessTokenCookieName())
}
if sessionData.refreshSession == nil {
sessionData.refreshSession, _ = sm.store.Get(r, sm.refreshTokenCookieName())
}
if sessionData.idTokenSession == nil {
sessionData.idTokenSession, _ = sm.store.Get(r, sm.idTokenCookieName())
}
return sessionData
}
// - An error if session loading or validation fails.
func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
sessionData, _ := sm.sessionPool.Get().(*SessionData) // Safe to ignore: pool return is best-effort
atomic.AddInt64(&sm.poolHits, 1)
@@ -1230,6 +1268,7 @@ func (sm *SessionManager) loadFromCombinedCookies(r *http.Request, sessionData *
sessionData.mainSession.Values["nonce"] = payload.N
sessionData.mainSession.Values["code_verifier"] = payload.Cv
sessionData.mainSession.Values["incoming_path"] = payload.Ip
sessionData.mainSession.Values["redirect_url"] = payload.Ru
sessionData.mainSession.Values["created_at"] = payload.Ca
sessionData.mainSession.Values["redirect_count"] = payload.Rc
@@ -1243,6 +1282,9 @@ func (sm *SessionManager) loadFromCombinedCookies(r *http.Request, sessionData *
sessionData.refreshSession.Values["token"] = payload.R
sessionData.refreshSession.Values["compressed"] = false
if payload.Ri > 0 {
sessionData.refreshSession.Values["issued_at"] = payload.Ri
}
sessionData.idTokenSession.Values["token"] = payload.I
sessionData.idTokenSession.Values["compressed"] = false
@@ -1259,7 +1301,14 @@ func (sm *SessionManager) loadFromCombinedCookies(r *http.Request, sessionData *
// - chunks: The map (typically SessionData.accessTokenChunks or SessionData.refreshTokenChunks)
// to populate with the found session chunks.
func (sm *SessionManager) getTokenChunkSessions(r *http.Request, baseName string, chunks map[int]*sessions.Session) {
for i := 0; ; i++ {
// Bound the scan at MaxChunks (50) so an oversized set of chunk
// cookies (attack or corruption) can't drive unbounded
// securecookie decrypts. Loading through index 50 still lets
// processChunkedToken's len(chunks) > MaxChunks check fire, so the
// overflow → error behavior is unchanged; indices beyond 50 would be
// rejected anyway (R183).
const maxChunkScan = 50
for i := 0; i <= maxChunkScan; i++ {
sessionName := fmt.Sprintf("%s_%d", baseName, i)
session, err := sm.store.Get(r, sessionName)
if err != nil || session.IsNew {
@@ -1305,6 +1354,12 @@ type SessionData struct {
dirty bool
// expireCookie is a one-shot flag set by Clear to force the cookies it
// writes to be expired (Max-Age: -1). Without it, Save rebuilds a
// positive MaxAge and logout leaves an empty session cookie alive for
// the full session lifetime instead of expiring it (R101).
expireCookie bool
inUse atomic.Bool
// cachedClaimsToken is the ID token string whose claims were last parsed and
@@ -1346,11 +1401,23 @@ func (sd *SessionData) MarkDirty() {
// Returns:
// - An error if saving any of the session components fails.
func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error {
isSecure := r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil || sd.manager.forceHTTPS
// X-Forwarded-Proto value is compared case-insensitively: reverse proxies
// (nginx, Traefik) emit lowercase "https", but a client-controllable or
// non-canonical value like "HTTPS" must still yield a Secure cookie.
// Case-sensitive comparison here would silently downgrade the session
// cookie to transport-unsafe on such a header (R143).
isSecure := strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") || r.TLS != nil || sd.manager.forceHTTPS
options := sd.manager.getSessionOptions(isSecure)
options = sd.manager.EnhanceSessionSecurity(options, r)
if sd.expireCookie {
// Clear set this flag: the cookies written below must be expired,
// not re-issued with a fresh positive MaxAge.
options.MaxAge = -1
sd.expireCookie = false
}
// Use combined storage for new sessions
if sd.useCombinedStorage {
return sd.saveCombined(r, w, options)
@@ -1363,7 +1430,11 @@ func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error {
// saveCombined saves all session data in a single compressed, chunked cookie.
// This reduces cookie count and total size through combined compression.
func (sd *SessionData) saveCombined(r *http.Request, w http.ResponseWriter, options *sessions.Options) error {
// Build the combined payload
// Build the combined payload. All token/main fields are read under
// sessionMutex.RLock so a concurrent Set* (which writes under the
// write side) can't tear the payload mid-build (R132). The extra-map
// walk below already took the Read side; extend it to the field reads.
sd.sessionMutex.RLock()
payload := &combinedSessionPayload{
A: sd.getAccessTokenUnsafe(),
R: sd.getRefreshTokenUnsafe(),
@@ -1374,12 +1445,18 @@ func (sd *SessionData) saveCombined(r *http.Request, w http.ResponseWriter, opti
N: sd.getNonceUnsafe(),
Cv: sd.getCodeVerifierUnsafe(),
Ip: sd.getIncomingPathUnsafe(),
Ru: sd.getRedirectURLUnsafe(),
Ca: sd.getCreatedAtUnsafe(),
Rc: sd.getRedirectCountUnsafe(),
}
// Persist the refresh token's issued_at so maxRefreshTokenAge keeps
// working after a combined save/load round-trip (it lives on
// refreshSession, which is otherwise dropped by combined storage).
if ra, ok := sd.refreshSession.Values["issued_at"].(int64); ok {
payload.Ri = ra
}
// Collect extra session values not handled by the standard fields
sd.sessionMutex.RLock()
if sd.mainSession != nil && len(sd.mainSession.Values) > 0 {
extra := make(map[string]interface{})
for key, val := range sd.mainSession.Values {
@@ -1404,7 +1481,11 @@ func (sd *SessionData) saveCombined(r *http.Request, w http.ResponseWriter, opti
compressed, err := compressCombinedPayload(payload)
if err != nil {
sd.manager.logger.Errorf("Failed to compress combined payload: %v", err)
// Fall back to legacy storage on compression failure
// Fall back to legacy storage on compression failure. Expire any
// pre-existing combined chunk cookies first, or GetSession
// (combined-first) would keep serving the stale combined payload
// and shadow the freshly written legacy tokens (R163).
sd.expireOldCombinedChunks(r, w, options, 0)
return sd.saveLegacy(r, w, options)
}
@@ -1413,8 +1494,15 @@ func (sd *SessionData) saveCombined(r *http.Request, w http.ResponseWriter, opti
// Split into chunks
chunks := splitCombinedIntoChunks(compressed, maxCookieSize)
if len(chunks) > maxCombinedChunks {
sd.manager.logger.Errorf("Combined session requires %d chunks, exceeds max %d", len(chunks), maxCombinedChunks)
return fmt.Errorf("session data too large: requires %d chunks, max is %d", len(chunks), maxCombinedChunks)
sd.manager.logger.Errorf("Combined session requires %d chunks, exceeds max %d; falling back to legacy storage", len(chunks), maxCombinedChunks)
// The legacy path supports up to 50 token chunks (~100KB), so large
// tokens must not drop the session entirely — persist via the
// legacy (per-token chunk) format instead of erroring and forcing
// the user to re-authenticate (R97). Expire the old combined
// chunks first so GetSession's combined-first read serves the
// fresh legacy tokens rather than the stale combined payload (R163).
sd.expireOldCombinedChunks(r, w, options, 0)
return sd.saveLegacy(r, w, options)
}
sd.manager.logger.Debugf("Combined session split into %d chunks", len(chunks))
@@ -1573,20 +1661,24 @@ func (sd *SessionData) expireLegacyCookies(r *http.Request, w http.ResponseWrite
expireLegacyMain(sd.manager.refreshTokenCookieName())
expireLegacyMain(sd.manager.idTokenCookieName())
// Expire legacy chunk cookies (safe to clear values, they're separate from main sessions)
for i := 0; i < 50; i++ { // Max legacy chunks was 50
accessChunk := fmt.Sprintf("%s_%d", sd.manager.accessTokenCookieName(), i)
refreshChunk := fmt.Sprintf("%s_%d", sd.manager.refreshTokenCookieName(), i)
idChunk := fmt.Sprintf("%s_%d", sd.manager.idTokenCookieName(), i)
session, err := sd.manager.store.Get(r, accessChunk)
if err != nil || session.IsNew {
break // No more chunks
// Expire legacy chunk cookies (safe to clear values, they're separate from main sessions).
// Each token type is walked to its own limit so a small, un-chunked access token
// does not gate refresh/id chunk expiry (R133 P2-A): previously the shared
// access-driven loop broke all three series at the first absent access chunk,
// leaving stale refresh/id legacy chunk cookies behind after combined migration.
expireLegacyChunkSeries := func(baseName string) {
for i := 0; i < 50; i++ { // Max legacy chunks was 50
chunk := fmt.Sprintf("%s_%d", baseName, i)
session, err := sd.manager.store.Get(r, chunk)
if err != nil || session.IsNew {
break // No more chunks for this token type
}
expireLegacyChunk(chunk)
}
expireLegacyChunk(accessChunk)
expireLegacyChunk(refreshChunk)
expireLegacyChunk(idChunk)
}
expireLegacyChunkSeries(sd.manager.accessTokenCookieName())
expireLegacyChunkSeries(sd.manager.refreshTokenCookieName())
expireLegacyChunkSeries(sd.manager.idTokenCookieName())
}
// clearSessionValues removes all values from a session and optionally expires it.
@@ -1667,6 +1759,7 @@ func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error {
// Return a test error without trying to save problematic data
err = fmt.Errorf("test error triggered by X-Test-Error header")
} else {
sd.expireCookie = true
err = sd.Save(r, w)
}
}
@@ -1973,19 +2066,19 @@ func (sd *SessionData) SetAccessToken(token string) {
return
}
if sd.request != nil {
sd.expireAccessTokenChunksEnhanced(nil)
}
for k := range sd.accessTokenChunks {
delete(sd.accessTokenChunks, k)
}
// Defer clearing the PREVIOUS access token (its HTTP chunk cookies and the
// in-memory chunk map) until the new token's storage is fully validated.
// Clearing first, then aborting on a too-large new token (>100KB after
// compression, or >50 chunks) would leave the previous still-valid access
// token nowhere — GetAccessToken returns "" and the user is forced to
// re-authenticate (R122). We commit the clear only once we're replacing it.
if token == "" {
if sd.accessSession != nil {
sd.accessSession.Values["token"] = ""
sd.accessSession.Values["compressed"] = false
}
sd.clearAccessTokenChunks()
return
}
@@ -2014,6 +2107,7 @@ func (sd *SessionData) SetAccessToken(token string) {
}
if len(compressed) <= maxCookieSize {
sd.clearAccessTokenChunks()
if sd.accessSession != nil {
sd.accessSession.Values["token"] = compressed
sd.accessSession.Values["compressed"] = (compressed != token)
@@ -2024,10 +2118,12 @@ func (sd *SessionData) SetAccessToken(token string) {
}
}
} else {
if sd.accessSession != nil {
sd.accessSession.Values["token"] = ""
sd.accessSession.Values["compressed"] = (compressed != token)
}
// WARNING: do not clear the existing token (accessSession.Values
// ["token"] / accessTokenChunks) until the new token's chunks are
// validated below. Clearing first meant an abort on any chunk
// failure (too many chunks, reassembly mismatch) left NO token
// anywhere -> GetAccessToken returned "" -> forced re-auth (data
// loss of the previous still-valid access token).
chunks := splitIntoChunks(compressed, maxCookieSize)
@@ -2042,11 +2138,23 @@ func (sd *SessionData) SetAccessToken(token string) {
}
testReassembled := strings.Join(chunks, "")
// R188 cross-chunk integrity: bind all chunks to one another so a
// mix of authentic-but-different chunk sets is caught at assembly
// (see processChunkedToken). Stored on every chunk.
chunkIntegrity := fmt.Sprintf("%x", sha256.Sum256([]byte(testReassembled)))
if testReassembled != compressed {
sd.manager.logger.Debug("Access token chunk reassembly test failed")
return
}
// Validation passed: now it is safe to clear the existing token
// before storing the new chunked one.
sd.clearAccessTokenChunks()
if sd.accessSession != nil {
sd.accessSession.Values["token"] = ""
sd.accessSession.Values["compressed"] = (compressed != token)
}
for i, chunkData := range chunks {
sessionName := fmt.Sprintf("%s_%d", sd.manager.accessTokenCookieName(), i)
@@ -2080,6 +2188,7 @@ func (sd *SessionData) SetAccessToken(token string) {
session.Values["compressed"] = (compressed != token)
session.Values["chunk_created_at"] = time.Now().Unix()
session.Values["token_total"] = len(chunks)
session.Values["chunk_integrity"] = chunkIntegrity
sd.accessTokenChunks[i] = session
}
@@ -2087,6 +2196,20 @@ func (sd *SessionData) SetAccessToken(token string) {
}
}
// clearAccessTokenChunks expires the current access token's HTTP chunk cookies
// (via expireAccessTokenChunksEnhanced) and clears the in-memory access
// token chunk map. Callers invoke it only once they are committed to
// replacing the token, so a failed (too-large) store leaves the previous
// valid access token intact. Called with sd.sessionMutex already held.
func (sd *SessionData) clearAccessTokenChunks() {
if sd.request != nil {
sd.expireAccessTokenChunksEnhanced(nil)
}
for k := range sd.accessTokenChunks {
delete(sd.accessTokenChunks, k)
}
}
// GetRefreshToken retrieves the user's refresh token from session storage.
// It handles both single-cookie storage and chunked storage for large tokens,
// with automatic decompression if the token was compressed.
@@ -2182,15 +2305,14 @@ func (sd *SessionData) SetRefreshToken(token string) {
}
sd.dirty = true
if sd.request != nil {
sd.expireRefreshTokenChunksEnhanced(nil)
}
for k := range sd.refreshTokenChunks {
delete(sd.refreshTokenChunks, k)
}
if token == "" {
// Explicit clear: the caller wants the previous token gone.
if sd.request != nil {
sd.expireRefreshTokenChunksEnhanced(nil)
}
for k := range sd.refreshTokenChunks {
delete(sd.refreshTokenChunks, k)
}
sd.refreshSession.Values["token"] = ""
sd.refreshSession.Values["compressed"] = false
return
@@ -2207,70 +2329,98 @@ func (sd *SessionData) SetRefreshToken(token string) {
}
if len(compressed) <= maxCookieSize {
// Single-cookie layout: nothing to validate, safe to swap.
if sd.request != nil {
sd.expireRefreshTokenChunksEnhanced(nil)
}
for k := range sd.refreshTokenChunks {
delete(sd.refreshTokenChunks, k)
}
sd.refreshSession.Values["token"] = compressed
sd.refreshSession.Values["compressed"] = (compressed != token)
sd.refreshSession.Values["issued_at"] = time.Now().Unix()
} else {
sd.refreshSession.Values["token"] = ""
sd.refreshSession.Values["compressed"] = (compressed != token)
sd.refreshSession.Values["issued_at"] = time.Now().Unix()
chunks := splitIntoChunks(compressed, maxCookieSize)
if len(chunks) == 0 {
sd.manager.logger.Errorf("CRITICAL: Failed to create chunks for refresh token")
return
}
if len(chunks) > 50 {
sd.manager.logger.Errorf("CRITICAL: Too many chunks (%d) for refresh token - possible corruption", len(chunks))
return
}
testReassembled := strings.Join(chunks, "")
if testReassembled != compressed {
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk reassembly test failed")
return
}
for i, chunkData := range chunks {
sessionName := fmt.Sprintf("%s_%d", sd.manager.refreshTokenCookieName(), i)
if sd.request == nil {
sd.manager.logger.Errorf("CRITICAL: SetRefreshToken: sd.request is nil, cannot create chunk session %s", sessionName)
return
}
if chunkData == "" {
sd.manager.logger.Errorf("CRITICAL: Empty refresh token chunk data at index %d", i)
return
}
if len(chunkData) > maxCookieSize {
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize)
return
}
if !validateChunkSize(chunkData) {
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData))
return
}
session, err := sd.manager.store.Get(sd.request, sessionName)
if err != nil {
sd.manager.logger.Errorf("CRITICAL: Failed to get refresh token chunk session %s: %v", sessionName, err)
return
}
session.Values["token_chunk"] = chunkData
session.Values["compressed"] = (compressed != token)
session.Values["chunk_created_at"] = time.Now().Unix()
session.Values["token_total"] = len(chunks)
sd.refreshTokenChunks[i] = session
}
sd.manager.logger.Debugf("SUCCESS: Stored refresh token in %d chunks", len(chunks))
return
}
// Chunked layout: validate and write every new chunk BEFORE touching the
// previous token, so an abort (request missing, store error, size check,
// reassembly) leaves the still-valid prior refresh token intact (R154;
// extends the R95 access-token fix to the refresh token).
chunks := splitIntoChunks(compressed, maxCookieSize)
if len(chunks) == 0 {
sd.manager.logger.Errorf("CRITICAL: Failed to create chunks for refresh token")
return
}
if len(chunks) > 50 {
sd.manager.logger.Errorf("CRITICAL: Too many chunks (%d) for refresh token - possible corruption", len(chunks))
return
}
testReassembled := strings.Join(chunks, "")
// R188 cross-chunk integrity: bind all chunks to one another so a mix
// of authentic-but-different chunk sets is caught at assembly (see
// processChunkedToken). Stored on every chunk.
chunkIntegrity := fmt.Sprintf("%x", sha256.Sum256([]byte(testReassembled)))
if testReassembled != compressed {
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk reassembly test failed")
return
}
newChunks := make(map[int]*sessions.Session, len(chunks))
for i, chunkData := range chunks {
sessionName := fmt.Sprintf("%s_%d", sd.manager.refreshTokenCookieName(), i)
if sd.request == nil {
sd.manager.logger.Errorf("CRITICAL: SetRefreshToken: sd.request is nil, cannot create chunk session %s", sessionName)
return
}
if chunkData == "" {
sd.manager.logger.Errorf("CRITICAL: Empty refresh token chunk data at index %d", i)
return
}
if len(chunkData) > maxCookieSize {
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize)
return
}
if !validateChunkSize(chunkData) {
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData))
return
}
session, err := sd.manager.store.Get(sd.request, sessionName)
if err != nil {
sd.manager.logger.Errorf("CRITICAL: Failed to get refresh token chunk session %s: %v", sessionName, err)
return
}
session.Values["token_chunk"] = chunkData
session.Values["compressed"] = (compressed != token)
session.Values["chunk_created_at"] = time.Now().Unix()
session.Values["token_total"] = len(chunks)
session.Values["chunk_integrity"] = chunkIntegrity
newChunks[i] = session
}
// Every new chunk committed: now it is safe to discard the old token.
if sd.request != nil {
sd.expireRefreshTokenChunksEnhanced(nil)
}
for k := range sd.refreshTokenChunks {
delete(sd.refreshTokenChunks, k)
}
sd.refreshSession.Values["token"] = ""
sd.refreshSession.Values["compressed"] = (compressed != token)
sd.refreshSession.Values["issued_at"] = time.Now().Unix()
for i, s := range newChunks {
sd.refreshTokenChunks[i] = s
}
sd.manager.logger.Debugf("SUCCESS: Stored refresh token in %d chunks", len(chunks))
}
// GetRefreshTokenIssuedAt retrieves the timestamp when the refresh token was issued/stored.
@@ -2517,6 +2667,8 @@ func isCorruptionMarker(data string) bool {
// Returns:
// - The CSRF token string, or an empty string if not set.
func (sd *SessionData) GetCSRF() string {
sd.sessionMutex.RLock()
defer sd.sessionMutex.RUnlock()
csrf, _ := sd.mainSession.Values["csrf"].(string)
return csrf
}
@@ -2526,6 +2678,8 @@ func (sd *SessionData) GetCSRF() string {
// Parameters:
// - token: The CSRF token to store.
func (sd *SessionData) SetCSRF(token string) {
sd.sessionMutex.Lock()
defer sd.sessionMutex.Unlock()
currentVal, _ := sd.mainSession.Values["csrf"].(string)
if currentVal != token {
sd.mainSession.Values["csrf"] = token
@@ -2539,6 +2693,8 @@ func (sd *SessionData) SetCSRF(token string) {
// Returns:
// - The nonce string, or an empty string if not set.
func (sd *SessionData) GetNonce() string {
sd.sessionMutex.RLock()
defer sd.sessionMutex.RUnlock()
nonce, _ := sd.mainSession.Values["nonce"].(string)
return nonce
}
@@ -2548,6 +2704,8 @@ func (sd *SessionData) GetNonce() string {
// Parameters:
// - nonce: The nonce string to store.
func (sd *SessionData) SetNonce(nonce string) {
sd.sessionMutex.Lock()
defer sd.sessionMutex.Unlock()
currentVal, _ := sd.mainSession.Values["nonce"].(string)
if currentVal != nonce {
sd.mainSession.Values["nonce"] = nonce
@@ -2561,6 +2719,8 @@ func (sd *SessionData) SetNonce(nonce string) {
// Returns:
// - The code verifier string, or an empty string if not set or PKCE is disabled.
func (sd *SessionData) GetCodeVerifier() string {
sd.sessionMutex.RLock()
defer sd.sessionMutex.RUnlock()
codeVerifier, _ := sd.mainSession.Values["code_verifier"].(string)
return codeVerifier
}
@@ -2571,6 +2731,8 @@ func (sd *SessionData) GetCodeVerifier() string {
// Parameters:
// - codeVerifier: The PKCE code verifier string to store.
func (sd *SessionData) SetCodeVerifier(codeVerifier string) {
sd.sessionMutex.Lock()
defer sd.sessionMutex.Unlock()
currentVal, _ := sd.mainSession.Values["code_verifier"].(string)
if currentVal != codeVerifier {
sd.mainSession.Values["code_verifier"] = codeVerifier
@@ -2612,6 +2774,8 @@ func (sd *SessionData) SetUserIdentifier(userIdentifier string) {
// Returns:
// - The original request URI string, or an empty string if not set.
func (sd *SessionData) GetIncomingPath() string {
sd.sessionMutex.RLock()
defer sd.sessionMutex.RUnlock()
path, _ := sd.mainSession.Values["incoming_path"].(string)
return path
}
@@ -2622,6 +2786,8 @@ func (sd *SessionData) GetIncomingPath() string {
// Parameters:
// - path: The original request URI string (e.g., "/protected/resource?id=123").
func (sd *SessionData) SetIncomingPath(path string) {
sd.sessionMutex.Lock()
defer sd.sessionMutex.Unlock()
currentVal, _ := sd.mainSession.Values["incoming_path"].(string)
if currentVal != path {
sd.mainSession.Values["incoming_path"] = path
@@ -2629,6 +2795,35 @@ func (sd *SessionData) SetIncomingPath(path string) {
}
}
// GetRedirectURL retrieves the fully-qualified redirect_uri persisted when the
// authentication flow was initiated. It is consumed at the callback (in place
// of a per-request rebuild) so a drift in X-Forwarded-Host between the
// initiate and callback requests cannot change the redirect_uri sent to the
// provider (R136).
func (sd *SessionData) GetRedirectURL() string {
sd.sessionMutex.RLock()
defer sd.sessionMutex.RUnlock()
ru, _ := sd.mainSession.Values["redirect_url"].(string)
return ru
}
// SetRedirectURL stores the redirect_uri used at initiate time (R136).
func (sd *SessionData) SetRedirectURL(redirectURL string) {
sd.sessionMutex.Lock()
defer sd.sessionMutex.Unlock()
currentVal, _ := sd.mainSession.Values["redirect_url"].(string)
if currentVal != redirectURL {
sd.mainSession.Values["redirect_url"] = redirectURL
sd.dirty = true
}
}
// getRedirectURLUnsafe retrieves the redirect URL without acquiring locks.
func (sd *SessionData) getRedirectURLUnsafe() string {
ru, _ := sd.mainSession.Values["redirect_url"].(string)
return ru
}
// GetIDToken retrieves the user's ID token from session storage.
// The ID token contains user claims and is used for user identification
// and authorization decisions. Handles compression and chunking automatically.
@@ -2804,14 +2999,6 @@ func (sd *SessionData) SetIDToken(token string) {
}
sd.dirty = true
if sd.request != nil {
sd.expireIDTokenChunksEnhanced(nil)
}
for k := range sd.idTokenChunks {
delete(sd.idTokenChunks, k)
}
if token == "" {
if sd.idTokenSession != nil {
sd.idTokenSession.Values["token"] = ""
@@ -2831,72 +3018,100 @@ func (sd *SessionData) SetIDToken(token string) {
}
if len(compressed) <= maxCookieSize {
// Single-cookie layout: nothing to validate, safe to swap.
if sd.request != nil {
sd.expireIDTokenChunksEnhanced(nil)
}
for k := range sd.idTokenChunks {
delete(sd.idTokenChunks, k)
}
if sd.idTokenSession != nil {
sd.idTokenSession.Values["token"] = compressed
sd.idTokenSession.Values["compressed"] = (compressed != token)
}
} else {
if sd.idTokenSession != nil {
sd.idTokenSession.Values["token"] = ""
sd.idTokenSession.Values["compressed"] = (compressed != token)
}
chunks := splitIntoChunks(compressed, maxCookieSize)
if len(chunks) == 0 {
sd.manager.logger.Errorf("CRITICAL: Failed to create chunks for ID token")
return
}
if len(chunks) > 50 {
sd.manager.logger.Errorf("CRITICAL: Too many chunks (%d) for ID token - possible corruption", len(chunks))
return
}
testReassembled := strings.Join(chunks, "")
if testReassembled != compressed {
sd.manager.logger.Errorf("CRITICAL: ID token chunk reassembly test failed")
return
}
for i, chunkData := range chunks {
sessionName := fmt.Sprintf("%s_%d", sd.manager.idTokenCookieName(), i)
if sd.request == nil {
sd.manager.logger.Errorf("CRITICAL: SetIDToken: sd.request is nil, cannot create chunk session %s", sessionName)
return
}
if chunkData == "" {
sd.manager.logger.Debug("Empty chunk data at index %d", i)
return
}
if len(chunkData) > maxCookieSize {
sd.manager.logger.Info("Chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize)
return
}
if !validateChunkSize(chunkData) {
sd.manager.logger.Errorf("CRITICAL: ID token chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData))
return
}
session, err := sd.manager.store.Get(sd.request, sessionName)
if err != nil {
sd.manager.logger.Errorf("CRITICAL: Failed to get chunk session %s: %v", sessionName, err)
return
}
session.Values["token_chunk"] = chunkData
session.Values["compressed"] = (compressed != token)
session.Values["chunk_created_at"] = time.Now().Unix()
session.Values["token_total"] = len(chunks)
sd.idTokenChunks[i] = session
}
sd.manager.logger.Debugf("SUCCESS: Stored ID token in %d chunks", len(chunks))
return
}
// Chunked layout: validate and write every new chunk BEFORE touching
// the previous token, so an abort (request missing, store error, size
// check) leaves the still-valid prior ID token intact (R154; extends
// the R95 access-token fix to the ID token).
chunks := splitIntoChunks(compressed, maxCookieSize)
if len(chunks) == 0 {
sd.manager.logger.Errorf("CRITICAL: Failed to create chunks for ID token")
return
}
if len(chunks) > 50 {
sd.manager.logger.Errorf("CRITICAL: Too many chunks (%d) for ID token - possible corruption", len(chunks))
return
}
testReassembled := strings.Join(chunks, "")
// R188 cross-chunk integrity: bind all chunks to one another so a mix
// of authentic-but-different chunk sets is caught at assembly (see
// processChunkedToken). Stored on every chunk.
chunkIntegrity := fmt.Sprintf("%x", sha256.Sum256([]byte(testReassembled)))
if testReassembled != compressed {
sd.manager.logger.Errorf("CRITICAL: ID token chunk reassembly test failed")
return
}
newChunks := make(map[int]*sessions.Session, len(chunks))
for i, chunkData := range chunks {
sessionName := fmt.Sprintf("%s_%d", sd.manager.idTokenCookieName(), i)
if sd.request == nil {
sd.manager.logger.Errorf("CRITICAL: SetIDToken: sd.request is nil, cannot create chunk session %s", sessionName)
return
}
if chunkData == "" {
sd.manager.logger.Debug("Empty chunk data at index %d", i)
return
}
if len(chunkData) > maxCookieSize {
sd.manager.logger.Info("Chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize)
return
}
if !validateChunkSize(chunkData) {
sd.manager.logger.Errorf("CRITICAL: ID token chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData))
return
}
session, err := sd.manager.store.Get(sd.request, sessionName)
if err != nil {
sd.manager.logger.Errorf("CRITICAL: Failed to get chunk session %s: %v", sessionName, err)
return
}
session.Values["token_chunk"] = chunkData
session.Values["compressed"] = (compressed != token)
session.Values["chunk_created_at"] = time.Now().Unix()
session.Values["token_total"] = len(chunks)
session.Values["chunk_integrity"] = chunkIntegrity
newChunks[i] = session
}
// Every new chunk committed: now it is safe to discard the old token.
if sd.request != nil {
sd.expireIDTokenChunksEnhanced(nil)
}
for k := range sd.idTokenChunks {
delete(sd.idTokenChunks, k)
}
if sd.idTokenSession != nil {
sd.idTokenSession.Values["token"] = ""
sd.idTokenSession.Values["compressed"] = (compressed != token)
}
for i, s := range newChunks {
sd.idTokenChunks[i] = s
}
sd.manager.logger.Debugf("SUCCESS: Stored ID token in %d chunks", len(chunks))
}
// GetRedirectCount returns the number of redirects in the current authentication flow.