Files
member-console/internal/auth/logout_test.go
T
cgalo5758 ea4fee18b6 Fix five findings from security audit run 2
- Rotate the session token at the OIDC callback and restore the full
  lifetime; cap pre-auth sessions at 15 minutes and write no session
  for bare anonymous requests
- Treat db-dsn as a secret: accept db-dsn-file, log only host, port,
  database and user, and never echo a malformed DSN in an error
- Guard the logout callback with a state cookie so a forged visit
  cannot end a live session
- Collapse FedWiki site actions on a foreign tenant's domain to the
  not-found answer, as for a domain that does not exist
2026-09-09 20:53:31 -05:00

666 lines
26 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/viper"
"golang.org/x/oauth2"
)
// fakeTokenEndpoint stands in for the identity provider's token endpoint
// (at /token) and revocation endpoint (at /revoke). It answers a
// refresh_token grant with the ID token it was given and a rotated refresh
// token, records what the revocation endpoint received, and counts calls so
// a test can assert an endpoint was not consulted.
type fakeTokenEndpoint struct {
srv *httptest.Server
calls atomic.Int32
idToken string
fail bool // answer 400 invalid_grant
unavailable bool // answer 503 with no body: no usable answer
delay time.Duration // hold each token call this long
lastRefresh string
lastGrant string
revokeCalls atomic.Int32
revokeFail bool
revokedTokens []string
revokedHint string
revokeUser string
revokePassword string
}
func newFakeTokenEndpoint(t *testing.T, idToken string) *fakeTokenEndpoint {
t.Helper()
f := &fakeTokenEndpoint{idToken: idToken}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
f.calls.Add(1)
if err := r.ParseForm(); err != nil {
t.Errorf("token endpoint: parse form: %v", err)
}
f.lastGrant = r.PostForm.Get("grant_type")
f.lastRefresh = r.PostForm.Get("refresh_token")
if f.delay > 0 {
time.Sleep(f.delay)
}
if f.unavailable {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if f.fail {
// JSON, as RFC 6749 section 5.2 has it; the oauth2 client reads
// the error code from a JSON body only.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid_grant","error_description":"Session not active"}`))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "fresh-access",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "rotated-refresh",
"id_token": f.idToken,
})
})
mux.HandleFunc("/revoke", func(w http.ResponseWriter, r *http.Request) {
f.revokeCalls.Add(1)
if err := r.ParseForm(); err != nil {
t.Errorf("revocation endpoint: parse form: %v", err)
}
f.revokedTokens = append(f.revokedTokens, r.PostForm.Get("token"))
f.revokedHint = r.PostForm.Get("token_type_hint")
f.revokeUser, f.revokePassword, _ = r.BasicAuth()
if f.revokeFail {
http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
})
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
// logoutConfig builds a Config whose token endpoint is the fake, with the
// Viper keys LogoutHandler reads set for the test's lifetime.
func logoutConfig(t *testing.T, endpoint *fakeTokenEndpoint) *Config {
t.Helper()
viper.Set("oidc-idp-issuer-url", "http://idp.test/realms/main")
viper.Set("base-url", "http://console.test")
viper.Set("oidc-sp-client-id", "test-client")
t.Cleanup(viper.Reset)
cfg := newTestConfig()
cfg.OAuthConfig = &oauth2.Config{
ClientID: "test-client",
ClientSecret: "test-secret",
Endpoint: oauth2.Endpoint{AuthURL: "http://idp.test/auth", TokenURL: endpoint.srv.URL + "/token"},
}
cfg.EndSessionEndpoint = "http://idp.test/realms/main/protocol/openid-connect/logout"
cfg.RevocationEndpoint = endpoint.srv.URL + "/revoke"
return cfg
}
// signedIn seeds an authenticated session carrying the given tokens and
// returns its cookie token.
func signedIn(t *testing.T, cfg *Config, idToken, refreshToken string) string {
t.Helper()
seed := httptest.NewRequest(http.MethodGet, "/", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyAuthenticated, true)
cfg.SessionManager.Put(r.Context(), sessionKeyPersonID, "person-1")
cfg.SessionManager.Put(r.Context(), sessionKeyIDToken, idToken)
if refreshToken != "" {
cfg.SessionManager.Put(r.Context(), sessionKeyRefreshToken, refreshToken)
}
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued")
}
return token
}
// logout posts to /logout with the given session cookie, as the account
// menu does, and returns the recorder and the parsed redirect target.
func logout(t *testing.T, cfg *Config, token string) (*httptest.ResponseRecorder, *url.URL) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body %q", rec.Code, rec.Body.String())
}
dest, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("Location %q: %v", rec.Header().Get("Location"), err)
}
return rec, dest
}
// stillAuthenticated reports whether a request carrying token still passes
// the auth middleware, which is the only definition of "signed in" that
// matters.
func stillAuthenticated(t *testing.T, cfg *Config, token string) bool {
t.Helper()
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })
req := httptest.NewRequest(http.MethodGet, "/some/page", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(rec, req)
return reached
}
// Finding 11 of the 2026-09 security audit: the session was destroyed only in
// the logout callback, so a person who closed the tab at the identity
// provider's confirmation prompt stayed signed in for the rest of the
// session's week. Sign-out now ends the session before the redirect, and the
// cookie the browser still holds names nothing.
func TestLogoutEndsTheSessionBeforeTheRedirect(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "")
if !stillAuthenticated(t, cfg, token) {
t.Fatal("setup: the seeded session does not pass the middleware")
}
rec, dest := logout(t, cfg, token)
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived /logout: the round trip to the provider was still in charge of ending it")
}
expired := false
for _, c := range rec.Result().Cookies() {
if c.Name == cfg.SessionManager.Cookie.Name && (c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now()))) {
expired = true
}
}
if !expired {
t.Error("the response did not expire the session cookie")
}
if dest.Host != "idp.test" || !strings.HasSuffix(dest.Path, "/protocol/openid-connect/logout") {
t.Errorf("redirected to %q, want the provider's logout endpoint", dest)
}
if got := dest.Query().Get("post_logout_redirect_uri"); got != "http://console.test/logout-callback" {
t.Errorf("post_logout_redirect_uri = %q", got)
}
// The round trip no longer ends the session, but the 2026-09 audit's
// second run found a forged GET straight at /logout-callback could still
// end an unrelated live session; state is what the callback uses to tell
// a genuine round trip apart from that forgery (design D4).
if dest.Query().Get("state") == "" {
t.Error("no logout state was sent; the callback has nothing to distinguish a forged visit with")
}
}
// While the stored ID token is still valid it is the hint, and the provider's
// token endpoint is not consulted.
func TestLogoutUsesTheStoredHintWhileItIsValid(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "should-not-be-minted")
cfg := logoutConfig(t, endpoint)
stored := fakeIDToken(t, time.Now().Add(time.Hour).Unix())
token := signedIn(t, cfg, stored, "refresh-1")
_, dest := logout(t, cfg, token)
if got := dest.Query().Get("id_token_hint"); got != stored {
t.Errorf("id_token_hint = %q, want the stored token", got)
}
if n := endpoint.calls.Load(); n != 0 {
t.Errorf("token endpoint called %d times for a hint that was already valid", n)
}
}
// The reason the refresh token is kept: an aged session's stored ID token is
// expired, and an expired hint makes the provider show its confirmation
// prompt -- the step a person abandons. A fresh ID token minted from the
// refresh token is a valid hint, so the provider signs out without asking.
func TestLogoutMintsAFreshHintFromTheRefreshToken(t *testing.T) {
fresh := fakeIDToken(t, time.Now().Add(5*time.Minute).Unix())
endpoint := newFakeTokenEndpoint(t, fresh)
cfg := logoutConfig(t, endpoint)
expired := fakeIDToken(t, time.Now().Add(-48*time.Hour).Unix())
token := signedIn(t, cfg, expired, "refresh-1")
_, dest := logout(t, cfg, token)
if got := dest.Query().Get("id_token_hint"); got != fresh {
t.Errorf("id_token_hint = %q, want the freshly minted token", got)
}
if endpoint.lastGrant != "refresh_token" || endpoint.lastRefresh != "refresh-1" {
t.Errorf("token endpoint saw grant %q with refresh token %q", endpoint.lastGrant, endpoint.lastRefresh)
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived a sign-out that minted a hint")
}
}
// A refresh that fails costs the hint and nothing else: the session still
// ends and the provider is still asked, and it will prompt as it did before
// the refresh token was kept.
func TestLogoutWithoutAUsableHintStillEndsTheSession(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
endpoint.fail = true
cfg := logoutConfig(t, endpoint)
expired := fakeIDToken(t, time.Now().Add(-48*time.Hour).Unix())
token := signedIn(t, cfg, expired, "refresh-1")
_, dest := logout(t, cfg, token)
if dest.Query().Has("id_token_hint") {
t.Errorf("a hint was sent after the refresh failed: %q", dest.Query().Get("id_token_hint"))
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived because the refresh failed; the hint must never gate the sign-out")
}
// One attempt from this side. The oauth2 library, left to auto-detect
// the client-auth style, retries a 4xx once with the other style, so
// the endpoint may see two requests for the one call idTokenHint makes.
if n := endpoint.calls.Load(); n < 1 || n > 2 {
t.Errorf("token endpoint called %d times, want one attempt (at most two requests)", n)
}
}
// A session with no refresh token (a provider that issues none) behaves as
// before: no hint past the ID token's lifetime, and the session still ends.
func TestLogoutWithNoRefreshTokenOmitsTheHint(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "unreachable")
cfg := logoutConfig(t, endpoint)
expired := fakeIDToken(t, time.Now().Add(-48*time.Hour).Unix())
token := signedIn(t, cfg, expired, "")
_, dest := logout(t, cfg, token)
if dest.Query().Has("id_token_hint") {
t.Error("a hint was sent with nothing valid to send")
}
if n := endpoint.calls.Load(); n != 0 {
t.Errorf("token endpoint called %d times with no refresh token to present", n)
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived")
}
}
// The callback never refuses: with no session, with a stale cookie, or with
// whatever query the provider (or anyone) appends, the browser is always
// redirected. None of these cases carries a logout_state cookie matching the
// query's state (design D4), so none of them is the genuine round trip that
// lands on /login; they redirect to / instead, the page that knows whether a
// session that may still be live should bounce.
func TestLogoutCallbackNeverRefuses(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
for _, tc := range []struct {
name string
token string
query string
}{
{"no session, no query", "", ""},
{"no session, a state nobody stored", "", "?state=whatever"},
{"a cookie for a session that no longer exists", "stale-token-value", "?state=whatever"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/logout-callback"+tc.query, nil)
if tc.token != "" {
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: tc.token})
}
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutCallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
t.Errorf("status %d Location %q, want 302 to /", rec.Code, rec.Header().Get("Location"))
}
})
}
}
// Design D4: the redirect to the provider carries an unguessable state, and
// the same value is held in a cookie scoped to the callback path -- the
// only place the state can live once /logout has already destroyed the
// session.
func TestLogoutSetsTheLogoutStateCookieAndCarriesItInTheRedirect(t *testing.T) {
cfg := logoutConfig(t, newFakeTokenEndpoint(t, ""))
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "")
rec, dest := logout(t, cfg, token)
state := dest.Query().Get("state")
if state == "" {
t.Fatal("the end-session URL carries no state")
}
var cookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "logout_state" {
cookie = c
}
}
if cookie == nil {
t.Fatal("the response set no logout_state cookie")
}
if cookie.Value != state {
t.Errorf("logout_state cookie = %q, want the redirect's state %q", cookie.Value, state)
}
if cookie.Path != "/logout-callback" {
t.Errorf("logout_state cookie Path = %q, want /logout-callback", cookie.Path)
}
if !cookie.HttpOnly {
t.Error("logout_state cookie is not HttpOnly")
}
if cookie.MaxAge != 300 {
t.Errorf("logout_state cookie Max-Age = %d, want 300", cookie.MaxAge)
}
}
// The genuine round trip: the provider echoed back the state LogoutHandler
// handed it, held in the browser's logout_state cookie (design D4). Any
// session the browser still presents -- a store that refused /logout's own
// destroy, or one that outlived it some other way -- is ended here too, and
// the cookie is cleared so a replay of the same callback URL matches
// nothing.
func TestLogoutCallbackWithMatchingStateEndsTheSessionAndRedirectsToLogin(t *testing.T) {
cfg := logoutConfig(t, newFakeTokenEndpoint(t, ""))
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "")
req := httptest.NewRequest(http.MethodGet, "/logout-callback?state=the-state", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
req.AddCookie(&http.Cookie{Name: "logout_state", Value: "the-state"})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutCallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
t.Fatalf("status %d Location %q, want 302 to /login", rec.Code, rec.Header().Get("Location"))
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived a callback whose state matched the cookie")
}
var cleared bool
for _, c := range rec.Result().Cookies() {
if c.Name == "logout_state" && c.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Error("the response did not clear the logout_state cookie")
}
}
// The 2026-09 audit's second run: a forged visit to /logout-callback, with a
// live session the browser still carries, must not end it. None of these
// requests can produce a state that equals the logout_state cookie -- the
// cookie is HttpOnly and scoped to this console, so a page on another
// origin sending the browser here has no way to read or guess it -- and
// each leaves the session alone and lands on / rather than /login.
func TestLogoutCallbackForgedRequestsLeaveALiveSessionAlone(t *testing.T) {
for _, tc := range []struct {
name string
setCookie bool
cookieState string
queryState string
origin string
}{
{"no logout_state cookie at all", false, "", "the-real-state", ""},
{"a query state that does not match the cookie", true, "the-real-state", "someone-elses-state", ""},
{"a forged cross-origin navigation with a guessed state", true, "the-real-state", "attacker-guessed-state", "https://evil.example"},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := logoutConfig(t, newFakeTokenEndpoint(t, ""))
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "")
req := httptest.NewRequest(http.MethodGet, "/logout-callback?state="+tc.queryState, nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
if tc.setCookie {
req.AddCookie(&http.Cookie{Name: "logout_state", Value: tc.cookieState})
}
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutCallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
t.Errorf("status %d Location %q, want 302 to /", rec.Code, rec.Header().Get("Location"))
}
if !stillAuthenticated(t, cfg, token) {
t.Error("a forged callback ended a live session")
}
})
}
}
// fakeDiscovery serves an OpenID discovery document whose issuer is its own
// URL, with the endpoints given, so a real *oidc.Provider can be built from
// it without a network.
func fakeDiscovery(t *testing.T, extra map[string]any) *oidc.Provider {
t.Helper()
var issuer string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/openid-configuration" {
http.NotFound(w, r)
return
}
doc := map[string]any{
"issuer": issuer,
"authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token",
"jwks_uri": issuer + "/keys",
}
for k, v := range extra {
doc[k] = v
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(doc)
}))
t.Cleanup(srv.Close)
issuer = srv.URL
provider, err := oidc.NewProvider(context.Background(), issuer)
if err != nil {
t.Fatalf("discovery: %v", err)
}
return provider
}
// The two endpoints sign-out needs come from discovery, once, at Setup. The
// end-session endpoint is required (OpenID Connect RP-Initiated Logout 1.0,
// section 2.1): a provider without one cannot end its session for the person,
// and there is no path worth guessing. The revocation endpoint (RFC 7009) is
// optional.
func TestProviderEndpointsComeFromDiscovery(t *testing.T) {
t.Run("both published", func(t *testing.T) {
provider := fakeDiscovery(t, map[string]any{
"end_session_endpoint": "https://idp.example/session/end",
"revocation_endpoint": "https://idp.example/revoke",
})
endSession, revocation, err := providerEndpoints(provider)
if err != nil {
t.Fatalf("providerEndpoints: %v", err)
}
if endSession != "https://idp.example/session/end" || revocation != "https://idp.example/revoke" {
t.Errorf("got %q, %q", endSession, revocation)
}
})
t.Run("no revocation endpoint is allowed", func(t *testing.T) {
provider := fakeDiscovery(t, map[string]any{"end_session_endpoint": "https://idp.example/session/end"})
_, revocation, err := providerEndpoints(provider)
if err != nil || revocation != "" {
t.Errorf("got revocation %q, err %v", revocation, err)
}
})
t.Run("no end-session endpoint refuses to start", func(t *testing.T) {
provider := fakeDiscovery(t, nil)
if _, _, err := providerEndpoints(provider); err == nil || !strings.Contains(err.Error(), "end_session_endpoint") {
t.Errorf("want an error naming end_session_endpoint, got %v", err)
}
})
}
// The handler sends the browser to the endpoint Setup discovered and nothing
// else; a Config without one is a construction error, refused rather than
// guessed around.
func TestLogoutGoesToTheDiscoveredEndSessionEndpoint(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
cfg.EndSessionEndpoint = "https://idp.example/session/end"
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "")
_, dest := logout(t, cfg, token)
if got := dest.Scheme + "://" + dest.Host + dest.Path; got != "https://idp.example/session/end" {
t.Errorf("redirected to %q, want the discovered endpoint", got)
}
cfg.EndSessionEndpoint = ""
token = signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "")
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("with no end-session endpoint: status %d, want 500 rather than a guessed path", rec.Code)
}
}
// The refresh token is revoked at the provider once the session is gone (RFC
// 7009), authenticated as the client, so that a copy of it kept anywhere no
// longer mints tokens.
func TestLogoutRevokesTheRefreshToken(t *testing.T) {
t.Run("the stored token, when no refresh was needed", func(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
logout(t, cfg, token)
if len(endpoint.revokedTokens) != 1 || endpoint.revokedTokens[0] != "refresh-1" || endpoint.revokedHint != "refresh_token" {
t.Errorf("revocation endpoint got tokens %q hint %q", endpoint.revokedTokens, endpoint.revokedHint)
}
if endpoint.revokeUser != "test-client" || endpoint.revokePassword != "test-secret" {
t.Errorf("revocation was not authenticated as the client: user %q", endpoint.revokeUser)
}
})
t.Run("both the stored and the rotated token, when the refresh issued a new one", func(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, fakeIDToken(t, time.Now().Add(5*time.Minute).Unix()))
cfg := logoutConfig(t, endpoint)
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(-48*time.Hour).Unix()), "refresh-1")
logout(t, cfg, token)
want := []string{"refresh-1", "rotated-refresh"}
if len(endpoint.revokedTokens) != 2 || endpoint.revokedTokens[0] != want[0] || endpoint.revokedTokens[1] != want[1] {
t.Errorf("revoked %q, want both tokens that were live: %q", endpoint.revokedTokens, want)
}
})
t.Run("no revocation endpoint, no call", func(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
cfg.RevocationEndpoint = ""
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
logout(t, cfg, token)
if n := endpoint.revokeCalls.Load(); n != 0 {
t.Errorf("revocation endpoint called %d times with none configured", n)
}
})
t.Run("a failed revocation costs nothing else", func(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
endpoint.revokeFail = true
cfg := logoutConfig(t, endpoint)
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
_, dest := logout(t, cfg, token)
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived because revocation failed")
}
if !dest.Query().Has("id_token_hint") {
t.Error("the hint was dropped because revocation failed")
}
})
}
// The 2026-09 audit's "Logout CSRF" candidate: /logout was a GET, exempt from
// the cross-origin protection by its method, so any page could end a
// person's session by sending the browser there. Sign-out is now a POST the
// account menu's button makes; a GET is a link someone was sent and leads
// back to the console with the session intact.
func TestLogoutGetNeverEndsTheSession(t *testing.T) {
cfg := logoutConfig(t, newFakeTokenEndpoint(t, ""))
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
mux := http.NewServeMux()
cfg.RegisterHandlers(mux)
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(mux).ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Errorf("GET /logout: %d to %q, want 303 to /", rec.Code, rec.Header().Get("Location"))
}
if !stillAuthenticated(t, cfg, token) {
t.Error("a GET at /logout ended the session")
}
// The handler itself refuses anything but a POST, for a caller that
// wires it without RegisterHandlers.
req = httptest.NewRequest(http.MethodGet, "/logout", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec = httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != http.MethodPost {
t.Errorf("LogoutHandler on GET: %d, Allow %q; want 405 allowing POST", rec.Code, rec.Header().Get("Allow"))
}
if !stillAuthenticated(t, cfg, token) {
t.Error("a refused GET at LogoutHandler ended the session")
}
}
// The account menu's Sign out is an htmx request. fetch cannot follow a
// redirect to the provider's origin, so the answer is 200 with HX-Redirect
// naming the end-session URL, and the session is gone before it is sent.
func TestLogoutFromHTMXAnswersWithHXRedirect(t *testing.T) {
cfg := logoutConfig(t, newFakeTokenEndpoint(t, ""))
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("HX-Request", "true")
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 with HX-Redirect; body %q", rec.Code, rec.Body.String())
}
dest, err := url.Parse(rec.Header().Get("HX-Redirect"))
if err != nil || dest.Scheme+"://"+dest.Host+dest.Path != cfg.EndSessionEndpoint {
t.Errorf("HX-Redirect = %q, want the end-session endpoint %q", rec.Header().Get("HX-Redirect"), cfg.EndSessionEndpoint)
}
if dest.Query().Get("id_token_hint") == "" {
t.Error("the htmx answer dropped the id_token_hint")
}
if rec.Header().Get("Location") != "" {
t.Error("an htmx request must not also get a Location redirect")
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived an htmx sign-out")
}
}