- 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
666 lines
26 KiB
Go
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")
|
|
}
|
|
}
|