Skip to content

Commit 2029c42

Browse files
Merge pull request #409 from supertokens/cookie-domain-fix
Cookie domain fix
2 parents 0ca19ca + 5adb33b commit 2029c42

12 files changed

+723
-56
lines changed

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [unreleased]
99

10+
## [0.19.0] - 2024-05-01
11+
12+
- Added `OlderCookieDomain` config option in the session recipe. This will allow users to clear cookies from the older domain when the `CookieDomain` is changed.
13+
- If `VerifySession` detects multiple access tokens in the request, it will return a 401 error, prompting a refresh, even if one of the tokens is valid.
14+
- `RefreshPOST` (`/auth/session/refresh` by default) API changes:
15+
- now returns 500 error if multiple access tokens are present in the request and `config.OlderCookieDomain` is not set.
16+
- now clears the access token cookie if it was called without a refresh token (if an access token cookie exists and if using cookie-based sessions).
17+
- now clears cookies from the old domain if `OlderCookieDomain` is specified and multiple refresh/access token cookies exist, without updating the front-token or any of the tokens.
18+
- now a 200 response may not include new session tokens.
19+
- Fixed a bug in the `normaliseSessionScopeOrThrowError` util function that caused it to remove leading dots from the scope string.
20+
21+
### Rationale
22+
23+
This update addresses an edge case where changing the `CookieDomain` config on the server can lead to session integrity issues. For instance, if the API server URL is 'api.example.com' with a cookie domain of '.example.com', and the server updates the cookie domain to 'api.example.com', the client may retain cookies with both '.example.com' and 'api.example.com' domains, resulting in multiple sets of session token cookies existing.
24+
25+
Previously, verifySession would select one of the access tokens from the incoming request. If it chose the older cookie, it would return a 401 status code, prompting a refresh request. However, the `RefreshPOST` API would then set new session token cookies with the updated `CookieDomain`, but older cookies will persist, leading to repeated 401 errors and refresh loops.
26+
27+
With this update, verifySession will return a 401 error if it detects multiple access tokens in the request, prompting a refresh request. The `RefreshPOST` API will clear cookies from the old domain if `OlderCookieDomain` is specified in the configuration, then return a 200 status. If `OlderCookieDomain` is not configured, the `RefreshPOST` API will return a 500 error with a message instructing to set `OlderCookieDomain`.
28+
29+
30+
**Example:**
31+
32+
- `APIDomain`: 'api.example.com'
33+
- `CookieDomain`: 'api.example.com'
34+
35+
**Flow:**
36+
37+
1. After authentication, the frontend has cookies set with `domain=api.example.com`, but the access token has expired.
38+
2. The server updates `CookieDomain` to `.example.com`.
39+
3. An API call requiring session with an expired access token (cookie with `domain=api.example.com`) results in a 401 response.
40+
4. The frontend attempts to refresh the session, generating a new access token saved with `domain=.example.com`.
41+
5. The original API call is retried, but because it sends both the old and new cookies, it again results in a 401 response.
42+
6. The frontend tries to refresh the session with multiple access tokens:
43+
- If `OlderCookieDomain` is not set, the refresh fails with a 500 error.
44+
- The user remains stuck until they clear cookies manually or `OlderCookieDomain` is set.
45+
- If `OlderCookieDomain` is set, the refresh clears the older cookie, returning a 200 response.
46+
- The frontend retries the original API call, sending only the new cookie (`domain=.example.com`), resulting in a successful request.
47+
1048
## [0.18.0] - 2024-04-30
1149

1250
### Changes

