Skip to content

Commit 746aa8f

Browse files
authored
Fix Logout Path, enable logout handler capabilities (#538)
* fixes for logout handler * linter
1 parent 9db5400 commit 746aa8f

12 files changed

Lines changed: 536 additions & 45 deletions

auth/auth.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,13 @@ func SetAuthCookies(w http.ResponseWriter, accessToken, refreshToken string, c s
113113
sessions.SetCookie(w, refreshToken, RefreshTokenCookie, c)
114114
}
115115

116-
// ClearAuthCookies is a helper function to clear authentication cookies on a echo
117-
// request to effectively logger out a user.
118-
func ClearAuthCookies(w http.ResponseWriter) {
119-
sessions.RemoveCookies(w, *sessions.DefaultCookieConfig, AccessTokenCookie, RefreshTokenCookie)
116+
// ClearAuthCookies clears the access and refresh token cookies to effectively log out a user. The
117+
// supplied CookieConfig must match the one the cookies were set with via SetAuthCookies (domain,
118+
// path, secure, samesite); otherwise the browser will not match the expiry cookie and the original
119+
// cookies will persist. Pass the same SessionConfig.CookieConfig used at login rather than a global
120+
// default.
121+
func ClearAuthCookies(w http.ResponseWriter, c sessions.CookieConfig) {
122+
sessions.RemoveCookies(w, c, AccessTokenCookie, RefreshTokenCookie)
120123
}
121124

122125
// CookieExpired checks to see if a cookie is expired

auth/auth_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ func TestClearAuthCookies(t *testing.T) {
259259
}
260260
for _, tc := range tests {
261261
t.Run(tc.name, func(_ *testing.T) {
262-
auth.ClearAuthCookies(tc.ctx.Response().Writer)
262+
auth.ClearAuthCookies(tc.ctx.Response().Writer, *sessions.DebugOnlyCookieConfig)
263263
})
264264
}
265265
}

sessions/cookiestore.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ type Store[T any] interface {
2525
Get(req *http.Request, name string) (*Session[T], error)
2626
// Save writes a Session to the ResponseWriter
2727
Save(w http.ResponseWriter, session *Session[T]) error
28-
// Destroy removes (expires) a named Session
28+
// Destroy expires the named session cookie on the response. This only clears the client cookie
29+
// and does not remove any persisted session; use SessionConfig.DestroySession on logout
2930
Destroy(w http.ResponseWriter, name string)
3031
// GetSessionIDFromCookie returns the key, which should be the sessionID, in the map
3132
GetSessionIDFromCookie(sess *Session[T]) string
@@ -115,14 +116,19 @@ func (s *cookieStore[T]) Save(w http.ResponseWriter, session *Session[T]) error
115116
return nil
116117
}
117118

119+
// EncodeCookie encodes the session values into a cookie value string
118120
func (s *cookieStore[T]) EncodeCookie(session *Session[T]) (string, error) {
119121
return securecookie.EncodeMulti(session.Name(), &session.values, s.codecs...)
120122
}
121123

122-
// Destroy deletes the Session with the given name by issuing an expired
123-
// session cookie with the same name.
124+
// Destroy expires the session cookie with the given name by issuing an expired cookie. It only
125+
// clears the client cookie and does not remove any persisted session; use
126+
// SessionConfig.DestroySession to also delete the session from the backing store on logout.
124127
func (s *cookieStore[T]) Destroy(w http.ResponseWriter, name string) {
125-
http.SetCookie(w, NewCookie(name, "", &CookieConfig{MaxAge: -1, Path: s.config.Path}))
128+
// reuse the full store config so the deletion cookie matches the original on Domain, Path,
129+
// Secure, and SameSite; a deletion cookie that omits the Domain will not match a cookie that was
130+
// set with one and the browser will keep it
131+
RemoveCookie(w, name, *s.config)
126132
}
127133

128134
// NewSessionCookie creates a cookie from a session id

sessions/destroysession_test.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package sessions_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/alicebob/miniredis/v2"
11+
"github.com/redis/go-redis/v9"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/theopenlane/iam/sessions"
16+
)
17+
18+
// errDeleteSession simulates a backing-store failure when deleting a persisted session
19+
var errDeleteSession = errors.New("delete failed")
20+
21+
// failingDeleteStore wraps a PersistentStore but always fails DeleteSession, used to exercise the
22+
// error path of DestroySession
23+
type failingDeleteStore struct {
24+
sessions.PersistentStore
25+
}
26+
27+
// DeleteSession always returns an error
28+
func (failingDeleteStore) DeleteSession(context.Context, string) error {
29+
return errDeleteSession
30+
}
31+
32+
// newDestroyTestConfig builds a redis-backed session config sharing a single named cookie config
33+
// between the cookie store and the session config, mirroring how the server wires sessions
34+
func newDestroyTestConfig(t *testing.T) (sessions.SessionConfig, sessions.Store[map[string]any], *miniredis.Miniredis) {
35+
t.Helper()
36+
37+
mr, err := miniredis.Run()
38+
require.NoError(t, err)
39+
40+
rc := redis.NewClient(&redis.Options{Addr: mr.Addr()})
41+
42+
cc := sessions.DebugOnlyCookieConfig
43+
sm := sessions.NewCookieStore[map[string]any](cc, []byte("my-signing-secret"), []byte("encryptionsecret"))
44+
45+
sc := sessions.NewSessionConfig(sm, sessions.WithPersistence(rc))
46+
sc.CookieConfig = cc
47+
48+
return sc, sm, mr
49+
}
50+
51+
// requestWithSessionCookie creates a session in the store and returns a request carrying its cookie
52+
func requestWithSessionCookie(t *testing.T, sc sessions.SessionConfig, userID string) *http.Request {
53+
t.Helper()
54+
55+
createRec := httptest.NewRecorder()
56+
_, err := sc.CreateAndStoreSession(context.Background(), createRec, userID)
57+
require.NoError(t, err)
58+
59+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/logout", nil)
60+
for _, c := range createRec.Result().Cookies() {
61+
req.AddCookie(c)
62+
}
63+
64+
return req
65+
}
66+
67+
func TestDestroySession(t *testing.T) {
68+
ctx := context.Background()
69+
70+
t.Run("removes the persisted session and expires the cookie", func(t *testing.T) {
71+
sc, _, mr := newDestroyTestConfig(t)
72+
defer mr.Close()
73+
74+
req := requestWithSessionCookie(t, sc, "user-123")
75+
76+
session, err := sc.SessionManager.Get(req, sc.CookieConfig.Name)
77+
require.NoError(t, err)
78+
79+
sessionID := sc.SessionManager.GetSessionIDFromCookie(session)
80+
require.NotEmpty(t, sessionID)
81+
82+
exists, err := sc.RedisStore.Exists(ctx, sessionID)
83+
require.NoError(t, err)
84+
require.Equal(t, int64(1), exists)
85+
86+
destroyRec := httptest.NewRecorder()
87+
err = sc.DestroySession(ctx, destroyRec, req)
88+
require.NoError(t, err)
89+
90+
// the persisted session is gone
91+
exists, err = sc.RedisStore.Exists(ctx, sessionID)
92+
require.NoError(t, err)
93+
assert.Equal(t, int64(0), exists)
94+
95+
// the cookie is expired on the response
96+
cookies := destroyRec.Result().Cookies()
97+
require.Len(t, cookies, 1)
98+
assert.Equal(t, sc.CookieConfig.Name, cookies[0].Name)
99+
assert.Equal(t, -1, cookies[0].MaxAge)
100+
})
101+
102+
t.Run("no session cookie is a no-op that still returns nil", func(t *testing.T) {
103+
sc, _, mr := newDestroyTestConfig(t)
104+
defer mr.Close()
105+
106+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/logout", nil)
107+
rec := httptest.NewRecorder()
108+
109+
err := sc.DestroySession(ctx, rec, req)
110+
assert.NoError(t, err)
111+
})
112+
113+
t.Run("malformed session cookie is a no-op that still returns nil", func(t *testing.T) {
114+
sc, _, mr := newDestroyTestConfig(t)
115+
defer mr.Close()
116+
117+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/logout", nil)
118+
req.AddCookie(&http.Cookie{
119+
Name: sc.CookieConfig.Name,
120+
Value: "not-a-valid-encoded-cookie",
121+
Secure: true,
122+
HttpOnly: true,
123+
SameSite: http.SameSiteStrictMode,
124+
})
125+
126+
rec := httptest.NewRecorder()
127+
128+
err := sc.DestroySession(ctx, rec, req)
129+
assert.NoError(t, err)
130+
})
131+
132+
t.Run("returns an error and keeps the cookie when the persisted session cannot be deleted", func(t *testing.T) {
133+
sc, _, mr := newDestroyTestConfig(t)
134+
defer mr.Close()
135+
136+
req := requestWithSessionCookie(t, sc, "user-err")
137+
138+
// swap in a store whose DeleteSession always fails
139+
failing := sc
140+
failing.RedisStore = failingDeleteStore{PersistentStore: sc.RedisStore}
141+
142+
destroyRec := httptest.NewRecorder()
143+
err := failing.DestroySession(ctx, destroyRec, req)
144+
145+
// the failure is surfaced, not swallowed
146+
assert.ErrorIs(t, err, errDeleteSession)
147+
148+
// the cookie is NOT expired, so the client is forced to retry rather than believe it logged out
149+
assert.Empty(t, destroyRec.Result().Cookies())
150+
})
151+
152+
t.Run("expires the cookie without a persistent store", func(t *testing.T) {
153+
cc := sessions.DebugOnlyCookieConfig
154+
sm := sessions.NewCookieStore[map[string]any](cc, []byte("my-signing-secret"), []byte("encryptionsecret"))
155+
156+
// no WithPersistence, so RedisStore is nil
157+
sc := sessions.NewSessionConfig(sm)
158+
sc.CookieConfig = cc
159+
require.Nil(t, sc.RedisStore)
160+
161+
// manually create a session cookie using the same store
162+
setRec := httptest.NewRecorder()
163+
session := sm.New(cc.Name)
164+
session.Set(sessions.GenerateSessionID(), map[string]any{sessions.UserIDKey: "user-x"})
165+
require.NoError(t, session.Save(setRec))
166+
167+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/logout", nil)
168+
for _, c := range setRec.Result().Cookies() {
169+
req.AddCookie(c)
170+
}
171+
172+
destroyRec := httptest.NewRecorder()
173+
err := sc.DestroySession(ctx, destroyRec, req)
174+
assert.NoError(t, err)
175+
176+
cookies := destroyRec.Result().Cookies()
177+
require.Len(t, cookies, 1)
178+
assert.Equal(t, -1, cookies[0].MaxAge)
179+
})
180+
}
181+
182+
// TestCookieStoreDestroy covers the fix that makes Store.Destroy build the deletion cookie from the
183+
// full store config rather than only the path, so it reliably matches the original cookie
184+
func TestCookieStoreDestroy(t *testing.T) {
185+
t.Run("replaces the original cookie with an expired one matching its identity", func(t *testing.T) {
186+
cfg := &sessions.CookieConfig{
187+
Name: "sess",
188+
Domain: "example.com",
189+
Path: "/app",
190+
Secure: true,
191+
HTTPOnly: true,
192+
SameSite: http.SameSiteStrictMode,
193+
}
194+
cs := sessions.NewCookieStore[map[string]any](cfg, []byte("my-signing-secret"), []byte("encryptionsecret"))
195+
196+
// set a real session cookie and capture the original that we intend to remove
197+
setRec := httptest.NewRecorder()
198+
session := cs.New(cfg.Name)
199+
session.Set(sessions.GenerateSessionID(), map[string]any{sessions.UserIDKey: "user-x"})
200+
require.NoError(t, session.Save(setRec))
201+
202+
setCookies := setRec.Result().Cookies()
203+
require.Len(t, setCookies, 1)
204+
205+
original := setCookies[0]
206+
require.NotEmpty(t, original.Value)
207+
require.Equal(t, "example.com", original.Domain) // the attribute the buggy deletion dropped
208+
209+
// destroy and capture the deletion cookie
210+
destroyRec := httptest.NewRecorder()
211+
cs.Destroy(destroyRec, cfg.Name)
212+
213+
delCookies := destroyRec.Result().Cookies()
214+
require.Len(t, delCookies, 1)
215+
216+
deletion := delCookies[0]
217+
218+
// the deletion cookie must share the original's identity (name+domain+path) and security
219+
// attributes, otherwise the browser stores a second, non-matching cookie and keeps the original.
220+
// This is exactly what the old Destroy did by dropping Domain/Secure/SameSite
221+
assert.Equal(t, original.Name, deletion.Name)
222+
assert.Equal(t, original.Domain, deletion.Domain)
223+
assert.Equal(t, original.Path, deletion.Path)
224+
assert.Equal(t, original.Secure, deletion.Secure)
225+
assert.Equal(t, original.SameSite, deletion.SameSite)
226+
227+
// and it must actually expire and empty the value so the original is replaced, not duplicated
228+
assert.Equal(t, -1, deletion.MaxAge)
229+
assert.Empty(t, deletion.Value)
230+
})
231+
232+
t.Run("sets no cookie when no name can be resolved", func(t *testing.T) {
233+
cfg := &sessions.CookieConfig{Path: "/"} // empty Name
234+
cs := sessions.NewCookieStore[map[string]any](cfg, []byte("my-signing-secret"), []byte("encryptionsecret"))
235+
236+
rec := httptest.NewRecorder()
237+
cs.Destroy(rec, "") // empty name and empty config name resolves to no cookie
238+
239+
assert.Empty(t, rec.Result().Cookies())
240+
})
241+
}

