diff --git a/auth/session.go b/auth/session.go index 21ec836e2c..05232f3b76 100644 --- a/auth/session.go +++ b/auth/session.go @@ -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" @@ -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. +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") @@ -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 } @@ -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 { diff --git a/auth/session_test.go b/auth/session_test.go index fc69da4d34..aae456157a 100644 --- a/auth/session_test.go +++ b/auth/session_test.go @@ -11,6 +11,7 @@ licenses/APL2.txt. package auth import ( + "fmt" "net/http" "net/http/httptest" "strings" @@ -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) { @@ -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) } @@ -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) @@ -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) @@ -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) +} diff --git a/docs/api/paths/public/db-_session.yaml b/docs/api/paths/public/db-_session.yaml index e0a839d72a..de5437500c 100644 --- a/docs/api/paths/public/db-_session.yaml +++ b/docs/api/paths/public/db-_session.yaml @@ -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 @@ -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": diff --git a/rest/blip_sync.go b/rest/blip_sync.go index 8b58f5cd16..af70226200 100644 --- a/rest/blip_sync.go +++ b/rest/blip_sync.go @@ -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) diff --git a/rest/blip_sync_test.go b/rest/blip_sync_test.go index d85c4cb46b..491799c6a5 100644 --- a/rest/blip_sync_test.go +++ b/rest/blip_sync_test.go @@ -10,9 +10,12 @@ package rest import ( "fmt" + "net/http" "testing" + "github.com/couchbase/sync_gateway/base" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHostOnlyCORS(t *testing.T) { @@ -70,3 +73,50 @@ func TestHostOnlyCORS(t *testing.T) { }) } } + +func TestOneTimeSessionBlipSyncAuthentication(t *testing.T) { + rt := NewRestTesterPersistentConfig(t) + defer rt.Close() + + const username = "alice" + rt.CreateUser(username, []string{"*"}) + + resp := rt.SendUserRequest(http.MethodPost, "/{{.db}}/_session?one_time=true", "", username) + RequireStatus(t, resp, http.StatusOK) + + var sessionResp struct { + SessionID string `json:"one_time_session_id"` + } + + require.NoError(t, base.JSONUnmarshal(resp.BodyBytes(), &sessionResp)) + + require.NotEmpty(t, sessionResp.SessionID, "Expected non-empty session ID for %s", resp.BodyString()) + + resp = rt.SendRequestWithHeaders(http.MethodGet, "/{{.db}}/_blipsync", "", nil) + RequireStatus(t, resp, http.StatusUnauthorized) + + resp = rt.SendUserRequest(http.MethodGet, "/{{.db}}/_blipsync", "", username) + RequireStatus(t, resp, http.StatusUpgradeRequired) + + // no header should show database not found + resp = rt.SendRequestWithHeaders(http.MethodGet, "/{{.db}}/_blipsync", "", nil) + RequireStatus(t, resp, http.StatusUnauthorized) + + // invalid token is header not known + resp = rt.SendRequestWithHeaders(http.MethodGet, "/{{.db}}/_blipsync", "", map[string]string{ + secWebSocketProtocolHeader: blipSessionIDPrefix + "badtoken", + }) + RequireStatus(t, resp, http.StatusUnauthorized) + + // first request will succeed + resp = rt.SendRequestWithHeaders(http.MethodGet, "/{{.db}}/_blipsync", "", map[string]string{ + secWebSocketProtocolHeader: blipSessionIDPrefix + sessionResp.SessionID, + }) + RequireStatus(t, resp, http.StatusUpgradeRequired) + + // one time token is expired + resp = rt.SendRequestWithHeaders(http.MethodGet, "/{{.db}}/_blipsync", "", map[string]string{ + secWebSocketProtocolHeader: blipSessionIDPrefix + sessionResp.SessionID, + }) + RequireStatus(t, resp, http.StatusUnauthorized) +} diff --git a/rest/handler.go b/rest/handler.go index a57be00053..9a9b993c0f 100644 --- a/rest/handler.go +++ b/rest/handler.go @@ -992,6 +992,17 @@ func (h *handler) checkPublicAuth(dbCtx *db.DatabaseContext) (err error) { } } + // check smuggled websocket token before cookie, since this is sure to be more explicit than cookie + if h.isBlipSync() { + sessionID := h.getWebsocketToken() + if sessionID != "" { + var err error + auditFields = base.AuditFields{base.AuditFieldAuthMethod: "websocket_token"} + h.user, err = dbCtx.Authenticator(h.ctx()).AuthenticateOneTimeSession(h.ctx(), sessionID) + return err + } + } + // Check cookie auditFields = base.AuditFields{base.AuditFieldAuthMethod: "cookie"} h.user, err = dbCtx.Authenticator(h.ctx()).AuthenticateCookie(h.rq, h.response) @@ -1019,6 +1030,30 @@ func (h *handler) checkPublicAuth(dbCtx *db.DatabaseContext) (err error) { return nil } +// verifyWebsocketToken checks for a valid websocket token in the request headers. If present, invalidate the token so it can't be reused. +// +// Returns a nil error if there is no token on the header but no user object. If the header is found, +func (h *handler) getWebsocketToken() string { + // go blip expects only one Sec-WebSocket-Protocol header, with comma separated values + protocolHeaders := h.rq.Header.Get(secWebSocketProtocolHeader) + var outputHeaders []string + var sessionID string + for _, header := range strings.Split(protocolHeaders, ",") { + trimmedHeader := strings.TrimSpace(header) + if !strings.HasPrefix(trimmedHeader, blipSessionIDPrefix) { + outputHeaders = append(outputHeaders, header) + continue + } + sessionID = strings.TrimPrefix(trimmedHeader, blipSessionIDPrefix) + } + if sessionID == "" { + return "" + } + // Remove the websocket protocol so it doesn't get logged in BlipWebsocketServer.handshake + h.rq.Header.Set(secWebSocketProtocolHeader, strings.Join(outputHeaders, ",")) + return sessionID +} + func checkJWTIssuerStillValid(ctx context.Context, dbCtx *db.DatabaseContext, user auth.User) *auth.PrincipalConfig { issuer := user.JWTIssuer() if issuer == "" { diff --git a/rest/oidc_api.go b/rest/oidc_api.go index f588abbef4..d4e032bf0c 100644 --- a/rest/oidc_api.go +++ b/rest/oidc_api.go @@ -279,7 +279,8 @@ func (h *handler) createSessionForTrustedIdToken(rawIDToken string, provider *au if !provider.DisableSession { sessionTTL := tokenExpiryTime.Sub(time.Now()) - sessionID, err := h.makeSessionWithTTL(user, sessionTTL) + oneTime := false + sessionID, err := h.makeSessionWithTTL(user, sessionTTL, oneTime) return user.Name(), sessionID, err } return user.Name(), "", nil diff --git a/rest/session_api.go b/rest/session_api.go index 68b43d2a2f..e30f15de5a 100644 --- a/rest/session_api.go +++ b/rest/session_api.go @@ -18,12 +18,18 @@ import ( "github.com/couchbase/sync_gateway/db" ) -const kDefaultSessionTTL = 24 * time.Hour +const ( + // defaultSessionTTL is the default time-to-live for login sessions + defaultSessionTTL = 24 * time.Hour + // oneTimeSessionTTL is the time-to-live for one-time login sessions, long enough to complete the authentication + // flow, but disappear if they are unused + oneTimeSessionTTL = 5 * time.Minute +) // Respond with a JSON struct containing info about the current login session func (h *handler) respondWithSessionInfo() error { - response := h.formatSessionResponse(h.user) + response := h.formatSessionResponse(h.user, "") h.writeJSON(response) return nil @@ -48,6 +54,8 @@ func (h *handler) handleSessionPOST() error { } } + oneTime := h.getBoolQuery("one_time") + // NOTE: handleSessionPOST doesn't handle creating users from OIDC - checkPublicAuth calls out into AuthenticateUntrustedJWT. // Therefore, if by this point `h.user` is guest, this isn't creating a session from OIDC. if h.db.Options.DisablePasswordAuthentication && (h.user == nil || h.user.Name() == "") { @@ -55,16 +63,32 @@ func (h *handler) handleSessionPOST() error { } user, err := h.getUserFromSessionRequestBody() + ttl := defaultSessionTTL + if oneTime { + ttl = oneTimeSessionTTL + } + var sessionID string // If we fail to get a user from the body and we've got a non-GUEST authenticated user, create the session based on that user if user == nil && h.user != nil && h.user.Name() != "" { - return h.makeSession(h.user) + sessionID, err = h.makeSessionWithTTL(h.user, ttl, oneTime) + if err != nil { + return err + } + user = h.user + } else if err != nil { + return err } else { + sessionID, err = h.makeSessionWithTTL(user, ttl, oneTime) if err != nil { return err } - return h.makeSession(user) } - + if oneTime { + h.writeJSON(h.formatSessionResponse(user, sessionID)) + } else { + h.writeJSON(h.formatSessionResponse(user, "")) + } + return nil } func (h *handler) getUserFromSessionRequestBody() (auth.User, error) { @@ -120,9 +144,10 @@ func (h *handler) handleSessionDELETE() error { return nil } +// makeSession creates a user session and responds with CouchDB compatible session info. func (h *handler) makeSession(user auth.User) error { - - _, err := h.makeSessionWithTTL(user, kDefaultSessionTTL) + oneTime := false + _, err := h.makeSessionWithTTL(user, defaultSessionTTL, oneTime) if err != nil { return err } @@ -130,13 +155,13 @@ func (h *handler) makeSession(user auth.User) error { } // Creates a session with TTL and adds to the response. Does NOT return the session info response. -func (h *handler) makeSessionWithTTL(user auth.User, expiry time.Duration) (sessionID string, err error) { +func (h *handler) makeSessionWithTTL(user auth.User, expiry time.Duration, oneTime bool) (sessionID string, err error) { if user == nil { return "", ErrInvalidLogin } h.user = user auth := h.db.Authenticator(h.ctx()) - session, err := auth.CreateSession(h.ctx(), h.user, expiry) + session, err := auth.CreateSession(h.ctx(), h.user, expiry, oneTime) if err != nil { return "", err } @@ -201,7 +226,7 @@ func (h *handler) createUserSession() error { Name string `json:"name"` TTL int `json:"ttl"` } - params.TTL = int(kDefaultSessionTTL / time.Second) + params.TTL = int(defaultSessionTTL / time.Second) err := h.readJSONInto(¶ms) if err != nil { return err @@ -222,7 +247,8 @@ func (h *handler) createUserSession() error { return base.HTTPErrorf(http.StatusBadRequest, "Invalid or missing ttl") } - session, err := authenticator.CreateSession(h.ctx(), user, ttl) + oneTime := false + session, err := authenticator.CreateSession(h.ctx(), user, ttl, oneTime) if err != nil { return err } @@ -241,7 +267,7 @@ func (h *handler) createUserSession() error { func (h *handler) getUserSession() error { h.assertAdminOnly() - session, err := h.db.Authenticator(h.ctx()).GetSession(h.PathVar("sessionid")) + session, _, err := h.db.Authenticator(h.ctx()).GetSession(h.PathVar("sessionid")) if err != nil { return err } @@ -286,7 +312,7 @@ func (h *handler) deleteUserSessionWithValidation(sessionId string, userName str // Validate that the session being deleted belongs to the user. This adds some // overhead - for user-agnostic session deletion should use deleteSession - session, getErr := h.db.Authenticator(h.ctx()).GetSession(sessionId) + session, _, getErr := h.db.Authenticator(h.ctx()).GetSession(sessionId) if getErr != nil { return getErr } @@ -312,7 +338,7 @@ func (h *handler) respondWithSessionInfoForSession(session *auth.LoginSession) e return err } - response := h.formatSessionResponse(user) + response := h.formatSessionResponse(user, "") if response != nil { h.writeJSON(response) } @@ -320,7 +346,7 @@ func (h *handler) respondWithSessionInfoForSession(session *auth.LoginSession) e } // Formats session response similar to what is returned by CouchDB -func (h *handler) formatSessionResponse(user auth.User) db.Body { +func (h *handler) formatSessionResponse(user auth.User, oneTimeSessionID string) db.Body { var name *string allChannels := channels.TimedSet{} @@ -337,6 +363,9 @@ func (h *handler) formatSessionResponse(user auth.User) db.Body { userCtx := db.Body{"name": name, "channels": allChannels} handlers := []string{"default", "cookie"} response := db.Body{"ok": true, "userCtx": userCtx, "authentication_handlers": handlers} + if oneTimeSessionID != "" { + response["one_time_session_id"] = oneTimeSessionID + } return response } diff --git a/rest/session_test.go b/rest/session_test.go index f172452365..5300ac884a 100644 --- a/rest/session_test.go +++ b/rest/session_test.go @@ -726,3 +726,25 @@ func TestSessionExpirationDateTimeFormat(t *testing.T) { assert.NoError(t, err, "Couldn't parse session expiration datetime") assert.True(t, expires.Sub(time.Now()).Hours() <= 24, "Couldn't validate session expiration") } + +func TestOneTimeSessionWithCookie(t *testing.T) { + rt := NewRestTesterPersistentConfig(t) + defer rt.Close() + + username := "alice" + rt.CreateUser(username, []string{"*"}) + + resp := rt.SendUserRequest(http.MethodPost, "/{{.db}}/_session?one_time=true", "", username) + RequireStatus(t, resp, http.StatusOK) + + headers := map[string]string{ + "Cookie": resp.Header().Get("Set-Cookie"), + } + + resp = rt.SendRequestWithHeaders(http.MethodGet, "/{{.db}}/", "", headers) + RequireStatus(t, resp, http.StatusOK) + + // Second request should fail since it's a one-time session + resp = rt.SendRequestWithHeaders(http.MethodGet, "/{{.db}}/", "", headers) + RequireStatus(t, resp, http.StatusUnauthorized) +}