recipe/session/cookieAndHeaders.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import (
2626
"strings"
2727
"time"
2828

29+
sessionError "github.com/supertokens/supertokens-golang/recipe/session/errors"
30+
2931
"github.com/supertokens/supertokens-golang/supertokens"
3032

3133
"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
@@ -316,3 +318,68 @@ func getCookieName(cookie string) string {
316318
}
317319
return kv[0]
318320
}
321+
322+
// ClearSessionCookiesFromOlderCookieDomain addresses an edge case where changing the cookieDomain config on the server can
323+
// lead to session integrity issues. For instance, if the API server URL is 'api.example.com'
324+
// with a cookie domain of '.example.com', and the server updates the cookie domain to 'api.example.com',
325+
// the client may retain cookies with both '.example.com' and 'api.example.com' domains.
326+
//
327+
// Consequently, if the server chooses the older cookie, session invalidation occurs, potentially
328+
// resulting in an infinite refresh loop. To fix this, users are asked to specify "OlderCookieDomain" in
329+
// the config.
330+
//
331+
// This function checks for multiple cookies with the same name and clears the cookies for the older domain.
332+
func ClearSessionCookiesFromOlderCookieDomain(req *http.Request, res http.ResponseWriter, config sessmodels.TypeNormalisedInput, userContext supertokens.UserContext) error {
333+
allowedTransferMethod := config.GetTokenTransferMethod(req, false, userContext)
334+
335+
// If the transfer method is 'header', there's no need to clear cookies immediately, even if there are multiple in the request.
336+
if allowedTransferMethod == sessmodels.HeaderTransferMethod {
337+
return nil
338+
}
339+
340+
didClearCookies := false
341+
342+
tokenTypes := []sessmodels.TokenType{sessmodels.AccessToken, sessmodels.RefreshToken}
343+
for _, token := range tokenTypes {
344+
if hasMultipleCookiesForTokenType(req, token) {
345+
// If a request has multiple session cookies and 'olderCookieDomain' is
346+
// unset, we can't identify the correct cookie for refreshing the session.
347+
// Using the wrong cookie can cause an infinite refresh loop. To avoid this,
348+
// we throw a 500 error asking the user to set 'olderCookieDomain'.
349+
if config.OlderCookieDomain == nil {
350+
return errors.New(`The request contains multiple session cookies. This may happen if you've changed the 'cookieDomain' value in your configuration. To clear tokens from the previous domain, set 'olderCookieDomain' in your config.`)
351+
}
352+
353+
supertokens.LogDebugMessage(fmt.Sprint("ClearSessionCookiesFromOlderCookieDomain: Clearing duplicate ", token, " cookie with domain ", config.OlderCookieDomain))
354+
config.CookieDomain = config.OlderCookieDomain
355+
setToken(config, res, token, "", 0, sessmodels.CookieTransferMethod, req, userContext)
356+
357+
didClearCookies = true
358+
}
359+
}
360+
361+
if didClearCookies {
362+
return sessionError.ClearDuplicateSessionCookiesError{
363+
Msg: "The request contains multiple session cookies. We are clearing the cookie from OlderCookieDomain. Session will be refreshed in the next refresh call.",
364+
}
365+
}
366+
367+
return nil
368+
}
369+
370+
func hasMultipleCookiesForTokenType(req *http.Request, tokenType sessmodels.TokenType) bool {
371+
// Count of cookies with the specified token type
372+
count := 0
373+
374+
// Loop through each cookie in the request
375+
for _, cookie := range req.Cookies() {
376+
// Check if the cookie's name matches the token type
377+
cookieName, _ := getCookieNameFromTokenType(tokenType)
378+
if cookie.Name == cookieName {
379+
count++
380+
}
381+
}
382+
383+
// If count is greater than 1, then there are multiple cookies with the given token type
384+
return count > 1
385+
}

recipe/session/errors/errors.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ package errors
1818
import "github.com/supertokens/supertokens-golang/recipe/session/claims"
1919

2020
const (
21-
UnauthorizedErrorStr = "UNAUTHORISED"
22-
TryRefreshTokenErrorStr = "TRY_REFRESH_TOKEN"
23-
TokenTheftDetectedErrorStr = "TOKEN_THEFT_DETECTED"
24-
InvalidClaimsErrorStr = "INVALID_CLAIMS"
21+
UnauthorizedErrorStr = "UNAUTHORISED"
22+
TryRefreshTokenErrorStr = "TRY_REFRESH_TOKEN"
23+
TokenTheftDetectedErrorStr = "TOKEN_THEFT_DETECTED"
24+
InvalidClaimsErrorStr = "INVALID_CLAIMS"
25+
ClearDuplicateSessionCookiesErrorStr = "CLEAR_DUPLICATE_SESSION_COOKIES"
2526
)
2627

