Skip to content

Commit fcf4c35

Browse files
Merge pull request #485 from supertokens/fix/cookie-domain-inconsistency
fix: duplicate session token issue when cookieDomain is changed
2 parents f7558bc + 1987320 commit fcf4c35

39 files changed

+830
-42
lines changed

CHANGELOG.md

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

99
## [unreleased]
1010

11+
## [0.20.0] - 2024-05-08
12+
13+
- Added `older_cookie_domain` config option in the session recipe. This will allow users to clear cookies from the older domain when the `cookie_domain` is changed.
14+
- If `verify_session` detects multiple access tokens in the request, it will return a 401 error, prompting a refresh, even if one of the tokens is valid.
15+
- `refresh_post` (`/auth/session/refresh` by default) API changes:
16+
- now returns 500 error if multiple access tokens are present in the request and `config.older_cookie_domain` is not set.
17+
- 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).
18+
- now clears cookies from the old domain if `older_cookie_domain` is specified and multiple refresh/access token cookies exist, without updating the front-token or any of the tokens.
19+
- now a 200 response may not include new session tokens.
20+
- Fixed a bug in the `normalise_session_scope` util function that caused it to remove leading dots from the scope string.
21+
22+
### Migration
23+
24+
With this update, the second argument in the `session.init` function changes from `cookie_secure` to `older_cookie_domain`. If you're using positional arguments, you need to insert `None` for `older_cookie_domain` as the second argument to maintain the correct order of parameters.
25+
26+
Before:
27+
```python
28+
from supertokens_python import init, SupertokensConfig, InputAppInfo
29+
from supertokens_python.recipe import session
30+
31+
init(
32+
supertokens_config=SupertokensConfig("..."),
33+
app_info=InputAppInfo("..."),
34+
framework="...",
35+
recipe_list=[
36+
session.init(
37+
"example.com" # cookie_domain
38+
True, # cookie_secure
39+
"strict" # cookie_same_site
40+
),
41+
],
42+
)
43+
```
44+
45+
After the update:
46+
47+
```python
48+
from supertokens_python import init, SupertokensConfig, InputAppInfo
49+
from supertokens_python.recipe import session
50+
51+
init(
52+
supertokens_config=SupertokensConfig("..."),
53+
app_info=InputAppInfo("..."),
54+
framework="...",
55+
recipe_list=[
56+
session.init(
57+
"example.com" # cookie_domain
58+
None, # older_cookie_domain
59+
True, # cookie_secure
60+
"strict" # cookie_same_site
61+
),
62+
],
63+
)
64+
```
65+
66+
### Rationale
67+
68+
This update addresses an edge case where changing the `cookie_domain` 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.
69+
70+
Previously, verify_session 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 `refresh_post` API would then set new session token cookies with the updated `cookie_domain`, but older cookies will persist, leading to repeated 401 errors and refresh loops.
71+
72+
With this update, verify_session will return a 401 error if it detects multiple access tokens in the request, prompting a refresh request. The `refresh_post` API will clear cookies from the old domain if `older_cookie_domain` is specified in the configuration, then return a 200 status. If `older_cookie_domain` is not configured, the `refresh_post` API will return a 500 error with a message instructing to set `older_cookie_domain`.
73+
74+
**Example:**
75+
76+
- `apiDomain`: 'api.example.com'
77+
- `cookie_domain`: 'api.example.com'
78+
79+
**Flow:**
80+
81+
1. After authentication, the frontend has cookies set with `domain=api.example.com`, but the access token has expired.
82+
2. The server updates `cookie_domain` to `.example.com`.
83+
3. An API call requiring session with an expired access token (cookie with `domain=api.example.com`) results in a 401 response.
84+
4. The frontend attempts to refresh the session, generating a new access token saved with `domain=.example.com`.
85+
5. The original API call is retried, but because it sends both the old and new cookies, it again results in a 401 response.
86+
6. The frontend tries to refresh the session with multiple access tokens:
87+
- If `older_cookie_domain` is not set, the refresh fails with a 500 error.
88+
- The user remains stuck until they clear cookies manually or `older_cookie_domain` is set.
89+
- If `older_cookie_domain` is set, the refresh clears the older cookie, returning a 200 response.
90+
- The frontend retries the original API call, sending only the new cookie (`domain=.example.com`), resulting in a successful request.
91+
1192
## [0.19.0] - 2024-05-06
1293

1394
- `create_new_session` now defaults to the value of the `st-auth-mode` header (if available) if the configured `get_token_transfer_method` returns `any`.

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282

