From df075408fbbde2179fd449d98841b4329b3798f3 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Mon, 3 Jul 2023 09:52:55 +0200 Subject: [PATCH] feat: add different logout scopes (#1112) Right now, probably due to a bug, `POST /logout` would log the user out from _all_ sessions they have. This is not always desired behavior. This change adds a new `scope` query param on `/logout` with these values: - `global` (default when not provided) Logs a user out from all sessions they have. - `local` Logs a user out from the current session only. - `others` Logs a user out from all other sessions except the current one. See: - https://github.com/supabase/gotrue-js/pull/713 --- internal/api/logout.go | 46 +++++++++++++++++++++++++++++++++---- internal/api/logout_test.go | 34 +++++++++++++++++++-------- openapi.yaml | 11 +++++++++ 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/internal/api/logout.go b/internal/api/logout.go index 5c8931402..a2d2c41ed 100644 --- a/internal/api/logout.go +++ b/internal/api/logout.go @@ -1,19 +1,44 @@ package api import ( + "fmt" "net/http" "github.com/supabase/gotrue/internal/models" "github.com/supabase/gotrue/internal/storage" ) +type LogoutBehavior string + +const ( + LogoutGlobal LogoutBehavior = "global" + LogoutLocal LogoutBehavior = "local" + LogoutOthers LogoutBehavior = "others" +) + // Logout is the endpoint for logging out a user and thereby revoking any refresh tokens func (a *API) Logout(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) config := a.config - a.clearCookieTokens(config, w) + scope := LogoutGlobal + + if r.URL.Query() != nil { + switch r.URL.Query().Get("scope") { + case "", "global": + scope = LogoutGlobal + + case "local": + scope = LogoutLocal + + case "others": + scope = LogoutOthers + + default: + return badRequestError(fmt.Sprintf("Unsupported logout scope %q", r.URL.Query().Get("scope"))) + } + } s := getSession(ctx) u := getUser(ctx) @@ -22,15 +47,28 @@ func (a *API) Logout(w http.ResponseWriter, r *http.Request) error { if terr := models.NewAuditLogEntry(r, tx, u, models.LogoutAction, "", nil); terr != nil { return terr } - if s != nil { - return models.Logout(tx, u.ID) + + if s == nil { + return models.LogoutAllRefreshTokens(tx, u.ID) + } + + switch scope { + case LogoutLocal: + return models.LogoutSession(tx, s.ID) + + case LogoutOthers: + return models.LogoutAllExceptMe(tx, s.ID, u.ID) } - return models.LogoutAllRefreshTokens(tx, u.ID) + + // default mode, log out everywhere + return models.Logout(tx, u.ID) }) if err != nil { return internalServerError("Error logging out user").WithInternalError(err) } + a.clearCookieTokens(config, w) w.WriteHeader(http.StatusNoContent) + return nil } diff --git a/internal/api/logout_test.go b/internal/api/logout_test.go index 46d250062..c873532e5 100644 --- a/internal/api/logout_test.go +++ b/internal/api/logout_test.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/require" @@ -47,18 +48,31 @@ func (ts *LogoutTestSuite) SetupTest() { } func (ts *LogoutTestSuite) TestLogoutSuccess() { - req := httptest.NewRequest(http.MethodPost, "http://localhost/logout", nil) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token)) - w := httptest.NewRecorder() + for _, scope := range []string{"", "global", "local", "others"} { + ts.SetupTest() - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusNoContent, w.Code) + reqURL, err := url.ParseRequestURI("http://localhost/logout") + require.NoError(ts.T(), err) - accessTokenKey := fmt.Sprintf("%v-access-token", ts.Config.Cookie.Key) - refreshTokenKey := fmt.Sprintf("%v-refresh-token", ts.Config.Cookie.Key) - for _, c := range w.Result().Cookies() { - if c.Name == accessTokenKey || c.Name == refreshTokenKey { - require.Equal(ts.T(), "", c.Value) + if scope != "" { + query := reqURL.Query() + query.Set("scope", scope) + reqURL.RawQuery = query.Encode() + } + + req := httptest.NewRequest(http.MethodPost, reqURL.String(), nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token)) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + + accessTokenKey := fmt.Sprintf("%v-access-token", ts.Config.Cookie.Key) + refreshTokenKey := fmt.Sprintf("%v-refresh-token", ts.Config.Cookie.Key) + for _, c := range w.Result().Cookies() { + if c.Name == accessTokenKey || c.Name == refreshTokenKey { + require.Equal(ts.T(), "", c.Value) + } } } } diff --git a/openapi.yaml b/openapi.yaml index 8d7acaf00..00294f9e2 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -158,6 +158,17 @@ paths: security: - APIKeyAuth: [] UserAuth: [] + parameters: + - name: scope + in: query + description: > + (Optional.) Determines how the user should be logged out. When `global` is used, the user is logged out from all active sessions. When `local` is used, the user is logged out from the current session. When `others` is used, the user is logged out from all other sessions except the current one. Clients should remove stored access and refresh tokens except when `others` is used. + schema: + type: string + enum: + - global + - local + - others responses: 204: description: No content returned on successful logout.