2728
// TryRefreshTokenError used for when the refresh API needs to be called
@@ -66,3 +67,11 @@ type InvalidClaimError struct {
6667
func (err InvalidClaimError) Error() string {
6768
return err.Msg
6869
}
70+
71+
type ClearDuplicateSessionCookiesError struct {
72+
Msg string
73+
}
74+
75+
func (err ClearDuplicateSessionCookiesError) Error() string {
76+
return err.Msg
77+
}

recipe/session/recipe.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,13 @@ func (r *Recipe) handleError(err error, req *http.Request, res http.ResponseWrit
204204
supertokens.LogDebugMessage("errorHandler: returning INVALID_CLAIMS")
205205
errs := err.(errors.InvalidClaimError)
206206
return true, r.Config.ErrorHandlers.OnInvalidClaim(errs.InvalidClaims, req, res)
207+
} else if defaultErrors.As(err, &errors.ClearDuplicateSessionCookiesError{}) {
208+
supertokens.LogDebugMessage("errorHandler: returning CLEAR_DUPLICATE_SESSION_COOKIES")
209+
// This error occurs in the `refreshPOST` API when multiple session
210+
// cookies are found in the request and the user has set `olderCookieDomain`.
211+
// We remove session cookies from the olderCookieDomain. The response must return `200 OK`
212+
// to avoid logging out the user, allowing the session to continue with the valid cookie.
213+
return true, r.Config.ErrorHandlers.OnClearDuplicateSessionCookies(err.Error(), req, res)
207214
} else {
208215
return r.OpenIdRecipe.RecipeModule.HandleError(err, req, res, userContext)
209216
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved.
3+
*
4+
* This software is licensed under the Apache License, Version 2.0 (the
5+
* "License") as published by the Apache Software Foundation.
6+
*
7+
* You may not use this file except in compliance with the License. You may
8+
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package session
18+
19+
import (
20+
"io"
21+
"net/http"
22+
"net/http/httptest"
23+
"testing"
24+
25+
"github.com/supertokens/supertokens-golang/recipe/session/claims"
26+
sessionErrors "github.com/supertokens/supertokens-golang/recipe/session/errors"
27+
"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
28+
"github.com/supertokens/supertokens-golang/supertokens"
29+
"github.com/supertokens/supertokens-golang/test/unittesting"
30+
31+
"github.com/stretchr/testify/assert"
32+
)
33+
34+
func TestSessionErrorHandlerOverides(t *testing.T) {
35+
BeforeEach()
36+
37+
customAntiCsrfVal := "VIA_TOKEN"
38+
configValue := supertokens.TypeInput{
39+
Supertokens: &supertokens.ConnectionInfo{
40+
ConnectionURI: "http://localhost:8080",
41+
},
42+
AppInfo: supertokens.AppInfo{
43+
AppName: "SuperTokens",
44+
WebsiteDomain: "supertokens.io",
45+
APIDomain: "api.supertokens.io",
46+
},
47+
RecipeList: []supertokens.Recipe{
48+
Init(&sessmodels.TypeInput{
49+
AntiCsrf: &customAntiCsrfVal,
50+
ErrorHandlers: &sessmodels.ErrorHandlers{
51+
OnUnauthorised: func(message string, req *http.Request, res http.ResponseWriter) error {
52+
res.WriteHeader(401)
53+
res.Write([]byte("unauthorised from errorHandler"))
54+
return nil
55+
},
56+
OnTokenTheftDetected: func(sessionHandle, userID string, req *http.Request, res http.ResponseWriter) error {
57+
res.WriteHeader(403)
58+
res.Write([]byte("token theft detected from errorHandler"))
59+
return nil
60+
},
61+
OnTryRefreshToken: func(message string, req *http.Request, res http.ResponseWriter) error {
62+
res.WriteHeader(401)
63+
res.Write([]byte("try refresh token from errorHandler"))
64+
return nil
65+
},
66+
OnInvalidClaim: func(validationErrors []claims.ClaimValidationError, req *http.Request, res http.ResponseWriter) error {
67+
res.WriteHeader(403)
68+
res.Write([]byte("invalid claim from errorHandler"))
69+
return nil
70+
},
71+
OnClearDuplicateSessionCookies: func(message string, req *http.Request, res http.ResponseWriter) error {
72+
res.WriteHeader(200)
73+
res.Write([]byte("clear duplicate session cookies from errorHandler"))
74+
return nil
75+
},
76+
},
77+
GetTokenTransferMethod: func(req *http.Request, forCreateNewSession bool, userContext supertokens.UserContext) sessmodels.TokenTransferMethod {
78+
return sessmodels.CookieTransferMethod
79+
},
80+
}),
81+
},
82+
}
83+
84+
unittesting.StartUpST("localhost", "8080")
85+
defer AfterEach()
86+
err := supertokens.Init(configValue)
87+
if err != nil {
88+
t.Error(err.Error())
89+
}
90+
91+
mux := http.NewServeMux()
92+
93+
mux.HandleFunc("/test/unauthorized", func(rw http.ResponseWriter, r *http.Request) {
94+
supertokens.ErrorHandler(sessionErrors.UnauthorizedError{}, r, rw)
95+
})
96+
97+
mux.HandleFunc("/test/try-refresh", func(rw http.ResponseWriter, r *http.Request) {
98+
supertokens.ErrorHandler(sessionErrors.TryRefreshTokenError{}, r, rw)
99+
})
100+
101+
mux.HandleFunc("/test/token-theft", func(rw http.ResponseWriter, r *http.Request) {
102+
supertokens.ErrorHandler(sessionErrors.TokenTheftDetectedError{}, r, rw)
103+
})
104+
105+
mux.HandleFunc("/test/claim-validation", func(rw http.ResponseWriter, r *http.Request) {
106+
supertokens.ErrorHandler(sessionErrors.InvalidClaimError{}, r, rw)
107+
})
108+
109+
mux.HandleFunc("/test/clear-duplicate-session", func(rw http.ResponseWriter, r *http.Request) {
110+
supertokens.ErrorHandler(sessionErrors.ClearDuplicateSessionCookiesError{}, r, rw)
111+
})
112+
113+
testServer := httptest.NewServer(supertokens.Middleware(mux))
114+
defer func() {
115+
testServer.Close()
116+
}()
117+
118+
t.Run("should override session errorHandlers", func(t *testing.T) {
119+
req, err := http.NewRequest(http.MethodGet, testServer.URL+"/test/unauthorized", nil)
120+
assert.NoError(t, err)
121+
122+
res, err := http.DefaultClient.Do(req)
123+
assert.NoError(t, err)
124+
assert.Equal(t, 401, res.StatusCode)
125+
126+
content, err := io.ReadAll(res.Body)
127+
assert.NoError(t, err)
128+
assert.Equal(t, `{"message":"unauthorised from errorHandler"}`, string(content))
129+
130+
req, err = http.NewRequest(http.MethodGet, testServer.URL+"/test/try-refresh", nil)
131+
assert.NoError(t, err)
132+
133+
res, err = http.DefaultClient.Do(req)
134+
assert.NoError(t, err)
135+
assert.Equal(t, 401, res.StatusCode)
136+
137+
content, err = io.ReadAll(res.Body)
138+
assert.NoError(t, err)
139+
assert.Equal(t, `{"message":"try refresh token from errorHandler"}`, string(content))
140+
141+
req, err = http.NewRequest(http.MethodGet, testServer.URL+"/test/token-theft", nil)
142+
assert.NoError(t, err)
143+
144+
res, err = http.DefaultClient.Do(req)
145+
assert.NoError(t, err)
146+
assert.Equal(t, 403, res.StatusCode)
147+
148+
content, err = io.ReadAll(res.Body)
149+
assert.NoError(t, err)
150+
assert.Equal(t, `{"message":"token theft detected from errorHandler"}`, string(content))
151+
152+
req, err = http.NewRequest(http.MethodGet, testServer.URL+"/test/claim-validation", nil)
153+
assert.NoError(t, err)
154+
155+
res, err = http.DefaultClient.Do(req)
156+
assert.NoError(t, err)
157+
assert.Equal(t, 403, res.StatusCode)
158+
159+
content, err = io.ReadAll(res.Body)
160+
assert.NoError(t, err)
161+
assert.Equal(t, `{"message":"invalid claim from errorHandler"}`, string(content))
162+
163+
req, err = http.NewRequest(http.MethodGet, testServer.URL+"/test/clear-duplicate-session", nil)
164+
assert.NoError(t, err)
165+
166+
res, err = http.DefaultClient.Do(req)
167+
assert.NoError(t, err)
168+
assert.Equal(t, 200, res.StatusCode)
169+
170+
content, err = io.ReadAll(res.Body)
171+
assert.NoError(t, err)
172+
assert.Equal(t, `{"message":"clear duplicate session cookies from errorHandler"}`, string(content))
173+
})
174+
}

0 commit comments

Comments
 (0)