Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 1d8b80b

Browse files
authored
Support expiry of refresh tokens and expiry of the overall session when refresh tokens are in use. (#11425)
1 parent e2c300e commit 1d8b80b

File tree

8 files changed

+338
-54
lines changed

8 files changed

+338
-54
lines changed

changelog.d/11425.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support expiry of refresh tokens and expiry of the overall session when refresh tokens are in use.

synapse/config/registration.py

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -113,32 +113,22 @@ def read_config(self, config, **kwargs):
113113
self.session_lifetime = session_lifetime
114114

115115
# The `refreshable_access_token_lifetime` applies for tokens that can be renewed
116-
# using a refresh token, as per MSC2918. If it is `None`, the refresh
117-
# token mechanism is disabled.
118-
#
119-
# Since it is incompatible with the `session_lifetime` mechanism, it is set to
120-
# `None` by default if a `session_lifetime` is set.
116+
# using a refresh token, as per MSC2918.
117+
# If it is `None`, the refresh token mechanism is disabled.
121118
refreshable_access_token_lifetime = config.get(
122119
"refreshable_access_token_lifetime",
123-
"5m" if session_lifetime is None else None,
120+
"5m",
124121
)
125122
if refreshable_access_token_lifetime is not None:
126123
refreshable_access_token_lifetime = self.parse_duration(
127124
refreshable_access_token_lifetime
128125
)
129126
self.refreshable_access_token_lifetime = refreshable_access_token_lifetime
130127

131-
if (
132-
session_lifetime is not None
133-
and refreshable_access_token_lifetime is not None
134-
):
135-
raise ConfigError(
136-
"The refresh token mechanism is incompatible with the "
137-
"`session_lifetime` option. Consider disabling the "
138-
"`session_lifetime` option or disabling the refresh token "
139-
"mechanism by removing the `refreshable_access_token_lifetime` "
140-
"option."
141-
)
128+
refresh_token_lifetime = config.get("refresh_token_lifetime")
129+
if refresh_token_lifetime is not None:
130+
refresh_token_lifetime = self.parse_duration(refresh_token_lifetime)
131+
self.refresh_token_lifetime = refresh_token_lifetime
142132

143133
# The fallback template used for authenticating using a registration token
144134
self.registration_token_template = self.read_template("registration_token.html")

synapse/handlers/auth.py

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import unicodedata
1919
import urllib.parse
2020
from binascii import crc32
21+
from http import HTTPStatus
2122
from typing import (
2223
TYPE_CHECKING,
2324
Any,
@@ -756,53 +757,109 @@ def _auth_dict_for_flows(
756757
async def refresh_token(
757758
self,
758759
refresh_token: str,
759-
valid_until_ms: Optional[int],
760-
) -> Tuple[str, str]:
760+
access_token_valid_until_ms: Optional[int],
761+
refresh_token_valid_until_ms: Optional[int],
762+
) -> Tuple[str, str, Optional[int]]:
761763
"""
762764
Consumes a refresh token and generate both a new access token and a new refresh token from it.
763765
764766
The consumed refresh token is considered invalid after the first use of the new access token or the new refresh token.
765767
768+
The lifetime of both the access token and refresh token will be capped so that they
769+
do not exceed the session's ultimate expiry time, if applicable.
770+
766771
Args:
767772
refresh_token: The token to consume.
768-
valid_until_ms: The expiration timestamp of the new access token.
769-
773+
access_token_valid_until_ms: The expiration timestamp of the new access token.
774+
None if the access token does not expire.
775+
refresh_token_valid_until_ms: The expiration timestamp of the new refresh token.
776+
None if the refresh token does not expire.
770777
Returns:
771-
A tuple containing the new access token and refresh token
778+
A tuple containing:
779+
- the new access token
780+
- the new refresh token
781+
- the actual expiry time of the access token, which may be earlier than
782+
`access_token_valid_until_ms`.
772783
"""
773784

774785
# Verify the token signature first before looking up the token
775786
if not self._verify_refresh_token(refresh_token):
776-
raise SynapseError(401, "invalid refresh token", Codes.UNKNOWN_TOKEN)
787+
raise SynapseError(
788+
HTTPStatus.UNAUTHORIZED, "invalid refresh token", Codes.UNKNOWN_TOKEN
789+
)
777790

778791
existing_token = await self.store.lookup_refresh_token(refresh_token)
779792
if existing_token is None:
780-
raise SynapseError(401, "refresh token does not exist", Codes.UNKNOWN_TOKEN)
793+
raise SynapseError(
794+
HTTPStatus.UNAUTHORIZED,
795+
"refresh token does not exist",
796+
Codes.UNKNOWN_TOKEN,
797+
)
781798

782799
if (
783800
existing_token.has_next_access_token_been_used
784801
or existing_token.has_next_refresh_token_been_refreshed
785802
):
786803
raise SynapseError(
787-
403, "refresh token isn't valid anymore", Codes.FORBIDDEN
804+
HTTPStatus.FORBIDDEN,
805+
"refresh token isn't valid anymore",
806+
Codes.FORBIDDEN,
807+
)
808+
809+
now_ms = self._clock.time_msec()
810+
811+
if existing_token.expiry_ts is not None and existing_token.expiry_ts < now_ms:
812+
813+
raise SynapseError(
814+
HTTPStatus.FORBIDDEN,
815+
"The supplied refresh token has expired",
816+
Codes.FORBIDDEN,
788817
)
789818

819+
if existing_token.ultimate_session_expiry_ts is not None:
820+
# This session has a bounded lifetime, even across refreshes.
821+
822+
if access_token_valid_until_ms is not None:
823+
access_token_valid_until_ms = min(
824+
access_token_valid_until_ms,
825+
existing_token.ultimate_session_expiry_ts,
826+
)
827+
else:
828+
access_token_valid_until_ms = existing_token.ultimate_session_expiry_ts
829+
830+
if refresh_token_valid_until_ms is not None:
831+
refresh_token_valid_until_ms = min(
832+
refresh_token_valid_until_ms,
833+
existing_token.ultimate_session_expiry_ts,
834+
)
835+
else:
836+
refresh_token_valid_until_ms = existing_token.ultimate_session_expiry_ts
837+
if existing_token.ultimate_session_expiry_ts < now_ms:
838+
raise SynapseError(
839+
HTTPStatus.FORBIDDEN,
840+
"The session has expired and can no longer be refreshed",
841+
Codes.FORBIDDEN,
842+
)
843+
790844
(
791845
new_refresh_token,
792846
new_refresh_token_id,
793847
) = await self.create_refresh_token_for_user_id(
794-
user_id=existing_token.user_id, device_id=existing_token.device_id
848+
user_id=existing_token.user_id,
849+
device_id=existing_token.device_id,
850+
expiry_ts=refresh_token_valid_until_ms,
851+
ultimate_session_expiry_ts=existing_token.ultimate_session_expiry_ts,
795852
)
796853
access_token = await self.create_access_token_for_user_id(
797854
user_id=existing_token.user_id,
798855
device_id=existing_token.device_id,
799-
valid_until_ms=valid_until_ms,
856+
valid_until_ms=access_token_valid_until_ms,
800857
refresh_token_id=new_refresh_token_id,
801858
)
802859
await self.store.replace_refresh_token(
803860
existing_token.token_id, new_refresh_token_id
804861
)
805-
return access_token, new_refresh_token
862+
return access_token, new_refresh_token, access_token_valid_until_ms
806863

807864
def _verify_refresh_token(self, token: str) -> bool:
808865
"""
@@ -836,13 +893,22 @@ async def create_refresh_token_for_user_id(
836893
self,
837894
user_id: str,
838895
device_id: str,
896+
expiry_ts: Optional[int],
897+
ultimate_session_expiry_ts: Optional[int],
839898
) -> Tuple[str, int]:
840899
"""
841900
Creates a new refresh token for the user with the given user ID.
842901
843902
Args:
844903
user_id: canonical user ID
845904
device_id: the device ID to associate with the token.
905+
expiry_ts (milliseconds since the epoch): Time after which the
906+
refresh token cannot be used.
907+
If None, the refresh token never expires until it has been used.
908+
ultimate_session_expiry_ts (milliseconds since the epoch):
909+
Time at which the session will end and can not be extended any
910+
further.
911+
If None, the session can be refreshed indefinitely.
846912
847913
Returns:
848914
The newly created refresh token and its ID in the database
@@ -852,6 +918,8 @@ async def create_refresh_token_for_user_id(
852918
user_id=user_id,
853919
token=refresh_token,
854920
device_id=device_id,
921+
expiry_ts=expiry_ts,
922+
ultimate_session_expiry_ts=ultimate_session_expiry_ts,
855923
)
856924
return refresh_token, refresh_token_id
857925

synapse/handlers/register.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def __init__(self, hs: "HomeServer"):
119119
self.refreshable_access_token_lifetime = (
120120
hs.config.registration.refreshable_access_token_lifetime
121121
)
122+
self.refresh_token_lifetime = hs.config.registration.refresh_token_lifetime
122123

123124
init_counters_for_auth_provider("")
124125

@@ -793,13 +794,13 @@ async def register_device_inner(
793794
class and RegisterDeviceReplicationServlet.
794795
"""
795796
assert not self.hs.config.worker.worker_app
796-
valid_until_ms = None
797+
access_token_expiry = None
797798
if self.session_lifetime is not None:
798799
if is_guest:
799800
raise Exception(
800801
"session_lifetime is not currently implemented for guest access"
801802
)
802-
valid_until_ms = self.clock.time_msec() + self.session_lifetime
803+
access_token_expiry = self.clock.time_msec() + self.session_lifetime
803804

804805
refresh_token = None
805806
refresh_token_id = None
@@ -808,33 +809,60 @@ class and RegisterDeviceReplicationServlet.
808809
user_id, device_id, initial_display_name
809810
)
810811
if is_guest:
811-
assert valid_until_ms is None
812+
assert access_token_expiry is None
812813
access_token = self.macaroon_gen.generate_guest_access_token(user_id)
813814
else:
814815
if should_issue_refresh_token:
816+
now_ms = self.clock.time_msec()
817+
818+
# Set the expiry time of the refreshable access token
819+
access_token_expiry = now_ms + self.refreshable_access_token_lifetime
820+
821+
# Set the refresh token expiry time (if configured)
822+
refresh_token_expiry = None
823+
if self.refresh_token_lifetime is not None:
824+
refresh_token_expiry = now_ms + self.refresh_token_lifetime
825+
826+
# Set an ultimate session expiry time (if configured)
827+
ultimate_session_expiry_ts = None
828+
if self.session_lifetime is not None:
829+
ultimate_session_expiry_ts = now_ms + self.session_lifetime
830+
831+
# Also ensure that the issued tokens don't outlive the
832+
# session.
833+
# (It would be weird to configure a homeserver with a shorter
834+
# session lifetime than token lifetime, but may as well handle
835+
# it.)
836+
access_token_expiry = min(
837+
access_token_expiry, ultimate_session_expiry_ts
838+
)
839+
if refresh_token_expiry is not None:
840+
refresh_token_expiry = min(
841+
refresh_token_expiry, ultimate_session_expiry_ts
842+
)
843+
815844
(
816845
refresh_token,
817846
refresh_token_id,
818847
) = await self._auth_handler.create_refresh_token_for_user_id(
819848
user_id,
820849
device_id=registered_device_id,
821-
)
822-
valid_until_ms = (
823-
self.clock.time_msec() + self.refreshable_access_token_lifetime
850+
expiry_ts=refresh_token_expiry,
851+
ultimate_session_expiry_ts=ultimate_session_expiry_ts,
824852
)
825853

826854
access_token = await self._auth_handler.create_access_token_for_user_id(
827855
user_id,
828856
device_id=registered_device_id,
829-
valid_until_ms=valid_until_ms,
857+
valid_until_ms=access_token_expiry,
830858
is_appservice_ghost=is_appservice_ghost,
831859
refresh_token_id=refresh_token_id,
832860
)
833861

834862
return {
835863
"device_id": registered_device_id,
836864
"access_token": access_token,
837-
"valid_until_ms": valid_until_ms,
865+
"valid_until_ms": access_token_expiry,
838866
"refresh_token": refresh_token,
839867
}
840868

synapse/rest/client/login.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,17 @@
1414

1515
import logging
1616
import re
17-
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Tuple
17+
from typing import (
18+
TYPE_CHECKING,
19+
Any,
20+
Awaitable,
21+
Callable,
22+
Dict,
23+
List,
24+
Optional,
25+
Tuple,
26+
Union,
27+
)
1828

1929
from typing_extensions import TypedDict
2030

@@ -458,6 +468,7 @@ def __init__(self, hs: "HomeServer"):
458468
self.refreshable_access_token_lifetime = (
459469
hs.config.registration.refreshable_access_token_lifetime
460470
)
471+
self.refresh_token_lifetime = hs.config.registration.refresh_token_lifetime
461472

462473
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
463474
refresh_submission = parse_json_object_from_request(request)
@@ -467,22 +478,33 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
467478
if not isinstance(token, str):
468479
raise SynapseError(400, "Invalid param: refresh_token", Codes.INVALID_PARAM)
469480

470-
valid_until_ms = (
471-
self._clock.time_msec() + self.refreshable_access_token_lifetime
472-
)
473-
access_token, refresh_token = await self._auth_handler.refresh_token(
474-
token, valid_until_ms
475-
)
476-
expires_in_ms = valid_until_ms - self._clock.time_msec()
477-
return (
478-
200,
479-
{
480-
"access_token": access_token,
481-
"refresh_token": refresh_token,
482-
"expires_in_ms": expires_in_ms,
483-
},
481+
now = self._clock.time_msec()
482+
access_valid_until_ms = None
483+
if self.refreshable_access_token_lifetime is not None:
484+
access_valid_until_ms = now + self.refreshable_access_token_lifetime
485+
refresh_valid_until_ms = None
486+
if self.refresh_token_lifetime is not None:
487+
refresh_valid_until_ms = now + self.refresh_token_lifetime
488+
489+
(
490+
access_token,
491+
refresh_token,
492+
actual_access_token_expiry,
493+
) = await self._auth_handler.refresh_token(
494+
token, access_valid_until_ms, refresh_valid_until_ms
484495
)
485496

497+
response: Dict[str, Union[str, int]] = {
498+
"access_token": access_token,
499+
"refresh_token": refresh_token,
500+
}
501+
502+
# expires_in_ms is only present if the token expires
503+
if actual_access_token_expiry is not None:
504+
response["expires_in_ms"] = actual_access_token_expiry - now
505+
506+
return 200, response
507+
486508

487509
class SsoRedirectServlet(RestServlet):
488510
PATTERNS = list(client_patterns("/login/(cas|sso)/redirect$", v1=True)) + [

0 commit comments

Comments
 (0)