diff --git a/README.md b/README.md index 41df877..e6f8012 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,24 @@ An empty admin list allows every authenticated user through the HTTP admin gates UserInfo fallback requires a matching non-empty `sub`; its verification flag is used only when that response supplies the email or confirms the exact same email claim, including case and whitespace. + +Successful login rotates the JaWS session and discards pre-login application data. +UserInfo is fetched before rotation so its latency does not consume the new +session's idle lifetime. +Rotation requires a free slot under both session limits until the new cookie is +published. In particular, `MaxSessionsPerIP = 1` prevents login; allow room for +rotation when setting limits. If no slot is available, the default response is 503 +and the existing session's authentication and refresh timer are preserved. +Restart login after capacity becomes available. + +Logout and auth expiry cancel the session's live JaWS requests. Protected renders +also recheck authorization when the HTTP handler returns, cancelling requests +attached to that session during a concurrent revocation. Already-running handlers +can finish; applications needing authorization at each event must recheck current +claims, expiry, and `JawsAuth.IsAdmin` before acting. Changing `SetAdmins` also cancels +every page of sessions that lose admin access; their ordinary login remains valid. +Clients reconnect and reload. Use an HTTP endpoint for logout redirects: calling +`Request.Redirect` after `Server.Logout` in a JaWS event cannot send the redirect +because that request has been cancelled. Closed or expired JaWS sessions have +their tokens and timers discarded at the next scheduled refresh, which calls +`LogoutEvent` with a nil HTTP request. diff --git a/adminpolicy_test.go b/adminpolicy_test.go index b4e4cad..12cd41e 100644 --- a/adminpolicy_test.go +++ b/adminpolicy_test.go @@ -36,7 +36,7 @@ func TestAdminEmailPolicy(t *testing.T) { srv.SetAdmins([]string{"admin@example.com", "kate@example.com", "Åsa@example.com", `"\"quoted\""@example.com`}) req := httptest.NewRequest(http.MethodGet, "/", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - if err = srv.storeSessionAuthClaims(t.Context(), sess, tc.claims, nil, time.Now().Add(time.Hour), nil); err != nil { + if err = srv.storeSessionAuthClaims(sess, tc.claims, nil, time.Now().Add(time.Hour), nil); err != nil { t.Fatal(err) } auth := &JawsAuth{server: srv, sess: sess} @@ -126,7 +126,7 @@ func TestAdminEmailPolicyConcurrentUpdates(t *testing.T) { verified bool }{{"user@example.com", true}, {"admin@example.com", false}} { claims := map[string]any{"sub": "user", "email": state.email, "email_verified": state.verified} - if err := srv.storeSessionAuthClaims(t.Context(), sess, claims, nil, expiry, nil); err != nil { + if err := srv.storeSessionAuthClaims(sess, claims, nil, expiry, nil); err != nil { t.Error(err) return } diff --git a/authsession.go b/authsession.go index ebd03c8..99ba1a6 100644 --- a/authsession.go +++ b/authsession.go @@ -95,39 +95,52 @@ func (srv *Server) sessionAuthStatus(sess *jaws.Session, now func() time.Time) ( return } -func (srv *Server) storeSessionAuthClaims(ctx context.Context, sess *jaws.Session, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, entry *authTimerState) (err error) { - err = ErrOAuth2NotConfigured - if srv != nil { - err = ErrOAuth2MissingSession - if sess != nil { - err = errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCInvalidExpiry} - if !expiry.IsZero() { - if fallback, e := srv.fetchUserInfo(ctx, srv.userinfoUrl, tokenSource); srv.Jaws.Log(e) == nil { - mergeUserInfoClaims(claims, fallback) - } - if entry != nil { - if !srv.sessionAuthTimerCurrent(sess, entry) { - err = errAuthTimerStale - return - } - } - email, verified := srv.extractEmail(claims) - claims["email_verified"] = verified - // Publish the email and its verification together for admin checks. - srv.mu.Lock() - sess.Set(srv.SessionKey, claims) - sess.Set(srv.SessionTokenKey, tokenSource) - sess.Set(oauth2IDTokenExpiryKey, expiry) - sess.Set(srv.SessionEmailKey, email) - sess.Set(srv.SessionEmailVerifiedKey, verified) - srv.mu.Unlock() - srv.Jaws.Dirty(sess) - srv.scheduleSessionAuthTimer(sess, expiry) - err = nil - } +func sessionAlive(sess *jaws.Session) bool { + cookie := sess.Cookie() + return cookie != nil && cookie.MaxAge >= 0 +} + +func (srv *Server) storeSessionAuthClaims(sess *jaws.Session, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, entry *authTimerState) error { + if srv == nil { + return ErrOAuth2NotConfigured + } + if sess == nil { + return ErrOAuth2MissingSession + } + if expiry.IsZero() { + return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCInvalidExpiry} + } + email, verified := srv.extractEmail(claims) // Logger callbacks must run outside srv.mu. + claims["email_verified"] = verified + srv.mu.Lock() + if entry != nil && srv.authTimers[sess.ID()] != entry { + srv.mu.Unlock() + return errAuthTimerStale + } + if !sessionAlive(sess) { + srv.mu.Unlock() + if entry == nil { + return ErrOAuth2MissingSession } + srv.clearSessionAuth(sess, nil, entry) + return errAuthTimerStale } - return + sess.Set(srv.SessionKey, claims) + sess.Set(srv.SessionTokenKey, tokenSource) + sess.Set(oauth2IDTokenExpiryKey, expiry) + sess.Set(srv.SessionEmailKey, email) + sess.Set(srv.SessionEmailVerifiedKey, verified) + delay, replaced := srv.scheduleSessionAuthTimerLocked(sess, expiry) + srv.mu.Unlock() + srv.Jaws.Dirty(sess) + srv.debugLog("jawsauth: scheduled auth refresh timer", + "session_prefix", debugSessionPrefix(sess), + "expiry", expiry, + "delay", delay, + "refresh_skew", authRefreshSkew, + "replaced_existing", replaced, + ) + return nil } func (srv *Server) setSessionAuthFromToken(ctx context.Context, sess *jaws.Session, tokenSource oauth2.TokenSource, token *oauth2.Token, minExpiry time.Time, entry *authTimerState) (err error) { @@ -146,7 +159,8 @@ func (srv *Server) setSessionAuthFromToken(ctx context.Context, sess *jaws.Sessi } else if !minExpiry.IsZero() && !idToken.Expiry.After(minExpiry) { err = errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCStaleIDToken} } else { - err = srv.storeSessionAuthClaims(ctx, sess, claims, tokenSource, idToken.Expiry, entry) + srv.addUserInfoClaims(ctx, claims, tokenSource) + err = srv.storeSessionAuthClaims(sess, claims, tokenSource, idToken.Expiry, entry) } } } @@ -215,35 +229,25 @@ func (srv *Server) refreshSessionAuth(ctx context.Context, sess *jaws.Session, m return } -func (srv *Server) scheduleSessionAuthTimer(sess *jaws.Session, expiry time.Time) { - if srv != nil && sess != nil && !expiry.IsZero() { - delay := max(time.Until(expiry.Add(-authRefreshSkew)), 0) - entry := &authTimerState{expiry: expiry} - srv.mu.Lock() - if srv.authTimers == nil { - srv.authTimers = make(map[uint64]*authTimerState) - } - if srv.authTimerAfterFunc == nil { - srv.authTimerAfterFunc = realAuthTimerAfterFunc - } - replaced := false - if old := srv.authTimers[sess.ID()]; old != nil && old.timer != nil { - replaced = true - old.timer.Stop() - } - srv.authTimers[sess.ID()] = entry - entry.timer = srv.authTimerAfterFunc(delay, func() { - srv.handleSessionAuthTimer(sess, entry) - }) - srv.mu.Unlock() - srv.debugLog("jawsauth: scheduled auth refresh timer", - "session_prefix", debugSessionPrefix(sess), - "expiry", expiry, - "delay", delay, - "refresh_skew", authRefreshSkew, - "replaced_existing", replaced, - ) +// Caller holds srv.mu across the auth writes and timer replacement. +func (srv *Server) scheduleSessionAuthTimerLocked(sess *jaws.Session, expiry time.Time) (delay time.Duration, replaced bool) { + delay = max(time.Until(expiry.Add(-authRefreshSkew)), 0) + entry := &authTimerState{expiry: expiry} + if srv.authTimers == nil { + srv.authTimers = make(map[uint64]*authTimerState) + } + if srv.authTimerAfterFunc == nil { + srv.authTimerAfterFunc = realAuthTimerAfterFunc } + if old := srv.authTimers[sess.ID()]; old != nil && old.timer != nil { + replaced = true + old.timer.Stop() + } + srv.authTimers[sess.ID()] = entry + entry.timer = srv.authTimerAfterFunc(delay, func() { + srv.handleSessionAuthTimer(sess, entry) + }) + return } func (srv *Server) sessionAuthTimerCurrent(sess *jaws.Session, entry *authTimerState) (current bool) { @@ -255,31 +259,24 @@ func (srv *Server) sessionAuthTimerCurrent(sess *jaws.Session, entry *authTimerS return } -func (srv *Server) stopSessionAuthTimer(sess *jaws.Session, entry *authTimerState) (stopped bool) { - if srv != nil && sess != nil { - srv.mu.Lock() - defer srv.mu.Unlock() - if entry == nil { - if old := srv.authTimers[sess.ID()]; old != nil { - delete(srv.authTimers, sess.ID()) - if old.timer != nil { - old.timer.Stop() - } - } - stopped = true - } else if srv.authTimers[sess.ID()] == entry { - delete(srv.authTimers, sess.ID()) - if entry.timer != nil { - entry.timer.Stop() - } - stopped = true - } +func (srv *Server) stopSessionAuthTimerLocked(sess *jaws.Session, entry *authTimerState) bool { + old := srv.authTimers[sess.ID()] + if entry != nil && old != entry { + return false } - return + delete(srv.authTimers, sess.ID()) + if old != nil && old.timer != nil { + old.timer.Stop() + } + return true } func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerState) { if srv.sessionAuthTimerCurrent(sess, entry) { + if !sessionAlive(sess) { + srv.clearSessionAuth(sess, nil, entry) + return + } current, present := srv.sessionAuthStatus(sess, time.Now) srv.debugLog("jawsauth: auth refresh timer fired", "session_prefix", debugSessionPrefix(sess), @@ -326,7 +323,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt "session_present", present, ) _ = srv.Jaws.Log(err) - srv.clearSessionAuth(sess, nil, true, true, entry) + srv.clearSessionAuth(sess, nil, entry) } else { srv.debugLog("jawsauth: auth refresh timer completed", "session_prefix", debugSessionPrefix(sess)) } @@ -345,46 +342,57 @@ func clearSessionOAuthFlow(sess *jaws.Session) { sess.Set(oauth2ReferrerKey, nil) } -// Logout clears all authentication state for the session and returns true if anything -// was cleared. +// Logout clears all authentication state for sess. // // It stops the auth-refresh timer, clears the OIDC claims, token source, email, expiry -// and any in-flight OAuth flow keys, calls LogoutEvent (if set), and marks the session -// dirty. It performs no HTTP redirect, so the caller can build its own post-logout -// response (for example an RP-initiated end-session redirect). It is safe to call with -// a nil receiver or nil session. +// and any in-flight OAuth flow keys, then cancels live JaWS requests, calls +// [Server.LogoutEvent] (if set), and marks the session dirty. It returns false for a +// nil receiver or nil session, and true otherwise. The hr argument may be nil. +// +// It performs no HTTP redirect. An HTTP handler can build its own post-logout +// response. A JaWS event handler cannot use [jaws.Request.Redirect] after Logout +// cancels its request; use an HTTP logout endpoint instead. func (srv *Server) Logout(sess *jaws.Session, hr *http.Request) (cleared bool) { - return srv.clearSessionAuth(sess, hr, true, false, nil) + return srv.clearSessionAuth(sess, hr, nil) } -func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, callLogout, reload bool, entry *authTimerState) (cleared bool) { - if srv != nil && sess != nil { - if srv.stopSessionAuthTimer(sess, entry) { - clearSessionOAuthFlow(sess) - srv.mu.Lock() - sess.Set(srv.SessionKey, nil) - sess.Set(srv.SessionTokenKey, nil) - sess.Set(oauth2IDTokenExpiryKey, nil) - sess.Set(srv.SessionEmailKey, nil) - sess.Set(srv.SessionEmailVerifiedKey, nil) - srv.mu.Unlock() - if callLogout && srv.LogoutEvent != nil { - srv.LogoutEvent(sess, hr) - } - srv.Jaws.Dirty(sess) - if reload { - sess.Reload() - } - cleared = true - srv.debugLog("jawsauth: cleared session auth", - "session_prefix", debugSessionPrefix(sess), - "request_present", hr != nil, - "call_logout", callLogout, - "reload", reload, - "timer_entry", entry != nil, - "entry_expiry", authTimerEntryExpiry(entry), - ) +func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry *authTimerState) (cleared bool) { + if srv == nil || sess == nil { + return + } + srv.mu.Lock() + cleared = srv.stopSessionAuthTimerLocked(sess, entry) + var requests []*jaws.Request + if cleared { + clearSessionOAuthFlow(sess) + sess.Set(srv.SessionKey, nil) + sess.Set(srv.SessionTokenKey, nil) + sess.Set(oauth2IDTokenExpiryKey, nil) + sess.Set(srv.SessionEmailKey, nil) + sess.Set(srv.SessionEmailVerifiedKey, nil) + requests = sess.Requests() + } + srv.mu.Unlock() + if cleared { + cancelAuthRequests(requests) + if srv.LogoutEvent != nil { + srv.LogoutEvent(sess, hr) } + srv.Jaws.Dirty(sess) + srv.debugLog("jawsauth: cleared session auth", + "session_prefix", debugSessionPrefix(sess), + "request_present", hr != nil, + "timer_entry", entry != nil, + "entry_expiry", authTimerEntryExpiry(entry), + ) } return } + +func cancelAuthRequests(requests []*jaws.Request) { + // ponytail: JaWS v0.805.0 never reuses Request identities; replace this snapshot + // cancellation with a Session-level API when JaWS provides one. + for _, rq := range requests { + rq.Cancel(nil) + } +} diff --git a/authsession_test.go b/authsession_test.go index 63164ff..a4a117a 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -255,7 +255,7 @@ func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { expiry := time.Now().Add(time.Hour).Truncate(time.Second) tokenSource := oauth2.StaticTokenSource(makeOAuth2Token("access", "", "")) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "User@Example.COM", "email_verified": "true", @@ -284,7 +284,9 @@ func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { t.Fatal("timer was not current") } - srv.scheduleSessionAuthTimer(sess, time.Now().Add(time.Second)) + srv.mu.Lock() + srv.scheduleSessionAuthTimerLocked(sess, time.Now().Add(time.Second)) + srv.mu.Unlock() if factory.len() != 2 { t.Fatal(factory.len()) } @@ -301,6 +303,7 @@ func TestStoreSessionAuthClaimsErrors(t *testing.T) { if err != nil { t.Fatal(err) } + go jw.Serve() defer jw.Close() factory := &testAuthTimerFactory{} @@ -308,26 +311,35 @@ func TestStoreSessionAuthClaimsErrors(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - err = (*Server)(nil).storeSessionAuthClaims(t.Context(), sess, map[string]any{}, nil, time.Now().Add(time.Hour), nil) + err = (*Server)(nil).storeSessionAuthClaims(sess, map[string]any{}, nil, time.Now().Add(time.Hour), nil) if !errors.Is(err, ErrOAuth2NotConfigured) { t.Fatal(err) } - err = srv.storeSessionAuthClaims(t.Context(), nil, map[string]any{}, nil, time.Now().Add(time.Hour), nil) + err = srv.storeSessionAuthClaims(nil, map[string]any{}, nil, time.Now().Add(time.Hour), nil) if !errors.Is(err, ErrOAuth2MissingSession) { t.Fatal(err) } - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{}, nil, time.Time{}, nil) + err = srv.storeSessionAuthClaims(sess, map[string]any{}, nil, time.Time{}, nil) if !errors.Is(err, ErrOIDCInvalidIDToken) { t.Fatal(err) } entry := &authTimerState{} - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{}, nil, time.Now().Add(time.Hour), entry) + err = srv.storeSessionAuthClaims(sess, map[string]any{}, nil, time.Now().Add(time.Hour), entry) if !errors.Is(err, errAuthTimerStale) { t.Fatal(err) } + + srv.LogoutEvent = func(*jaws.Session, *http.Request) { + t.Error("unauthenticated session emitted a logout event") + } + sess.Close() + err = srv.storeSessionAuthClaims(sess, map[string]any{}, nil, time.Now().Add(time.Hour), nil) + if !errors.Is(err, ErrOAuth2MissingSession) { + t.Fatal(err) + } } func TestSetSessionAuthFromTokenErrors(t *testing.T) { @@ -444,7 +456,7 @@ func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) tokenSource := oauth2.StaticTokenSource(makeOAuth2Token("cached-access", cachedIDToken, "refresh123")) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": initialExpiry.Unix(), "email": "cached@example.com", "email_verified": false, @@ -658,7 +670,7 @@ func TestAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(time.Minute).Truncate(time.Second) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "current@example.com", "email_verified": true, @@ -768,7 +780,7 @@ func TestAuthTimerRefreshFailureClearsExpiredAuth(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(-time.Second).Truncate(time.Second) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "old@example.com", "email_verified": true, @@ -820,7 +832,7 @@ func TestAuthTimerDebugLogsRefreshFailure(t *testing.T) { sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(30 * time.Second).Truncate(time.Second) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "old@example.com", "email_verified": true, @@ -864,7 +876,7 @@ func TestAuthTimerDebugLogsRequireJawsDebug(t *testing.T) { sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(30 * time.Second).Truncate(time.Second) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "old@example.com", "email_verified": true, @@ -900,7 +912,7 @@ func TestAuthTimerStaleCallbackNoOp(t *testing.T) { sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(time.Minute).Truncate(time.Second) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "first@example.com", }, tokenSourceFunc(func() (*oauth2.Token, error) { @@ -911,7 +923,7 @@ func TestAuthTimerStaleCallbackNoOp(t *testing.T) { t.Fatal(err) } firstTimer := factory.timer(0) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": time.Now().Add(time.Hour).Unix(), "email": "second@example.com", }, oauth2.StaticTokenSource(makeOAuth2Token("access", "", "")), time.Now().Add(time.Hour), nil) @@ -956,10 +968,12 @@ func TestAuthTimerStaleAfterRefreshNoOp(t *testing.T) { "email": "refreshed@example.com", }) tokenSource := tokenSourceFunc(func() (*oauth2.Token, error) { - srv.scheduleSessionAuthTimer(sess, newExpiry) + srv.mu.Lock() + srv.scheduleSessionAuthTimerLocked(sess, newExpiry) + srv.mu.Unlock() return makeOAuth2Token("access", rawIDToken, ""), nil }) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": oldExpiry.Unix(), "email": "old@example.com", }, tokenSource, oldExpiry, nil) @@ -996,7 +1010,7 @@ func TestExplicitLogoutStopsAuthTimer(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/logout", nil) rec := httptest.NewRecorder() sess := jw.NewSession(rec, req) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": time.Now().Add(time.Hour).Unix(), "email": "user@example.com", }, oauth2.StaticTokenSource(makeOAuth2Token("access", "", "")), time.Now().Add(time.Hour), nil) @@ -1076,7 +1090,7 @@ func TestServerLogout(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "http://example.com/logout", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{ + err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": time.Now().Add(time.Hour).Unix(), "email": "user@example.com", }, oauth2.StaticTokenSource(makeOAuth2Token("access", "", "")), time.Now().Add(time.Hour), nil) @@ -1127,7 +1141,7 @@ func TestAuthDebugSessionPrefix(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(time.Hour) - if err = srv.storeSessionAuthClaims(t.Context(), sess, map[string]any{"email": "user@example.com"}, tokenSourceFunc(func() (*oauth2.Token, error) { + if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, tokenSourceFunc(func() (*oauth2.Token, error) { return nil, errAuthSessionTestToken }), expiry, nil); err != nil { t.Fatal(err) diff --git a/oauth2.go b/oauth2.go index c350910..18d8ecf 100644 --- a/oauth2.go +++ b/oauth2.go @@ -126,7 +126,7 @@ func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { if hr.Method == http.MethodGet { _, location := srv.begin(hr) if sess := srv.Jaws.GetSession(hr); sess != nil { - srv.clearSessionAuth(sess, hr, true, false, nil) + srv.clearSessionAuth(sess, hr, nil) } hw.Header().Set("Location", location) statusCode = http.StatusFound @@ -269,12 +269,22 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS return } +func (srv *Server) addUserInfoClaims(ctx context.Context, claims map[string]any, tokenSource oauth2.TokenSource) { + if fallback, err := srv.fetchUserInfo(ctx, srv.userinfoUrl, tokenSource); srv.Jaws.Log(err) == nil { + mergeUserInfoClaims(claims, fallback) + } +} + // HandleAuthResponse handles the OIDC redirect/callback endpoint. // // For GET requests it validates the state, exchanges the authorization code using the -// stored PKCE verifier, verifies the id_token and its nonce, stores the verified claims -// in the session, and invokes LoginEvent on success or LoginFailed on failure. Non-GET -// requests receive 405. +// stored PKCE verifier, verifies the id_token and its nonce, fetches UserInfo, and stores +// the claims in a new session. It invokes [Server.LoginEvent] on success or +// [Server.LoginFailed] on failure. Non-GET requests receive 405. +// +// Rotation discards pre-login application data and requires a free JaWS session slot. +// If no slot is available, existing authentication is preserved and the default +// response is 503. The user must restart login after capacity becomes available. func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) { statusCode := http.StatusMethodNotAllowed err := ErrOAuth2Callback @@ -331,7 +341,29 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) var claims map[string]any if err = idToken.Claims(&claims); wrapOIDC(ErrOIDCInvalidIDToken, &err) == nil { tokenSource := oauth2Config.TokenSource(srv.oauth2Context(context.Background()), token) - if err = srv.storeSessionAuthClaims(authctx, sess, claims, tokenSource, idToken.Expiry, nil); err == nil { + // Finish network work before starting the new session's idle lifetime. + srv.addUserInfoClaims(authctx, claims, tokenSource) + referrer := sess.Get(oauth2ReferrerKey) + requests := sess.Requests() + _, hadAuth := srv.sessionAuthStatus(sess, nil) + err = ErrOAuth2MissingSession + statusCode = http.StatusServiceUnavailable + if rotated := srv.Jaws.NewSession(hw, hr); rotated != nil { + srv.mu.Lock() + hadTimer := srv.authTimers[sess.ID()] != nil + srv.stopSessionAuthTimerLocked(sess, nil) + srv.mu.Unlock() + cancelAuthRequests(requests) + if hadAuth && hadTimer && srv.LogoutEvent != nil { + srv.LogoutEvent(sess, hr) + } + sess = rotated + sess.Set(oauth2ReferrerKey, referrer) + err = srv.storeSessionAuthClaims(sess, claims, tokenSource, idToken.Expiry, nil) + } else { + hw.Header().Set("Retry-After", "60") + } + if err == nil { sessValue = claims sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) if s, ok := sess.Get(oauth2ReferrerKey).(string); ok { diff --git a/oauth2_test.go b/oauth2_test.go index 4aa655b..61708cc 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -430,7 +430,15 @@ func Test_handleAuthResponseUsesPKCEVerifier(t *testing.T) { sess.Set(oauth2NonceKey, wantNonce) sess.Set(oauth2ReferrerKey, "/secure") + oldSession := sess srv.HandleAuthResponse(rec, req) + sess = jw.GetSession(req) + if sess == nil || sess == oldSession || oldSession.Cookie().MaxAge >= 0 { + t.Fatal("login did not rotate the session") + } + if oldSession.Get(srv.SessionKey) != nil { + t.Fatal("old session retained authentication") + } resp := rec.Result() if resp.StatusCode != http.StatusFound { @@ -551,7 +559,15 @@ func Test_handleAuthResponseStoredTokenSourceSurvivesRequestCancel(t *testing.T) sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) sess.Set(oauth2NonceKey, wantNonce) + oldSession := sess srv.HandleAuthResponse(rec, req) + sess = jw.GetSession(req) + if sess == nil || sess == oldSession || oldSession.Cookie().MaxAge >= 0 { + t.Fatal("login did not rotate the session") + } + if oldSession.Get(srv.SessionKey) != nil { + t.Fatal("old session retained authentication") + } if status := rec.Result().StatusCode; status != http.StatusFound { t.Fatal(status) diff --git a/server.go b/server.go index 693466c..196945c 100644 --- a/server.go +++ b/server.go @@ -61,8 +61,7 @@ type HandleFunc func(uri string, handler http.Handler) // EventFunc is called for login and logout lifecycle events. // -// For a LogoutEvent triggered by an auth-refresh timer rather than an HTTP request, -// hr may be nil. +// For [Server.LogoutEvent], hr may be nil. type EventFunc func(sess *jaws.Session, hr *http.Request) // FailedFunc is called when a login attempt fails. @@ -84,7 +83,7 @@ type Server struct { SessionEmailVerifiedKey string // default is "email_verified", value will be of type bool HandledPaths map[string]struct{} // URI paths we have registered handlers for LoginEvent EventFunc // if not nil, called after a successful login - LogoutEvent EventFunc // if not nil, called before logout; hr may be nil for timer-driven logout + LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil LoginFailed FailedFunc // if not nil, called on failed login Options []oauth2.AuthCodeOption // options to use, see https://pkg.go.dev/golang.org/x/oauth2#AuthCodeOption @@ -195,7 +194,12 @@ func (srv *Server) sessionIsAdmin(sess *jaws.Session) bool { } srv.mu.Lock() defer srv.mu.Unlock() - if len(srv.admins) == 0 { + return srv.sessionIsAdminLocked(sess, srv.admins) +} + +// Caller holds srv.mu. +func (srv *Server) sessionIsAdminLocked(sess *jaws.Session, admins map[string]struct{}) bool { + if len(admins) == 0 { return true } if sess == nil { @@ -204,7 +208,7 @@ func (srv *Server) sessionIsAdmin(sess *jaws.Session) bool { email, _ := sess.Get(srv.SessionEmailKey).(string) verified, _ := sess.Get(srv.SessionEmailVerifiedKey).(bool) // Session emails are already parsed; parsing again can change quoted local parts. - _, listed := srv.admins[foldEmailCase(email)] + _, listed := admins[foldEmailCase(email)] return listed && (!srv.RequireVerifiedAdminEmail || verified) } @@ -213,20 +217,33 @@ func (srv *Server) sessionIsAdmin(sess *jaws.Session) bool { // Addresses are parsed with [mail.ParseAddress] and ASCII letters are lowercased. // Non-ASCII characters are matched exactly. If empty, everyone is considered an // administrator. Session checks also apply [Server.RequireVerifiedAdminEmail]. +// +// Sessions losing admin access have all their live JaWS requests cancelled; +// they remain authenticated and can reopen non-admin pages. func (srv *Server) SetAdmins(emails []string) { - if srv != nil { - srv.mu.Lock() - defer srv.mu.Unlock() - if srv.admins == nil { - srv.admins = make(map[string]struct{}) + if srv == nil { + return + } + admins := make(map[string]struct{}) + for _, email := range emails { + if email = normalizeEmail(email); email != "" { + admins[email] = struct{}{} } - clear(srv.admins) - for _, s := range emails { - if s = normalizeEmail(s); s != "" { - srv.admins[s] = struct{}{} - } + } + var sessions []*jaws.Session + if srv.Jaws != nil { + sessions = srv.Jaws.Sessions() + } + var requests []*jaws.Request + srv.mu.Lock() + for _, sess := range sessions { + if sess.Get(srv.SessionKey) != nil && srv.sessionIsAdminLocked(sess, srv.admins) && !srv.sessionIsAdminLocked(sess, admins) { + requests = append(requests, sess.Requests()...) } } + srv.admins = admins + srv.mu.Unlock() + cancelAuthRequests(requests) } // GetAdmins returns a sorted list of the administrator emails. If empty, everyone is considered an administrator. diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go new file mode 100644 index 0000000..6699f38 --- /dev/null +++ b/sessionlifecycle_test.go @@ -0,0 +1,349 @@ +package jawsauth + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/linkdata/jaws" + "golang.org/x/oauth2" +) + +type logoutOnWarnLogger struct{ onWarn func() } + +func (l logoutOnWarnLogger) Info(string, ...any) {} +func (l logoutOnWarnLogger) Error(string, ...any) {} +func (l logoutOnWarnLogger) Warn(string, ...any) { l.onWarn() } + +func TestLogoutDuringClaimPreparation(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + factory := &testAuthTimerFactory{} + srv := newTimerTestServer(t, jw, "https://issuer.example", factory) + req := httptest.NewRequest(http.MethodGet, "/", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + expiry := time.Now().Add(time.Hour) + if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, nil, expiry, nil); err != nil { + t.Fatal(err) + } + entry := srv.authTimers[sess.ID()] + jw.Logger = logoutOnWarnLogger{onWarn: func() { srv.Logout(sess, req) }} + // Claim preparation may invoke application logging while logout completes. + err = srv.storeSessionAuthClaims(sess, map[string]any{"sub": "one"}, nil, expiry, entry) + if !errors.Is(err, errAuthTimerStale) { + t.Fatalf("got %v, want stale refresh", err) + } + assertWrapperAuthCleared(t, srv, sess) + if len(srv.authTimers) != 0 || factory.len() != 1 { + t.Fatal("logout left a refresh timer") + } +} + +func TestAuthTimerDiscardsClosedSession(t *testing.T) { + for _, duringRefresh := range []bool{false, true} { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + factory := &testAuthTimerFactory{} + srv := newTimerTestServer(t, jw, "https://issuer.example", factory) + req := httptest.NewRequest(http.MethodGet, "/", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + expiry := time.Now().Add(time.Hour) + raw := makeIDToken(t, map[string]any{"iss": "https://issuer.example", "aud": "client", "sub": "one", "exp": expiry.Add(time.Hour).Unix(), "email": "user@example.com"}) + calls := 0 + source := tokenSourceFunc(func() (*oauth2.Token, error) { + calls++ + sess.Close() + return makeOAuth2Token("access", raw, "refresh"), nil + }) + if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, source, expiry, nil); err != nil { + t.Fatal(err) + } + if !duringRefresh { + sess.Close() + } + factory.timer(0).fire() + wantCalls := 0 + if duringRefresh { + wantCalls = 1 + } + if calls != wantCalls || len(srv.authTimers) != 0 || factory.len() != 1 { + t.Fatalf("calls=%d timers=%d scheduled=%d", calls, len(srv.authTimers), factory.len()) + } + assertWrapperAuthCleared(t, srv, sess) + jw.Close() + } +} + +func TestAuthorizationWithdrawalCancelsRequests(t *testing.T) { + for _, action := range []string{"logout", "httpLogout", "expiry", "demotion", "restrictEveryone"} { + t.Run(action, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + go jw.Serve() + defer jw.Close() + factory := &testAuthTimerFactory{} + srv := newTimerTestServer(t, jw, "https://issuer.example", factory) + if action != "restrictEveryone" { + srv.SetAdmins([]string{"user@example.com"}) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + expiry := time.Now().Add(time.Hour) + if action == "expiry" { + expiry = time.Now().Add(-time.Second) + } + if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, nil, expiry, nil); err != nil { + t.Fatal(err) + } + rq := jw.NewRequest(httptest.NewRecorder(), req) + ctx := rq.Context() + if rq.Session() != sess || len(sess.Requests()) != 1 { + t.Fatal("request not attached") + } + switch action { + case "logout": + srv.Logout(sess, req) + case "httpLogout": + srv.HandleLogout(httptest.NewRecorder(), req) + case "expiry": + factory.timer(0).fire() + default: + srv.SetAdmins([]string{"other@example.com"}) + } + if !errors.Is(context.Cause(ctx), context.Canceled) { + t.Fatalf("request cancellation cause: %v", context.Cause(ctx)) + } + if action == "demotion" || action == "restrictEveryone" { + if current, _ := srv.sessionAuthStatus(sess, time.Now); !current { + t.Fatal("demotion should preserve ordinary login") + } + } + }) + } +} + +func TestLoginRotationExistingAuth(t *testing.T) { + for _, limit := range []int{1, 2} { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + jw.MaxSessions = limit + factory := &testAuthTimerFactory{} + srv := newTimerTestServer(t, jw, "https://issuer.example", factory) + expiry := time.Now().Add(time.Hour) + raw := makeIDToken(t, map[string]any{"iss": "https://issuer.example", "aud": "client", "sub": "one", "exp": expiry.Unix(), "nonce": "nonce", "email": "user@example.com"}) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"access_token":"access","token_type":"Bearer","id_token":"` + raw + `"}`))}, nil + })} + req := httptest.NewRequest(http.MethodGet, "/callback?state=state&code=code", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "existing@example.com"}, nil, expiry, nil); err != nil { + t.Fatal(err) + } + entry := srv.authTimers[sess.ID()] + logouts, logins := 0, 0 + srv.LogoutEvent = func(old *jaws.Session, hr *http.Request) { + logouts++ + if old != sess || hr != req { + t.Fatal("wrong retired session") + } + _ = srv.GetAdmins() // Callbacks must run outside srv.mu. + } + srv.LoginEvent = func(current *jaws.Session, _ *http.Request) { + logins++ + if current == sess { + t.Fatal("login reused old session") + } + } + sess.Set(oauth2StateKey, "state") + sess.Set(oauth2NonceKey, "nonce") + sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) + rec := httptest.NewRecorder() + srv.HandleAuthResponse(rec, req) + if limit == 1 { + if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Location") != "" { + t.Fatalf("status=%d headers=%v", rec.Code, rec.Header()) + } + if jw.GetSession(req) != sess || !srv.sessionAuthTimerCurrent(sess, entry) || sess.Get(srv.SessionEmailKey) != "existing@example.com" { + t.Fatal("failed rotation changed existing auth") + } + if logouts != 0 || logins != 0 { + t.Fatal("failed rotation emitted lifecycle events") + } + } else { + if rec.Code != http.StatusFound || logouts != 1 || logins != 1 { + t.Fatalf("status=%d logouts=%d logins=%d", rec.Code, logouts, logins) + } + if !factory.timer(0).isStopped() || sess.Cookie().MaxAge >= 0 { + t.Fatal("old session was not retired") + } + } + jw.Close() + } +} + +func TestSetAdminsLeavesAnonymousRequestsOpen(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newWrapperTestServer(jw, "https://issuer.example") + req := httptest.NewRequest(http.MethodGet, "/public", nil) + jw.NewSession(httptest.NewRecorder(), req) + rq := jw.NewRequest(httptest.NewRecorder(), req) + srv.SetAdmins([]string{"admin@example.com"}) + if rq.Context().Err() != nil { + t.Fatal("public request was cancelled") + } +} + +func TestWrapperRevocationDuringRender(t *testing.T) { + for _, admin := range []bool{false, true} { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + srv := newTimerTestServer(t, jw, "https://issuer.example", &testAuthTimerFactory{}) + srv.SetAdmins([]string{"user@example.com"}) + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, nil, time.Now().Add(time.Hour), nil); err != nil { + t.Fatal(err) + } + var ctx context.Context + h := http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + if admin { + srv.SetAdmins([]string{"other@example.com"}) + } else { + srv.Logout(sess, hr) + } + ctx = jw.NewRequest(hw, hr).Context() + }) + srv.wrap(h, admin).ServeHTTP(httptest.NewRecorder(), req) + if ctx == nil || ctx.Err() == nil { + t.Fatal("request attached after revocation was not cancelled") + } + jw.Close() + } +} + +func TestSetAdminsUsesSessionPolicy(t *testing.T) { + const quoted = `"\"quoted\""@example.com` + for _, tc := range []struct { + name, email string + strict, verified bool + before, after []string + wantAdmin bool + }{ + {name: "strictUnverified", email: "user@example.com", strict: true, after: []string{"user@example.com"}}, + {name: "strictVerified", email: "user@example.com", strict: true, verified: true, after: []string{"user@example.com"}, wantAdmin: true}, + {name: "defaultUnverified", email: "user@example.com", after: []string{"user@example.com"}, wantAdmin: true}, + {name: "quotedRemoved", email: quoted, before: []string{quoted}, after: []string{"other@example.com"}}, + {name: "quotedUnchanged", email: quoted, before: []string{quoted}, after: []string{quoted}, wantAdmin: true}, + } { + t.Run(tc.name, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + go jw.Serve() + defer jw.Close() + srv := newWrapperTestServer(jw, "https://issuer.example") + srv.RequireVerifiedAdminEmail = tc.strict + srv.SetAdmins(tc.before) + hr := httptest.NewRequest(http.MethodGet, "/", nil) + sess := jw.NewSession(httptest.NewRecorder(), hr) + if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": tc.email, "email_verified": tc.verified}, nil, time.Now().Add(time.Hour), nil); err != nil { + t.Fatal(err) + } + defer srv.Logout(sess, nil) + var ctx context.Context + srv.WrapAdmin(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + ctx = jw.NewRequest(hw, hr).Context() + })).ServeHTTP(httptest.NewRecorder(), hr) + if ctx == nil || ctx.Err() != nil { + t.Fatal("initial admin request not live") + } + srv.SetAdmins(tc.after) + if srv.sessionIsAdmin(sess) != tc.wantAdmin || errors.Is(ctx.Err(), context.Canceled) == tc.wantAdmin { + t.Fatalf("admin=%v cancellation=%v, want admin=%v", srv.sessionIsAdmin(sess), ctx.Err(), tc.wantAdmin) + } + if current, _ := srv.sessionAuthStatus(sess, time.Now); !current { + t.Fatal("admin policy change cleared ordinary login") + } + }) + } +} + +func TestLoginPreparesUserInfoBeforeRotation(t *testing.T) { + // UserInfo latency can exceed a new session's one-minute idle lifetime. + // synctest covers that interval without a wall-clock delay. + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + go jw.ServeWithTimeout(2 * time.Minute) + defer jw.Close() + srv := newWrapperTestServer(jw, "https://issuer.example") + expiry := time.Now().Add(time.Hour) + hr := httptest.NewRequest(http.MethodGet, "/callback?state=state&code=code", nil) + sess := jw.NewSession(httptest.NewRecorder(), hr) + if err = srv.storeSessionAuthClaims(sess, map[string]any{"sub": "user", "email": "user@example.com"}, nil, expiry, nil); err != nil { + t.Fatal(err) + } + defer srv.Logout(sess, nil) + var ctx context.Context + srv.Wrap(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + ctx = jw.NewRequest(hw, hr).Context() + })).ServeHTTP(httptest.NewRecorder(), hr) + raw := makeIDToken(t, map[string]any{"iss": "https://issuer.example", "aud": "client", "sub": "user", "exp": expiry.Unix(), "nonce": "nonce", "email": "user@example.com"}) + userinfoCalls := 0 + srv.userinfoUrl = "https://provider.example/userinfo" + srv.httpClient = &http.Client{Transport: roundTripFunc(func(hr *http.Request) (*http.Response, error) { + body := `{"access_token":"dummy","token_type":"Bearer","expires_in":3600,"id_token":"` + raw + `"}` + if hr.URL.Path == "/userinfo" { + userinfoCalls++ + time.Sleep(61 * time.Second) + body = `{"sub":"user","email":"user@example.com","email_verified":true}` + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + })} + logins, logouts := 0, 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } + srv.LogoutEvent = func(*jaws.Session, *http.Request) { logouts++ } + sess.Set(oauth2StateKey, "state") + sess.Set(oauth2NonceKey, "nonce") + sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) + hw := httptest.NewRecorder() + srv.HandleAuthResponse(hw, hr) + current := jw.GetSession(hr) + if current != nil { + defer srv.Logout(current, nil) + } + if hw.Code != http.StatusFound || logins != 1 || logouts != 1 || userinfoCalls != 1 { + t.Fatalf("status=%d logins=%d logouts=%d userinfo=%d", hw.Code, logins, logouts, userinfoCalls) + } + if current == nil || current == sess || sess.Cookie().MaxAge >= 0 || !errors.Is(ctx.Err(), context.Canceled) { + t.Fatal("login did not retire the old session and request") + } + if current.Get(srv.SessionEmailVerifiedKey) != true { + t.Fatal("login did not store prepared UserInfo claims") + } + }) +} diff --git a/wrapper.go b/wrapper.go index fcd90ed..aab9836 100644 --- a/wrapper.go +++ b/wrapper.go @@ -12,20 +12,23 @@ type wrapper struct { } func (w wrapper) ServeHTTP(hw http.ResponseWriter, hr *http.Request) { - h := w.handler sess := w.server.Jaws.GetSession(hr) if current, present := w.server.sessionAuthStatus(sess, time.Now); !current { if present { - w.server.clearSessionAuth(sess, hr, true, false, nil) + w.server.clearSessionAuth(sess, hr, nil) } w.server.HandleLogin(hw, hr) return } - if w.admin { - if !w.server.sessionIsAdmin(sess) { - h = w.server.get403Handler() - } + if w.admin && !w.server.sessionIsAdmin(sess) { + w.server.get403Handler().ServeHTTP(hw, hr) + return + } + w.handler.ServeHTTP(hw, hr) + // A revocation may have taken its request snapshot before rendering attached + // a new JaWS request. Retire that request before this response completes. + if current, _ := w.server.sessionAuthStatus(sess, time.Now); !current || (w.admin && !w.server.sessionIsAdmin(sess)) { + cancelAuthRequests(sess.Requests()) } - h.ServeHTTP(hw, hr) }