Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions auth/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type LoginSession struct {
Expiration time.Time `json:"expiration"`
Ttl time.Duration `json:"ttl"`
SessionUUID string `json:"session_uuid"` // marker of when the user object changes, to match with session docs to determine if they are valid
OneTime *bool `json:"one_time,omitempty"`
}

const DefaultCookieName = "SyncGatewaySession"
Expand Down Expand Up @@ -76,10 +77,51 @@ func (auth *Authenticator) AuthenticateCookie(rq *http.Request, response http.Re
base.InfofCtx(auth.LogCtx, base.KeyAuth, "Session no longer valid for user %s", base.UD(session.Username))
return nil, base.HTTPErrorf(http.StatusUnauthorized, "Session no longer valid for user")
}
err = auth.deleteOneTimeSession(auth.LogCtx, &session)
if err != nil {
return nil, err
}

return user, err
}

func (auth *Authenticator) CreateSession(ctx context.Context, user User, ttl time.Duration) (*LoginSession, error) {
// AuthenticateOneTimeSession authenticates a session and deletes it upon successful authentication if it was marked as
// a one time sesssion. If it is a one time session, delete the session.
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'sesssion' to 'session'.

Suggested change
// a one time sesssion. If it is a one time session, delete the session.
// a one time session. If it is a one time session, delete the session.

Copilot uses AI. Check for mistakes.
func (auth *Authenticator) AuthenticateOneTimeSession(ctx context.Context, sessionID string) (User, error) {
session, user, err := auth.GetSession(sessionID)
if err != nil {
return nil, base.HTTPErrorf(http.StatusUnauthorized, "Session Invalid")
}

err = auth.deleteOneTimeSession(ctx, session)
if err != nil {
return nil, err
}
return user, nil
}

// deleteOneTimeSession deletes the session if it is marked as a one-time session. If the session can be not deleted, or
// was already deleted an error is returned.
func (auth *Authenticator) deleteOneTimeSession(ctx context.Context, session *LoginSession) error {
if session.OneTime == nil || !*session.OneTime {
return nil
}
err := auth.datastore.Delete(auth.DocIDForSession(session.ID))
if err != nil {
// If doc is not found, it probably means someone else is simultaneously using the one-time session, error.
// If the delete error comes from another source, still treat this as an error, expecting the client to retry
// due to a temporary KV issue.
if !base.IsDocNotFoundError(err) {
base.InfofCtx(ctx, base.KeyAuth, "Unable to delete one-time session %s. Not allowing login: %v", base.UD(session.ID), err)
}
return base.HTTPErrorf(http.StatusUnauthorized, "Session Invalid")
}
return nil
}

// CreateSession creates a new login session for the specified user with the specified TTL. If oneTime is true, the
// session is marked as a one-time session and will be removed with a successful authentication.
func (auth *Authenticator) CreateSession(ctx context.Context, user User, ttl time.Duration, oneTime bool) (*LoginSession, error) {
ttlSec := int(ttl.Seconds())
if ttlSec <= 0 {
return nil, base.HTTPErrorf(400, "Invalid session time-to-live")
Expand All @@ -103,6 +145,10 @@ func (auth *Authenticator) CreateSession(ctx context.Context, user User, ttl tim
Ttl: ttl,
SessionUUID: user.GetSessionUUID(),
}
// only serialize one_time if set
if oneTime {
session.OneTime = &oneTime
}
if err := auth.datastore.Set(auth.DocIDForSession(session.ID), base.DurationToCbsExpiry(ttl), nil, session); err != nil {
return nil, err
}
Expand All @@ -115,24 +161,24 @@ func (auth *Authenticator) CreateSession(ctx context.Context, user User, ttl tim
}

// GetSession returns a session by ID. Return a not found error if the session is not found, or is invalid.
func (auth *Authenticator) GetSession(sessionID string) (*LoginSession, error) {
func (auth *Authenticator) GetSession(sessionID string) (*LoginSession, User, error) {
var session LoginSession
_, err := auth.datastore.Get(auth.DocIDForSession(sessionID), &session)
if err != nil {
return nil, err
return nil, nil, err
}
user, err := auth.GetUser(session.Username)
if err != nil {
return nil, err
return nil, nil, err
}
if user == nil {
return nil, base.ErrNotFound
return nil, nil, base.ErrNotFound
}
if session.SessionUUID != user.GetSessionUUID() {
return nil, base.ErrNotFound
return nil, nil, base.ErrNotFound
}

return &session, nil
return &session, user, nil
}

func (auth *Authenticator) MakeSessionCookie(session *LoginSession, secureCookie bool, httpOnly bool) *http.Cookie {
Expand Down
147 changes: 101 additions & 46 deletions auth/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ licenses/APL2.txt.
package auth

import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
Expand All @@ -23,50 +24,66 @@ import (
)

func TestCreateSession(t *testing.T) {
const username = "Alice"
const invalidSessionTTLError = "400 Invalid session time-to-live"
base.SetUpTestLogging(t, base.LevelDebug, base.KeyAuth)
ctx := base.TestCtx(t)
testBucket := base.GetTestBucket(t)
defer testBucket.Close(ctx)
dataStore := testBucket.GetSingleDataStore()
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))

user, err := auth.NewUser(username, "password", base.Set{})
require.NoError(t, err)
require.NotNil(t, user)
require.NoError(t, auth.Save(user))

// Create session with a username and valid TTL of 2 hours.
session, err := auth.CreateSession(ctx, user, 2*time.Hour)
require.NoError(t, err)

assert.Equal(t, username, session.Username)
assert.Equal(t, 2*time.Hour, session.Ttl)
assert.NotEmpty(t, session.ID)
assert.NotEmpty(t, session.Expiration)

// Once the session is created, the details should be persisted on the bucket
// and it must be accessible anytime later within the session expiration time.
session, err = auth.GetSession(session.ID)
assert.NoError(t, err)
for _, oneTime := range []bool{true, false} {
t.Run(fmt.Sprintf("oneTime=%t", oneTime), func(t *testing.T) {
const username = "Alice"
const invalidSessionTTLError = "400 Invalid session time-to-live"
ctx := base.TestCtx(t)
testBucket := base.GetTestBucket(t)
defer testBucket.Close(ctx)
dataStore := testBucket.GetSingleDataStore()
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))

assert.Equal(t, username, session.Username)
assert.Equal(t, 2*time.Hour, session.Ttl)
assert.NotEmpty(t, session.ID)
assert.NotEmpty(t, session.Expiration)
user, err := auth.NewUser(username, "password", base.Set{})
require.NoError(t, err)
require.NotNil(t, user)
require.NoError(t, auth.Save(user))

// Session must not be created with zero TTL; it's illegal.
session, err = auth.CreateSession(ctx, user, time.Duration(0))
assert.Nil(t, session)
assert.Error(t, err)
assert.Contains(t, err.Error(), invalidSessionTTLError)
// Create session with a username and valid TTL of 2 hours.
session, err := auth.CreateSession(ctx, user, 2*time.Hour, oneTime)
require.NoError(t, err)

// Session must not be created with negative TTL; it's illegal.
session, err = auth.CreateSession(ctx, user, time.Duration(-1))
assert.Nil(t, session)
assert.Error(t, err)
assert.Contains(t, err.Error(), invalidSessionTTLError)
assert.Equal(t, username, session.Username)
assert.Equal(t, 2*time.Hour, session.Ttl)
assert.NotEmpty(t, session.ID)
assert.NotEmpty(t, session.Expiration)
if oneTime {
require.NotNil(t, session.OneTime)
require.True(t, *session.OneTime)
} else {
assert.Empty(t, session.OneTime)
}

// Once the session is created, the details should be persisted on the bucket
// and it must be accessible anytime later within the session expiration time.
session, _, err = auth.GetSession(session.ID)
assert.NoError(t, err)

assert.Equal(t, username, session.Username)
assert.Equal(t, 2*time.Hour, session.Ttl)
assert.NotEmpty(t, session.ID)
assert.NotEmpty(t, session.Expiration)
if oneTime {
require.NotNil(t, session.OneTime)
require.True(t, *session.OneTime)
} else {
assert.Empty(t, session.OneTime)
}

// Session must not be created with zero TTL; it's illegal.
session, err = auth.CreateSession(ctx, user, time.Duration(0), oneTime)
assert.Nil(t, session)
assert.Error(t, err)
assert.Contains(t, err.Error(), invalidSessionTTLError)

// Session must not be created with negative TTL; it's illegal.
session, err = auth.CreateSession(ctx, user, time.Duration(-1), oneTime)
assert.Nil(t, session)
assert.Error(t, err)
assert.Contains(t, err.Error(), invalidSessionTTLError)
})
}
}

func TestDeleteSession(t *testing.T) {
Expand All @@ -91,7 +108,7 @@ func TestDeleteSession(t *testing.T) {
assert.NoError(t, dataStore.Set(auth.DocIDForSession(mockSession.ID), noSessionExpiry, nil, mockSession))
assert.NoError(t, auth.DeleteSession(ctx, mockSession.ID, ""))

session, err := auth.GetSession(mockSession.ID)
session, _, err := auth.GetSession(mockSession.ID)
assert.Nil(t, session)
base.RequireDocNotFoundError(t, err)
}
Expand Down Expand Up @@ -239,11 +256,12 @@ func TestCreateSessionChangePassword(t *testing.T) {
require.NotNil(t, user)
require.NoError(t, auth.Save(user))

oneTime := false
// Create session with a username and valid TTL of 2 hours.
session, err := auth.CreateSession(ctx, user, 2*time.Hour)
session, err := auth.CreateSession(ctx, user, 2*time.Hour, oneTime)
require.NoError(t, err)

session, err = auth.GetSession(session.ID)
session, _, err = auth.GetSession(session.ID)
require.NoError(t, err)

request, err := http.NewRequest(http.MethodGet, "", nil)
Expand Down Expand Up @@ -295,11 +313,11 @@ func TestUserWithoutSessionUUID(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, user)

// Create session with a username and valid TTL of 2 hours.
session, err := auth.CreateSession(ctx, user, 2*time.Hour)
oneTime := false
session, err := auth.CreateSession(ctx, user, 2*time.Hour, oneTime)
require.NoError(t, err)

session, err = auth.GetSession(session.ID)
session, _, err = auth.GetSession(session.ID)
require.NoError(t, err)

request, err := http.NewRequest(http.MethodGet, "", nil)
Expand All @@ -311,3 +329,40 @@ func TestUserWithoutSessionUUID(t *testing.T) {
require.NoError(t, err)

}

func TestCreateOneTimeSession(t *testing.T) {
ctx := base.TestCtx(t)
testBucket := base.GetTestBucket(t)
defer testBucket.Close(ctx)
dataStore := testBucket.GetSingleDataStore()
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
const username = "Alice"
user, err := auth.NewUser(username, "password", base.Set{})
require.NoError(t, err)
require.NoError(t, auth.Save(user))

oneTime := true
session, err := auth.CreateSession(ctx, user, 2*time.Hour, oneTime)
require.NoError(t, err)

session, user, err = auth.GetSession(session.ID)
require.NoError(t, err)
require.Equal(t, username, session.Username)
require.Equal(t, username, user.Name())

// make sure this can be retrieved again if not through AuthenticateOneTimeSession
session, user, err = auth.GetSession(session.ID)
require.NoError(t, err)
require.Equal(t, username, session.Username)
require.Equal(t, username, user.Name())

// now test AuthenticateOneTimeSession deletes it
user, err = auth.AuthenticateOneTimeSession(ctx, session.ID)
require.NoError(t, err)
require.Equal(t, username, user.Name())

// make sure session is deleted
session, _, err = auth.GetSession(session.ID)
require.Nil(t, session)
base.RequireDocNotFoundError(t, err)
}
21 changes: 19 additions & 2 deletions docs/api/paths/public/db-_session.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ post:
Generates a login session for the user based on the credentials provided in the request body or if that fails (due to invalid credentials or none provided at all), generates the new session for the currently authenticated user instead. On a successful session creation, a session cookie is stored to keep the user authenticated for future API calls.

If `Origin` header is passed to this endpoint, the `Origin` header must match both the `cors.login_origin` and `cors.origin` configuration options.
parameters:
- name: one_time
description: Sets the session to only be valid for a single authentication. This session will expire in 5 minutes if not used.
in: query
schema:
type: boolean
requestBody:
description: When name and password are included in the request body, the session will be created for the specified user. Otherwise the session will be created for the authenticated user making the request.
required: false
Expand All @@ -40,8 +46,19 @@ post:
description: Password of the user to generate the session for. Omit this value to generate a session for the authenticated user.
type: string
responses:
"200":
$ref: ../../components/responses.yaml#/User-session-information
'200':
description: Session created successfully. Returned body is dependant on if using Public or Admin APIs
content:
application/json:
schema:
allOf:
- $ref: ../../components/schemas.yaml#/User-session-information
- type: object
properties:
one_time_session_id:
description: The id of a single use session if `one_time=true` query parameter was used.
type: string
example: c5af80a039db4ed9d2b6865576b6999935282689
"400":
$ref: ../../components/responses.yaml#/Invalid-CORS-LoginOrigin
"401":
Expand Down
5 changes: 5 additions & 0 deletions rest/blip_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import (
"github.com/couchbase/sync_gateway/base"
)

const (
secWebSocketProtocolHeader = "Sec-WebSocket-Protocol"
blipSessionIDPrefix = "SyncGatewaySession_"
)

// HTTP handler for incoming BLIP sync WebSocket request (/db/_blipsync)
func (h *handler) handleBLIPSync() error {
needRelease, err := h.server.incrementConcurrentReplications(h.rqCtx)
Expand Down
Loading
Loading