mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-17 04:37:48 +00:00
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:
+394
-179
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user