8383
setup(
8484
name="supertokens_python",
85-
version="0.19.0",
85+
version="0.20.0",
8686
author="SuperTokens",
8787
license="Apache 2.0",
8888
author_email="[email protected]",

supertokens_python/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from __future__ import annotations
1515

1616
SUPPORTED_CDI_VERSIONS = ["3.0"]
17-
VERSION = "0.19.0"
17+
VERSION = "0.20.0"
1818
TELEMETRY = "/telemetry"
1919
USER_COUNT = "/users/count"
2020
USER_DELETE = "/user/remove"

supertokens_python/recipe/session/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
def init(
3636
cookie_domain: Union[str, None] = None,
37+
older_cookie_domain: Union[str, None] = None,
3738
cookie_secure: Union[bool, None] = None,
3839
cookie_same_site: Union[Literal["lax", "none", "strict"], None] = None,
3940
session_expired_status_code: Union[int, None] = None,
@@ -53,6 +54,7 @@ def init(
5354
) -> Callable[[AppInfo], RecipeModule]:
5455
return SessionRecipe.init(
5556
cookie_domain,
57+
older_cookie_domain,
5658
cookie_secure,
5759
cookie_same_site,
5860
session_expired_status_code,

supertokens_python/recipe/session/cookie_and_header.py

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818

1919
from typing_extensions import Literal
2020

21+
from supertokens_python.recipe.session.exceptions import (
22+
raise_clear_duplicate_session_cookies_exception,
23+
)
24+
from supertokens_python.recipe.session.interfaces import ResponseMutator
25+
2126
from .constants import (
2227
ACCESS_CONTROL_EXPOSE_HEADERS,
2328
ACCESS_TOKEN_COOKIE_KEY,
@@ -111,9 +116,9 @@ def _set_cookie(
111116
expires: int,
112117
path_type: Literal["refresh_token_path", "access_token_path"],
113118
request: BaseRequest,
119+
domain: Optional[str],
114120
user_context: Dict[str, Any],
115121
):
116-
domain = config.cookie_domain
117122
secure = config.cookie_secure
118123
same_site = config.get_cookie_same_site(request, user_context)
119124
path = ""
@@ -141,10 +146,21 @@ def set_cookie_response_mutator(
141146
expires: int,
142147
path_type: Literal["refresh_token_path", "access_token_path"],
143148
request: BaseRequest,
149+
domain: Optional[str] = None,
144150
):
151+
domain = domain if domain is not None else config.cookie_domain
152+
145153
def mutator(response: BaseResponse, user_context: Dict[str, Any]):
146154
return _set_cookie(
147-
response, config, key, value, expires, path_type, request, user_context
155+
response,
156+
config,
157+
key,
158+
value,
159+
expires,
160+
path_type,
161+
request,
162+
domain,
163+
user_context,
148164
)
149165

150166
return mutator
@@ -294,6 +310,7 @@ def _set_token(
294310
expires,
295311
"refresh_token_path" if token_type == "refresh" else "access_token_path",
296312
request,
313+
config.cookie_domain,
297314
user_context,
298315
)
299316
elif transfer_method == "header":
@@ -398,3 +415,93 @@ def _set_access_token_in_response(
398415
request,
399416
user_context,
400417
)
418+
419+
420+
# This function addresses an edge case where changing the cookie_domain config on the server can
421+
# lead to session integrity issues. For instance, if the API server URL is 'api.example.com'
422+
# with a cookie domain of '.example.com', and the server updates the cookie domain to 'api.example.com',
423+
# the client may retain cookies with both '.example.com' and 'api.example.com' domains.
424+
425+
# Consequently, if the server chooses the older cookie, session invalidation occurs, potentially
426+
# resulting in an infinite refresh loop. To fix this, users are asked to specify "older_cookie_domain" in
427+
# the config.
428+
429+
# This function checks for multiple cookies with the same name and clears the cookies for the older domain.
430+
def clear_session_cookies_from_older_cookie_domain(
431+
request: BaseRequest, config: SessionConfig, user_context: Dict[str, Any]
432+
):
433+
allowed_transfer_method = config.get_token_transfer_method(
434+
request, False, user_context
435+
)
436+
# If the transfer method is 'header', there's no need to clear cookies immediately, even if there are multiple in the request.
437+
if allowed_transfer_method == "header":
438+
return
439+
440+
did_clear_cookies = False
441+
response_mutators: List[ResponseMutator] = []
442+
443+
token_types: List[TokenType] = ["access", "refresh"]
444+
for token_type in token_types:
445+
if has_multiple_cookies_for_token_type(request, token_type):
446+
# If a request has multiple session cookies and 'older_cookie_domain' is
447+
# unset, we can't identify the correct cookie for refreshing the session.
448+
# Using the wrong cookie can cause an infinite refresh loop. To avoid this,
449+
# we throw a 500 error asking the user to set 'older_cookie_domain''.
450+
if config.older_cookie_domain is None:
451+
raise Exception(
452+
"The request contains multiple session cookies. This may happen if you've changed the 'cookie_domain' setting in your configuration. To clear tokens from the previous domain, set 'older_cookie_domain' in your config."
453+
)
454+
455+
log_debug_message(
456+
"Clearing duplicate %s cookie with domain %s",
457+
token_type,
458+
config.cookie_domain,
459+
)
460+
response_mutators.append(
461+
set_cookie_response_mutator(
462+
config,
463+
get_cookie_name_from_token_type(token_type),
464+
"",
465+
0,
466+
"refresh_token_path"
467+
if token_type == "refresh"
468+
else "access_token_path",
469+
request,
470+
domain=config.older_cookie_domain,
471+
)
472+
)
473+
did_clear_cookies = True
474+
if did_clear_cookies:
475+
raise_clear_duplicate_session_cookies_exception(
476+
"The request contains multiple session cookies. We are clearing the cookie from older_cookie_domain. Session will be refreshed in the next refresh call.",
477+
response_mutators=response_mutators,
478+
)
479+
480+
481+
def has_multiple_cookies_for_token_type(
482+
request: BaseRequest, token_type: TokenType
483+
) -> bool:
484+
cookie_string = request.get_header("cookie")
485+
if cookie_string is None:
486+
return False
487+
488+
cookies = _parse_cookie_string_from_request_header_allow_duplicates(cookie_string)
489+
cookie_name = get_cookie_name_from_token_type(token_type)
490+
return cookie_name in cookies and len(cookies[cookie_name]) > 1
491+
492+
493+
def _parse_cookie_string_from_request_header_allow_duplicates(
494+
cookie_string: str,
495+
) -> Dict[str, List[str]]:
496+
cookies: Dict[str, List[str]] = {}
497+
cookie_pairs = cookie_string.split(";")
498+
for cookie_pair in cookie_pairs:
499+
name_value = cookie_pair.split("=")
500+
if len(name_value) != 2:
501+
raise Exception("Invalid cookie string in request header")
502+
name, value = unquote(name_value[0].strip()), unquote(name_value[1].strip())
503+
if name in cookies:
504+
cookies[name].append(value)
505+
else:
506+
cookies[name] = [value]
507+
return cookies

supertokens_python/recipe/session/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ def raise_unauthorised_exception(
4545
raise err
4646

4747

48+
def raise_clear_duplicate_session_cookies_exception(
49+
msg: str, response_mutators: List[ResponseMutator]
50+
) -> NoReturn:
51+
err = ClearDuplicateSessionCookiesError(msg)
52+
err.response_mutators.extend(response_mutators)
53+
raise err
54+
55+
4856
class SuperTokensSessionError(SuperTokensError):
4957
def __init__(self, *args: Any, **kwargs: Any) -> None:
5058
super().__init__(*args, **kwargs)
@@ -89,3 +97,7 @@ def to_json(self):
8997

9098
def raise_invalid_claims_exception(msg: str, payload: List[ClaimValidationError]):
9199
raise InvalidClaimsError(msg, payload)
100+
101+
102+
class ClearDuplicateSessionCookiesError(SuperTokensSessionError):
103+
pass

supertokens_python/recipe/session/recipe.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
get_cors_allowed_headers,
2424
)
2525
from .exceptions import (
26+
ClearDuplicateSessionCookiesError,
2627
SuperTokensSessionError,
2728
TokenTheftError,
2829
UnauthorisedError,
@@ -72,6 +73,7 @@ def __init__(
7273
recipe_id: str,
7374
app_info: AppInfo,
7475
cookie_domain: Union[str, None] = None,
76+
older_cookie_domain: Union[str, None] = None,
7577
cookie_secure: Union[bool, None] = None,
7678
cookie_same_site: Union[Literal["lax", "none", "strict"], None] = None,
7779
session_expired_status_code: Union[int, None] = None,
@@ -95,6 +97,7 @@ def __init__(
9597
self.config = validate_and_normalise_user_input(
9698
app_info,
9799
cookie_domain,
100+
older_cookie_domain,
98101
cookie_secure,
99102
cookie_same_site,
100103
session_expired_status_code,
@@ -265,6 +268,11 @@ async def handle_error(
265268
return await self.config.error_handlers.on_invalid_claim(
266269
self, request, err.payload, response
267270
)
271+
if isinstance(err, ClearDuplicateSessionCookiesError):
272+
log_debug_message("errorHandler: returning CLEAR_DUPLICATE_SESSION_COOKIES")
273+
return await self.config.error_handlers.on_clear_duplicate_session_cookies(
274+
request, str(err), response
275+
)
268276

269277
log_debug_message("errorHandler: returning TRY_REFRESH_TOKEN")
270278
return await self.config.error_handlers.on_try_refresh_token(
@@ -280,6 +288,7 @@ def get_all_cors_headers(self) -> List[str]:
280288
@staticmethod
281289
def init(
282290
cookie_domain: Union[str, None] = None,
291+
older_cookie_domain: Union[str, None] = None,
283292
cookie_secure: Union[bool, None] = None,
284293
cookie_same_site: Union[Literal["lax", "none", "strict"], None] = None,
285294
session_expired_status_code: Union[int, None] = None,
@@ -305,6 +314,7 @@ def func(app_info: AppInfo):
305314
SessionRecipe.recipe_id,
306315
app_info,
307316
cookie_domain,
317+
older_cookie_domain,
308318
cookie_secure,
309319
cookie_same_site,
310320
session_expired_status_code,

0 commit comments

Comments
 (0)