mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
feat(middleware): refresh expired sessions on SSE/WebSocket bypass
The SSE and WebSocket bypass does not check the token expiry. A session stays usable for streaming until the session cookie expires, also after the IdP disables the user. The bypass now forwards the configured headers (#162), so this gap also gives the proxy secret to such a session. Add the streamingRefresh option. The default is false. When it is true and the access token has expired, the bypass refreshes the session. It uses the ID token expiry if the access token is opaque. If the IdP rejects the refresh with invalid_grant, the request gets 401. If the IdP cannot be reached, the request is forwarded as before. The bypass waits for the full coordinated refresh. A shorter wait can drop a rotated refresh token, and the next refresh then logs the user out. applyBypassUserHeaders now takes the ResponseWriter, so a refresh can write the new session cookie. Add a yaegi check for the invalid_grant path through the real token endpoint and RefreshCoordinator. Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
This commit is contained in:
+32
-2
@@ -273,7 +273,7 @@ func stripIdentityHeaders(req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (bool, int) {
|
||||
func (t *TraefikOidc) applyBypassUserHeaders(rw http.ResponseWriter, req *http.Request, reason string) (bool, int) {
|
||||
if t.sessionManager == nil {
|
||||
return false, http.StatusUnauthorized
|
||||
}
|
||||
@@ -296,6 +296,17 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
|
||||
return false, http.StatusUnauthorized
|
||||
}
|
||||
|
||||
// Opt-in: refresh an expired session so an IdP-revoked grant stops opening
|
||||
// streams. Only a definitive rejection (refreshToken clears the session on
|
||||
// invalid_grant) denies; IdP outages keep forwarding as before. The full
|
||||
// coordinator wait is kept so a rotated refresh token is never dropped.
|
||||
if t.streamingRefresh && bypassTokenExpired(session) {
|
||||
if !t.refreshToken(rw, req, session) && !session.GetAuthenticated() {
|
||||
t.logger.Infof("User %s refresh rejected by IdP (bypass)", userIdentifier)
|
||||
return false, http.StatusUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the normal path's IdP-initiated logout check: this path forwards
|
||||
// configured headers (often a shared proxy secret), so a logged-out cookie
|
||||
// must not keep opening streams until the absolute session timeout.
|
||||
@@ -402,6 +413,25 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// bypassTokenExpired reports whether the session's access token, or its ID
|
||||
// token when the access token carries no readable exp (opaque), has expired.
|
||||
// exp is read unverified: the cookie is sealed and a verified refresh follows.
|
||||
func bypassTokenExpired(session *SessionData) bool {
|
||||
for _, token := range []string{session.GetAccessToken(), session.GetIDToken()} {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
jwt, err := parseJWT(token)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if exp, ok := jwt.Claims["exp"].(float64); ok {
|
||||
return !time.Now().Before(time.Unix(int64(exp), 0))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bypassSessionClaims returns the session's ID-token claims, falling back to
|
||||
// access-token claims when there is no ID token, matching the source the
|
||||
// normal authorization path uses (R118).
|
||||
@@ -640,7 +670,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
// Otherwise an unauthenticated client could hit the backend
|
||||
// just by setting Accept: text/event-stream or sending a
|
||||
// WebSocket upgrade.
|
||||
if ok, status := t.applyBypassUserHeaders(req, reason); !ok {
|
||||
if ok, status := t.applyBypassUserHeaders(rw, req, reason); !ok {
|
||||
msg := "Authentication required"
|
||||
if status == http.StatusForbidden {
|
||||
msg = "Access denied"
|
||||
|
||||
Reference in New Issue
Block a user