Skip to content

Commit d2ffa7a

Browse files
torcolvinCopilot
andauthored
CBG-5356 don't respond with Set-Cookie for one_time session (#8315)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 3698360 commit d2ffa7a

6 files changed

Lines changed: 144 additions & 6 deletions

File tree

auth/session.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ func (auth *Authenticator) AuthenticateCookie(rq *http.Request, response http.Re
5858
// SessionTimeElapsed and tenPercentOfTtl use Nanoseconds for more precision when converting to int
5959
sessionTimeElapsed := int((time.Now().Add(duration).Sub(session.Expiration)).Nanoseconds())
6060
tenPercentOfTtl := int(duration.Nanoseconds()) / 10
61-
if sessionTimeElapsed > tenPercentOfTtl {
61+
// One-time sessions must not refresh the cookie — they will be deleted on this request.
62+
if sessionTimeElapsed > tenPercentOfTtl && (session.OneTime == nil || !*session.OneTime) {
6263
session.Expiration = time.Now().Add(duration)
6364
if err = auth.datastore.Set(auth.LogCtx, auth.DocIDForSession(session.ID), base.DurationToCbsExpiry(duration), nil, session); err != nil {
6465
return nil, err

auth/session_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,90 @@ func TestUserDeleteAllSessions(t *testing.T) {
367367
require.EqualError(t, err, "401 Session no longer valid for user")
368368
}
369369

370+
// TestAuthenticateCookieOneTimeSession verifies one-time session behaviour in AuthenticateCookie:
371+
// - The session is consumed on first use and rejected on the second.
372+
// - No Set-Cookie header is ever written, including when the normal TTL-refresh branch would
373+
// fire for a regular session (sessionTimeElapsed > 10% of TTL).
374+
func TestAuthenticateCookieOneTimeSession(t *testing.T) {
375+
ctx := base.TestCtx(t)
376+
testBucket := base.GetTestBucket(t)
377+
defer testBucket.Close(ctx)
378+
dataStore := testBucket.GetSingleDataStore()
379+
a := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
380+
381+
const username = "Alice"
382+
user, err := a.NewUser(username, "password", base.Set{})
383+
require.NoError(t, err)
384+
require.NoError(t, a.Save(user))
385+
386+
t.Run("one-time session authenticates once then is deleted", func(t *testing.T) {
387+
session, err := a.CreateSession(ctx, user, 2*time.Hour, true)
388+
require.NoError(t, err)
389+
390+
req, err := http.NewRequest(http.MethodGet, "", nil)
391+
require.NoError(t, err)
392+
req.AddCookie(a.MakeSessionCookie(session, false, false, http.SameSiteDefaultMode))
393+
394+
authedUser, err := a.AuthenticateCookie(req, httptest.NewRecorder())
395+
require.NoError(t, err)
396+
require.NotNil(t, authedUser)
397+
assert.Equal(t, username, authedUser.Name())
398+
399+
_, err = a.AuthenticateCookie(req, httptest.NewRecorder())
400+
require.Error(t, err)
401+
assert.Equal(t, http.StatusUnauthorized, err.(*base.HTTPError).Status)
402+
})
403+
404+
t.Run("one-time session does not set cookie even when TTL refresh would trigger", func(t *testing.T) {
405+
// Expiration=now+2h with Ttl=24h means sessionTimeElapsed≈22h > tenPercentOfTtl≈2.4h,
406+
// so a regular session would call http.SetCookie here — a one-time session must not.
407+
oneTime := true
408+
sessionID, err := base.GenerateRandomSecret()
409+
require.NoError(t, err)
410+
session := &LoginSession{
411+
ID: sessionID,
412+
Username: username,
413+
Expiration: time.Now().Add(2 * time.Hour),
414+
Ttl: 24 * time.Hour,
415+
SessionUUID: user.GetSessionUUID(),
416+
OneTime: &oneTime,
417+
}
418+
require.NoError(t, dataStore.Set(ctx, a.DocIDForSession(sessionID), base.DurationToCbsExpiry(24*time.Hour), nil, session))
419+
420+
req, err := http.NewRequest(http.MethodGet, "", nil)
421+
require.NoError(t, err)
422+
req.AddCookie(&http.Cookie{Name: a.SessionCookieName, Value: sessionID})
423+
recorder := httptest.NewRecorder()
424+
authedUser, err := a.AuthenticateCookie(req, recorder)
425+
require.NoError(t, err)
426+
require.NotNil(t, authedUser)
427+
assert.Empty(t, recorder.Header().Get("Set-Cookie"), "one-time session must not set a cookie even when TTL refresh would trigger")
428+
})
429+
430+
t.Run("regular session refreshes cookie when TTL threshold met", func(t *testing.T) {
431+
// Same setup without OneTime — the refresh branch should fire and Set-Cookie should be present.
432+
sessionID, err := base.GenerateRandomSecret()
433+
require.NoError(t, err)
434+
session := &LoginSession{
435+
ID: sessionID,
436+
Username: username,
437+
Expiration: time.Now().Add(2 * time.Hour),
438+
Ttl: 24 * time.Hour,
439+
SessionUUID: user.GetSessionUUID(),
440+
}
441+
require.NoError(t, dataStore.Set(ctx, a.DocIDForSession(sessionID), base.DurationToCbsExpiry(24*time.Hour), nil, session))
442+
443+
req, err := http.NewRequest(http.MethodGet, "", nil)
444+
require.NoError(t, err)
445+
req.AddCookie(&http.Cookie{Name: a.SessionCookieName, Value: sessionID})
446+
recorder := httptest.NewRecorder()
447+
authedUser, err := a.AuthenticateCookie(req, recorder)
448+
require.NoError(t, err)
449+
require.NotNil(t, authedUser)
450+
assert.NotEmpty(t, recorder.Header().Get("Set-Cookie"), "regular session should refresh cookie when TTL threshold is met")
451+
})
452+
}
453+
370454
func TestCreateOneTimeSession(t *testing.T) {
371455
ctx := base.TestCtx(t)
372456
testBucket := base.GetTestBucket(t)

docs/api/paths/public/db-_session.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ post:
2626
If `Origin` header is passed to this endpoint, the `Origin` header must match both the `cors.login_origin` and `cors.origin` configuration options.
2727
parameters:
2828
- name: one_time
29-
description: Sets the session to only be valid for a single authentication. This session will expire in 5 minutes if not used.
29+
description: Sets the session to only be valid for a single authentication. This session will expire in 5 minutes if not used. If this is set the session cookie is only returned in the response body, and not the header.
3030
in: query
3131
schema:
3232
type: boolean

rest/blip_sync_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ func TestOneTimeSessionBlipSyncAuthentication(t *testing.T) {
117117
secWebSocketProtocolHeader: blipSessionIDPrefix + sessionResp.SessionID,
118118
})
119119
RequireStatus(t, resp, http.StatusUpgradeRequired)
120+
assert.Empty(t, resp.Header().Get("Set-Cookie"), "one-time session auth must not set a session cookie")
120121

121122
// one time token is expired
122123
resp = rt.SendRequestWithHeaders(http.MethodGet, "/{{.db}}/_blipsync", "", map[string]string{

rest/session_api.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,11 @@ func (h *handler) makeSessionWithTTL(user auth.User, expiry time.Duration, oneTi
150150
if err != nil {
151151
return "", err
152152
}
153-
cookie := auth.MakeSessionCookie(session, h.db.Options.SecureCookieOverride, h.db.Options.SessionCookieHttpOnly, h.db.SameSiteCookieMode)
154-
base.AddDbPathToCookie(h.rq, cookie)
155-
http.SetCookie(h.response, cookie)
153+
if !oneTime {
154+
cookie := auth.MakeSessionCookie(session, h.db.Options.SecureCookieOverride, h.db.Options.SessionCookieHttpOnly, h.db.SameSiteCookieMode)
155+
base.AddDbPathToCookie(h.rq, cookie)
156+
http.SetCookie(h.response, cookie)
157+
}
156158
return session.ID, nil
157159
}
158160

rest/session_test.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,49 @@ func TestSessionExpirationDateTimeFormat(t *testing.T) {
755755
assert.True(t, expires.Sub(time.Now()).Hours() <= 24, "Couldn't validate session expiration")
756756
}
757757

758+
// TestSessionCreationCookieBehavior verifies that POST /_session sets a cookie for regular sessions
759+
// and does not set a cookie for one-time sessions (whose ID is delivered via the JSON body instead).
760+
func TestSessionCreationCookieBehavior(t *testing.T) {
761+
rt := NewRestTesterPersistentConfig(t)
762+
defer rt.Close()
763+
764+
username := "alice"
765+
rt.CreateUser(username, []string{"*"})
766+
767+
dbName := rt.GetDatabase().Name
768+
769+
t.Run("regular session sets cookie", func(t *testing.T) {
770+
resp := rt.SendUserRequest(http.MethodPost, "/{{.db}}/_session", "", username)
771+
RequireStatus(t, resp, http.StatusOK)
772+
773+
cookie, err := http.ParseSetCookie(resp.Header().Get("Set-Cookie"))
774+
require.NoError(t, err)
775+
assert.Equal(t, auth.DefaultCookieName, cookie.Name)
776+
assert.NotEmpty(t, cookie.Value)
777+
assert.Equal(t, "/"+dbName, cookie.Path)
778+
assert.True(t, cookie.Expires.After(time.Now()), "cookie expiration should be in the future")
779+
780+
var body struct {
781+
OneTimeSessionID string `json:"one_time_session_id"`
782+
}
783+
require.NoError(t, base.JSONUnmarshal(resp.BodyBytes(), &body))
784+
assert.Empty(t, body.OneTimeSessionID, "regular session response must not include one_time_session_id")
785+
})
786+
787+
t.Run("one-time session sets no cookie", func(t *testing.T) {
788+
resp := rt.SendUserRequest(http.MethodPost, "/{{.db}}/_session?one_time=true", "", username)
789+
RequireStatus(t, resp, http.StatusOK)
790+
791+
assert.Empty(t, resp.Header().Get("Set-Cookie"), "one-time session creation must not set a cookie")
792+
793+
var body struct {
794+
OneTimeSessionID string `json:"one_time_session_id"`
795+
}
796+
require.NoError(t, base.JSONUnmarshal(resp.BodyBytes(), &body))
797+
assert.NotEmpty(t, body.OneTimeSessionID, "one-time session response must include one_time_session_id")
798+
})
799+
}
800+
758801
func TestOneTimeSessionWithCookie(t *testing.T) {
759802
rt := NewRestTesterPersistentConfig(t)
760803
defer rt.Close()
@@ -764,9 +807,16 @@ func TestOneTimeSessionWithCookie(t *testing.T) {
764807

765808
resp := rt.SendUserRequest(http.MethodPost, "/{{.db}}/_session?one_time=true", "", username)
766809
RequireStatus(t, resp, http.StatusOK)
810+
assert.Empty(t, resp.Header().Get("Set-Cookie"), "one-time session creation must not set a cookie")
811+
812+
var sessionResp struct {
813+
SessionID string `json:"one_time_session_id"`
814+
}
815+
require.NoError(t, base.JSONUnmarshal(resp.BodyBytes(), &sessionResp))
816+
require.NotEmpty(t, sessionResp.SessionID)
767817

768818
headers := map[string]string{
769-
"Cookie": resp.Header().Get("Set-Cookie"),
819+
"Cookie": auth.DefaultCookieName + "=" + sessionResp.SessionID,
770820
}
771821

772822
resp = rt.SendRequestWithHeaders(http.MethodGet, "/{{.db}}/", "", headers)

0 commit comments

Comments
 (0)