git.iko.soy/typeable/oauth2-proxy
on master help sign in
fix: invalidate session on fatal OAuth2 refresh errors (#3333)

* fix: invalidate session on fatal OAuth2 refresh errors When a token refresh fails with a fatal OAuth2 error (invalid_grant, invalid_client), the session is now cleared from the session store and the cookie is removed, forcing re-authentication. Previously, fatal refresh errors were logged but the stale session continued to be served, leaving users logged in indefinitely after their session was revoked at the provider level. Transient errors (network timeouts, server errors) continue to preserve the existing session as before. Fixes #1945 Signed-off-by: Francesco Pasqualini <frapas@gmail.com> * fix: apply review nits and add CHANGELOG entry Signed-off-by: Francesco Pasqualini <frapas@gmail.com> Signed-off-by: Jan Larwig <jan@larwig.com> --------- Signed-off-by: Francesco Pasqualini <frapas@gmail.com> Signed-off-by: Jan Larwig <jan@larwig.com>

commit2e1261c4bec8f092a42df3ac57ae54ea2acf0142 authorFrancesco Pasqualini <frapas@gmail.com> dateSun 12 Apr 2026, 14:48 (6 months ago) parent26de082
3 files changed+99−4
CHANGELOG.md+3−2 pkg/middleware/stored_session.go+45−2 pkg/middleware/stored_session_test.go+51
CHANGELOG.md+3−2
@@ -8,9 +8,10 @@
88
99## Changes since v7.15.1
1010
11−# V7.15.1
12−
1311- [#3411](https://github.com/oauth2-proxy/oauth2-proxy/pull/3411) chore(deps): update gomod dependencies (@tuunit)
12+- [#3333](https://github.com/oauth2-proxy/oauth2-proxy/pull/3333) fix: invalidate session on fatal OAuth2 refresh errors (@frhack)
13+
14+# V7.15.1
1415
1516## Release Highlights
1617
pkg/middleware/stored_session.go+45−2
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "net/http"
8+ "strings"
89 "time"
910
1011 "github.com/justinas/alice"
@@ -31,6 +32,33 @@ const (
3132 sessionRefreshRetryPeriod = 10 * time.Millisecond
3233)
3334
35+// isFatalRefreshError checks if a refresh error indicates a revoked or
36+// non-existent session that should be immediately invalidated.
37+// Fatal errors indicate the session is no longer valid at the provider level.
38+// Non-fatal errors (network issues, timeouts) should not invalidate the session.
39+//
40+// Only checks standard OAuth2 error codes (RFC 6749 Section 5.2).
41+// Does NOT check error_description strings as they are optional and provider-specific.
42+func isFatalRefreshError(err error) bool {
43+ if err == nil {
44+ return false
45+ }
46+
47+ // Only check standard OAuth2 error codes (RFC 6749 Section 5.2)
48+ // Do NOT check error_description strings as they are optional and provider-specific
49+ fatalErrors := []string{
50+ "invalid_grant", // refresh token revoked, expired, or session terminated
51+ "invalid_client", // client credentials no longer valid
52+ }
53+
54+ for _, fe := range fatalErrors {
55+ if strings.Contains(err.Error(), fe) {
56+ return true
57+ }
58+ }
59+ return false
60+}
61+
3462// StoredSessionLoaderOptions contains all of the requirements to construct
3563// a stored session loader.
3664// All options must be provided.
@@ -188,9 +216,24 @@ func (s *storedSessionLoader) refreshSessionIfNeeded(rw http.ResponseWriter, req
188216 // We are holding the lock and the session needs a refresh
189217 logger.Printf("Refreshing session - User: %s; SessionAge: %s", session.User, session.Age())
190218 if err := s.refreshSession(rw, req, session); err != nil {
191− // If a preemptive refresh fails, we still keep the session
192− // if validateSession succeeds.
193219 logger.Errorf("Unable to refresh session: %v", err)
220+
221+ // Check if this is a fatal error that indicates the session is revoked
222+ // or no longer valid at the provider level
223+ if isFatalRefreshError(err) {
224+ logger.Printf("Fatal refresh error detected (session revoked or invalid), clearing session for user: %s", session.User)
225+
226+ // Clear the session from storage (Redis) and remove the cookie
227+ if err := s.store.Clear(rw, req); err != nil {
228+ logger.Errorf("failed clearing session: %v", err)
229+ }
230+
231+ // Return error immediately to force re-authentication
232+ return fmt.Errorf("session invalidated due to fatal refresh error: %w", err)
233+ }
234+
235+ // For non-fatal errors (network issues, timeouts), keep the session
236+ // and let validateSession determine if it's still usable
194237 }
195238
196239 // Validate all sessions after any Redeem/Refresh operation (fail or success)
pkg/middleware/stored_session_test.go+51
@@ -7,6 +7,7 @@ import (
77 "net/http"
88 "net/http/httptest"
99 "sync"
10+ "testing"
1011 "time"
1112
1213 middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
@@ -801,3 +802,53 @@ func (f *fakeSessionStore) Clear(rw http.ResponseWriter, req *http.Request) erro
801802func (f *fakeSessionStore) VerifyConnection(_ context.Context) error {
802803 return nil
803804}
805+
806+// TestIsFatalRefreshError tests the isFatalRefreshError function to ensure
807+// it correctly identifies fatal OAuth2 errors that should invalidate a session.
808+func TestIsFatalRefreshError(t *testing.T) {
809+ tests := []struct {
810+ name string
811+ err error
812+ expected bool
813+ }{
814+ {
815+ name: "nil error",
816+ err: nil,
817+ expected: false,
818+ },
819+ {
820+ name: "invalid_grant error",
821+ err: fmt.Errorf("failed to get token: oauth2: \"invalid_grant\" \"Session not active\""),
822+ expected: true,
823+ },
824+ {
825+ name: "invalid_client error",
826+ err: fmt.Errorf("invalid_client: client not found"),
827+ expected: true,
828+ },
829+ {
830+ name: "network timeout - not fatal",
831+ err: fmt.Errorf("Post \"https://keycloak/token\": dial tcp: connect: connection refused"),
832+ expected: false,
833+ },
834+ {
835+ name: "server error - not fatal",
836+ err: fmt.Errorf("unexpected status code 500"),
837+ expected: false,
838+ },
839+ {
840+ name: "generic refresh error - not fatal",
841+ err: fmt.Errorf("error refreshing tokens: context deadline exceeded"),
842+ expected: false,
843+ },
844+ }
845+
846+ for _, tt := range tests {
847+ t.Run(tt.name, func(t *testing.T) {
848+ result := isFatalRefreshError(tt.err)
849+ if result != tt.expected {
850+ t.Errorf("isFatalRefreshError(%v) = %v, want %v", tt.err, result, tt.expected)
851+ }
852+ })
853+ }
854+}