Skip to content

Commit

Permalink
feat: add different logout scopes (#1112)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
hf committed Jul 3, 2023
1 parent 58552d6 commit df07540
Show file tree
Hide file tree
Showing 3 changed files with 77 additions and 14 deletions.
46 changes: 42 additions & 4 deletions internal/api/logout.go
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
}
34 changes: 24 additions & 10 deletions internal/api/logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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)
}
}
}
}
11 changes: 11 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit df07540

Please sign in to comment.