sessions/middleware.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package sessions
22

33
import (
44
"context"
5+
"errors"
56
"net/http"
67
"slices"
78
"time"
@@ -115,6 +116,43 @@ func (sc *SessionConfig) SaveAndStoreSession(ctx context.Context, w http.Respons
115116
return c, nil
116117
}
117118

119+
// DestroySession is the inverse of CreateAndStoreSession: it removes the session presented on the
120+
// request by deleting the persisted session from the backing store (e.g. redis) and expiring the
121+
// session cookie on the response. This is the correct way to invalidate a session on logout, since
122+
// the cookie-only Store.Destroy and Session.Destroy leave the persisted session in place.
123+
//
124+
// A request that carries no resolvable session is treated as already destroyed: the cookie is still
125+
// expired and nil is returned, which keeps logout idempotent. A cookie that is present but cannot be
126+
// decoded is anomalous (tampering, corruption, or a dropped signing key); it cannot identify a
127+
// persisted session to delete, so it is logged and the bad cookie is cleared, but it is not treated
128+
// as a hard failure since it is not retryable and decode errors can occur benignly during key
129+
// rotation. An error is only returned when an identified, persisted session could not be deleted, so
130+
// callers can avoid reporting a logout that did not take effect
131+
func (sc *SessionConfig) DestroySession(ctx context.Context, w http.ResponseWriter, req *http.Request) error {
132+
session, err := sc.SessionManager.Get(req, sc.CookieConfig.Name)
133+
if err != nil {
134+
// a missing cookie is normal; a cookie that is present but undecodable is anomalous and logged
135+
if !errors.Is(err, http.ErrNoCookie) {
136+
log.Warn().Err(err).Msg("could not decode session cookie on destroy; clearing it")
137+
}
138+
139+
// no resolvable session to remove server-side, but still expire whatever cookie was presented
140+
RemoveCookie(w, sc.CookieConfig.Name, *sc.CookieConfig)
141+
142+
return nil
143+
}
144+
145+
if sessionID := sc.SessionManager.GetSessionIDFromCookie(session); sessionID != "" && sc.RedisStore != nil {
146+
if err := sc.RedisStore.DeleteSession(ctx, sessionID); err != nil {
147+
return err
148+
}
149+
}
150+
151+
RemoveCookie(w, sc.CookieConfig.Name, *sc.CookieConfig)
152+
153+
return nil
154+
}
155+
118156
// LoadAndSave is a middleware function that loads and saves session data using a
119157
// provided session manager. It takes a `SessionManager` as input and returns a middleware function
120158
// that can be used with an Echo framework application

sessions/sessions.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ func (s *Session[T]) Save(w http.ResponseWriter) error {
8383
return s.store.Save(w, s)
8484
}
8585

86-
// Destroy destroys the session. Identical to calling
87-
// store.Destroy(w, session.name).
86+
// Destroy expires the session cookie on the response. Identical to calling
87+
// store.Destroy(w, session.name). This only clears the client cookie and does not remove any
88+
// persisted session; use SessionConfig.DestroySession to also delete the session from the backing
89+
// store on logout.
8890
func (s *Session[T]) Destroy(w http.ResponseWriter) {
8991
s.store.Destroy(w, s.name)
9092
}

tokens/blacklist_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -506,17 +506,18 @@ func TestGeneralTokenBlacklist(t *testing.T) {
506506
verifiedClaims, err := tmNoBlacklist.VerifyWithContext(ctx, tokenString)
507507
assert.NoError(t, err)
508508

509-
// Try to revoke (should be no-op)
509+
// Try to revoke; without a functional blacklist this surfaces an error rather than
510+
// silently succeeding, but the token remains valid since the revocation had no effect
510511
err = tmNoBlacklist.RevokeToken(ctx, verifiedClaims.ID, 30*time.Minute)
511-
assert.NoError(t, err)
512+
assert.ErrorIs(t, err, tokens.ErrRevocationNotConfigured)
512513

513514
// Token should still work since no blacklist
514515
_, err = tmNoBlacklist.VerifyWithContext(ctx, tokenString)
515516
assert.NoError(t, err)
516517

517-
// Try to suspend user (should be no-op)
518+
// Try to suspend user; same as revoke, this surfaces an error without a functional blacklist
518519
err = tmNoBlacklist.SuspendUser(ctx, "user-no-blacklist", 30*time.Minute)
519-
assert.NoError(t, err)
520+
assert.ErrorIs(t, err, tokens.ErrRevocationNotConfigured)
520521

521522
// Token should still work since no blacklist
522523
_, err = tmNoBlacklist.VerifyWithContext(ctx, tokenString)

tokens/errors.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ var (
142142
ErrInvalidTokenID = errors.New("invalid token ID")
143143
// ErrKeyLifecycleNotEnabled is returned when key lifecycle management is not enabled
144144
ErrKeyLifecycleNotEnabled = errors.New("key lifecycle management not enabled")
145+
// ErrRevocationNotConfigured is returned when a revocation or suspension operation is attempted
146+
// while only the no-op blacklist is installed, meaning the operation has no effect
147+
ErrRevocationNotConfigured = errors.New("token revocation is not configured: no functional blacklist is installed")
145148
)
146149

147150
// The errors that might occur when parsing and validating a token

0 commit comments

Comments
 (0)