Skip to content

Commit c00becf

Browse files
authored
feat: add different logout scopes (supabase#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: - supabase/auth-js#713
1 parent 5d208c5 commit c00becf

File tree

3 files changed

+77
-14
lines changed

3 files changed

+77
-14
lines changed

internal/api/logout.go

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,44 @@
11
package api
22

33
import (
4+
"fmt"
45
"net/http"
56

67
"github.com/supabase/gotrue/internal/models"
78
"github.com/supabase/gotrue/internal/storage"
89
)
910

11+
type LogoutBehavior string
12+
13+
const (
14+
LogoutGlobal LogoutBehavior = "global"
15+
LogoutLocal LogoutBehavior = "local"
16+
LogoutOthers LogoutBehavior = "others"
17+
)
18+
1019
// Logout is the endpoint for logging out a user and thereby revoking any refresh tokens
1120
func (a *API) Logout(w http.ResponseWriter, r *http.Request) error {
1221
ctx := r.Context()
1322
db := a.db.WithContext(ctx)
1423
config := a.config
1524

16-
a.clearCookieTokens(config, w)
25+
scope := LogoutGlobal
26+
27+
if r.URL.Query() != nil {
28+
switch r.URL.Query().Get("scope") {
29+
case "", "global":
30+
scope = LogoutGlobal
31+
32+
case "local":
33+
scope = LogoutLocal
34+
35+
case "others":
36+
scope = LogoutOthers
37+
38+
default:
39+
return badRequestError(fmt.Sprintf("Unsupported logout scope %q", r.URL.Query().Get("scope")))
40+
}
41+
}
1742

1843
s := getSession(ctx)
1944
u := getUser(ctx)
@@ -22,15 +47,28 @@ func (a *API) Logout(w http.ResponseWriter, r *http.Request) error {
2247
if terr := models.NewAuditLogEntry(r, tx, u, models.LogoutAction, "", nil); terr != nil {
2348
return terr
2449
}
25-
if s != nil {
26-
return models.Logout(tx, u.ID)
50+
51+
if s == nil {
52+
return models.LogoutAllRefreshTokens(tx, u.ID)
53+
}
54+
55+
switch scope {
56+
case LogoutLocal:
57+
return models.LogoutSession(tx, s.ID)
58+
59+
case LogoutOthers:
60+
return models.LogoutAllExceptMe(tx, s.ID, u.ID)
2761
}
28-
return models.LogoutAllRefreshTokens(tx, u.ID)
62+
63+
// default mode, log out everywhere
64+
return models.Logout(tx, u.ID)
2965
})
3066
if err != nil {
3167
return internalServerError("Error logging out user").WithInternalError(err)
3268
}
3369

70+
a.clearCookieTokens(config, w)
3471
w.WriteHeader(http.StatusNoContent)
72+
3573
return nil
3674
}

internal/api/logout_test.go

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"net/http"
66
"net/http/httptest"
7+
"net/url"
78
"testing"
89

910
"github.com/stretchr/testify/require"
@@ -47,18 +48,31 @@ func (ts *LogoutTestSuite) SetupTest() {
4748
}
4849

4950
func (ts *LogoutTestSuite) TestLogoutSuccess() {
50-
req := httptest.NewRequest(http.MethodPost, "http://localhost/logout", nil)
51-
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
52-
w := httptest.NewRecorder()
51+
for _, scope := range []string{"", "global", "local", "others"} {
52+
ts.SetupTest()
5353

54-
ts.API.handler.ServeHTTP(w, req)
55-
require.Equal(ts.T(), http.StatusNoContent, w.Code)
54+
reqURL, err := url.ParseRequestURI("http://localhost/logout")
55+
require.NoError(ts.T(), err)
5656

57-
accessTokenKey := fmt.Sprintf("%v-access-token", ts.Config.Cookie.Key)
58-
refreshTokenKey := fmt.Sprintf("%v-refresh-token", ts.Config.Cookie.Key)
59-
for _, c := range w.Result().Cookies() {
60-
if c.Name == accessTokenKey || c.Name == refreshTokenKey {
61-
require.Equal(ts.T(), "", c.Value)
57+
if scope != "" {
58+
query := reqURL.Query()
59+
query.Set("scope", scope)
60+
reqURL.RawQuery = query.Encode()
61+
}
62+
63+
req := httptest.NewRequest(http.MethodPost, reqURL.String(), nil)
64+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
65+
w := httptest.NewRecorder()
66+
67+
ts.API.handler.ServeHTTP(w, req)
68+
require.Equal(ts.T(), http.StatusNoContent, w.Code)
69+
70+
accessTokenKey := fmt.Sprintf("%v-access-token", ts.Config.Cookie.Key)
71+
refreshTokenKey := fmt.Sprintf("%v-refresh-token", ts.Config.Cookie.Key)
72+
for _, c := range w.Result().Cookies() {
73+
if c.Name == accessTokenKey || c.Name == refreshTokenKey {
74+
require.Equal(ts.T(), "", c.Value)
75+
}
6276
}
6377
}
6478
}

openapi.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,17 @@ paths:
158158
security:
159159
- APIKeyAuth: []
160160
UserAuth: []
161+
parameters:
162+
- name: scope
163+
in: query
164+
description: >
165+
(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.
166+
schema:
167+
type: string
168+
enum:
169+
- global
170+
- local
171+
- others
161172
responses:
162173
204:
163174
description: No content returned on successful logout.

0 commit comments

Comments
 (0)