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

Support expiry of refresh tokens and expiry of the overall session when refresh tokens are in use. #11425

Merged
merged 26 commits into from
Nov 26, 2021
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a29bf71
Add expiry_ts and ultimate_session_expiry_ts to refresh_tokens table
reivilibre Nov 18, 2021
88e5403
Use expiry_ts and ultimate_session_expiry_ts
reivilibre Nov 18, 2021
d26cf03
Return expiry and ultimate session expiry when looking up refresh tokens
reivilibre Nov 18, 2021
668ff10
Pass in both access and refresh token expiry times to `refresh_token`
reivilibre Nov 18, 2021
ee0310d
Enforce refresh token expiry
reivilibre Nov 18, 2021
21cb0c7
Add refresh_token_lifetime configuration option
reivilibre Nov 18, 2021
7e6d64e
Set up refresh token and ultimate session expiry on initial login
reivilibre Nov 18, 2021
db81406
Bound the lifetime of access and refresh tokens by the ultimate sessi…
reivilibre Nov 19, 2021
df26db6
Set validity correctly on refresh
reivilibre Nov 19, 2021
2ab9500
Some fixes around optional expiry
reivilibre Nov 22, 2021
f6d2e5a
Rename existing test to less confusing name
reivilibre Nov 22, 2021
8c5cb14
Add a test for refresh token expiry
reivilibre Nov 22, 2021
38adfbc
Factorise `use_refresh_token` function
reivilibre Nov 22, 2021
c72a7ed
Remove compatibility error between refresh tokens and session lifetimes
reivilibre Nov 24, 2021
f96c3c0
Add test for ultimate session expiry
reivilibre Nov 25, 2021
5322e1d
Antilint
reivilibre Nov 25, 2021
d83b8fe
Merge branch 'develop' into rei/expirable_refresh_tokens
reivilibre Nov 25, 2021
0d48026
Newsfile
reivilibre Nov 25, 2021
b5bdd97
Fixes falling out of the config option rename in develop
reivilibre Nov 25, 2021
87f5edf
Remove obsolete note and default about compatibility
reivilibre Nov 25, 2021
2e6ed28
Use constants for HTTP statuses in lieu of literals
reivilibre Nov 26, 2021
31d09e4
Document access_token_valid_until_ms
reivilibre Nov 26, 2021
29cee1d
Handle the case (on login) that session lifetime is shorter than toke…
reivilibre Nov 26, 2021
79cec6e
Try to make test_refresh_token_expiry clearer
reivilibre Nov 26, 2021
6b186a9
Check that refreshable access tokens arising from refresh have the co…
reivilibre Nov 26, 2021
430b305
Speak of refreshing the session rather than refreshing the access tok…
reivilibre Nov 26, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/11425.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support expiry of refresh tokens and expiry of the overall session when refresh tokens are in use.
24 changes: 7 additions & 17 deletions synapse/config/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,32 +113,22 @@ def read_config(self, config, **kwargs):
self.session_lifetime = session_lifetime

# The `refreshable_access_token_lifetime` applies for tokens that can be renewed
# using a refresh token, as per MSC2918. If it is `None`, the refresh
# token mechanism is disabled.
#
# Since it is incompatible with the `session_lifetime` mechanism, it is set to
# `None` by default if a `session_lifetime` is set.
# using a refresh token, as per MSC2918.
# If it is `None`, the refresh token mechanism is disabled.
refreshable_access_token_lifetime = config.get(
"refreshable_access_token_lifetime",
"5m" if session_lifetime is None else None,
"5m",
)
if refreshable_access_token_lifetime is not None:
refreshable_access_token_lifetime = self.parse_duration(
refreshable_access_token_lifetime
)
self.refreshable_access_token_lifetime = refreshable_access_token_lifetime

if (
session_lifetime is not None
and refreshable_access_token_lifetime is not None
):
raise ConfigError(
"The refresh token mechanism is incompatible with the "
"`session_lifetime` option. Consider disabling the "
"`session_lifetime` option or disabling the refresh token "
"mechanism by removing the `refreshable_access_token_lifetime` "
"option."
)
refresh_token_lifetime = config.get("refresh_token_lifetime")
if refresh_token_lifetime is not None:
refresh_token_lifetime = self.parse_duration(refresh_token_lifetime)
self.refresh_token_lifetime = refresh_token_lifetime

# The fallback template used for authenticating using a registration token
self.registration_token_template = self.read_template("registration_token.html")
Expand Down
90 changes: 79 additions & 11 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import unicodedata
import urllib.parse
from binascii import crc32
from http import HTTPStatus
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -756,53 +757,109 @@ def _auth_dict_for_flows(
async def refresh_token(
self,
refresh_token: str,
valid_until_ms: Optional[int],
) -> Tuple[str, str]:
access_token_valid_until_ms: Optional[int],
refresh_token_valid_until_ms: Optional[int],
) -> Tuple[str, str, Optional[int]]:
"""
Consumes a refresh token and generate both a new access token and a new refresh token from it.

The consumed refresh token is considered invalid after the first use of the new access token or the new refresh token.

The lifetime of both the access token and refresh token will be capped so that they
do not exceed the session's ultimate expiry time, if applicable.

Args:
refresh_token: The token to consume.
valid_until_ms: The expiration timestamp of the new access token.

access_token_valid_until_ms: The expiration timestamp of the new access token.
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
None if the access token does not expire.
refresh_token_valid_until_ms: The expiration timestamp of the new refresh token.
None if the refresh token does not expire.
Returns:
A tuple containing the new access token and refresh token
A tuple containing:
- the new access token
- the new refresh token
- the actual expiry time of the access token, which may be earlier than
`access_token_valid_until_ms`.
"""

# Verify the token signature first before looking up the token
if not self._verify_refresh_token(refresh_token):
raise SynapseError(401, "invalid refresh token", Codes.UNKNOWN_TOKEN)
raise SynapseError(
HTTPStatus.UNAUTHORIZED, "invalid refresh token", Codes.UNKNOWN_TOKEN
)

existing_token = await self.store.lookup_refresh_token(refresh_token)
if existing_token is None:
raise SynapseError(401, "refresh token does not exist", Codes.UNKNOWN_TOKEN)
raise SynapseError(
HTTPStatus.UNAUTHORIZED,
"refresh token does not exist",
Codes.UNKNOWN_TOKEN,
)

if (
existing_token.has_next_access_token_been_used
or existing_token.has_next_refresh_token_been_refreshed
):
raise SynapseError(
403, "refresh token isn't valid anymore", Codes.FORBIDDEN
HTTPStatus.FORBIDDEN,
"refresh token isn't valid anymore",
Codes.FORBIDDEN,
)

now_ms = self._clock.time_msec()

if existing_token.expiry_ts is not None and existing_token.expiry_ts < now_ms:

raise SynapseError(
HTTPStatus.FORBIDDEN,
"The supplied refresh token has expired",
Codes.FORBIDDEN,
)

if existing_token.ultimate_session_expiry_ts is not None:
# This session has a bounded lifetime, even across refreshes.

if access_token_valid_until_ms is not None:
access_token_valid_until_ms = min(
access_token_valid_until_ms,
existing_token.ultimate_session_expiry_ts,
)
else:
access_token_valid_until_ms = existing_token.ultimate_session_expiry_ts

if refresh_token_valid_until_ms is not None:
refresh_token_valid_until_ms = min(
refresh_token_valid_until_ms,
existing_token.ultimate_session_expiry_ts,
)
else:
refresh_token_valid_until_ms = existing_token.ultimate_session_expiry_ts
if existing_token.ultimate_session_expiry_ts < now_ms:
raise SynapseError(
HTTPStatus.FORBIDDEN,
"The session has expired and can no longer be refreshed",
Codes.FORBIDDEN,
)

(
new_refresh_token,
new_refresh_token_id,
) = await self.create_refresh_token_for_user_id(
user_id=existing_token.user_id, device_id=existing_token.device_id
user_id=existing_token.user_id,
device_id=existing_token.device_id,
expiry_ts=refresh_token_valid_until_ms,
ultimate_session_expiry_ts=existing_token.ultimate_session_expiry_ts,
)
access_token = await self.create_access_token_for_user_id(
user_id=existing_token.user_id,
device_id=existing_token.device_id,
valid_until_ms=valid_until_ms,
valid_until_ms=access_token_valid_until_ms,
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
refresh_token_id=new_refresh_token_id,
)
await self.store.replace_refresh_token(
existing_token.token_id, new_refresh_token_id
)
return access_token, new_refresh_token
return access_token, new_refresh_token, access_token_valid_until_ms

def _verify_refresh_token(self, token: str) -> bool:
"""
Expand Down Expand Up @@ -836,13 +893,22 @@ async def create_refresh_token_for_user_id(
self,
user_id: str,
device_id: str,
expiry_ts: Optional[int],
ultimate_session_expiry_ts: Optional[int],
) -> Tuple[str, int]:
"""
Creates a new refresh token for the user with the given user ID.

Args:
user_id: canonical user ID
device_id: the device ID to associate with the token.
expiry_ts (milliseconds since the epoch): Time after which the
refresh token cannot be used.
If None, the refresh token never expires until it has been used.
ultimate_session_expiry_ts (milliseconds since the epoch):
Time at which the session will end and can not be extended any
further.
If None, the session can be refreshed indefinitely.

Returns:
The newly created refresh token and its ID in the database
Expand All @@ -852,6 +918,8 @@ async def create_refresh_token_for_user_id(
user_id=user_id,
token=refresh_token,
device_id=device_id,
expiry_ts=expiry_ts,
ultimate_session_expiry_ts=ultimate_session_expiry_ts,
)
return refresh_token, refresh_token_id

Expand Down
44 changes: 36 additions & 8 deletions synapse/handlers/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def __init__(self, hs: "HomeServer"):
self.refreshable_access_token_lifetime = (
hs.config.registration.refreshable_access_token_lifetime
)
self.refresh_token_lifetime = hs.config.registration.refresh_token_lifetime

init_counters_for_auth_provider("")

Expand Down Expand Up @@ -793,13 +794,13 @@ async def register_device_inner(
class and RegisterDeviceReplicationServlet.
"""
assert not self.hs.config.worker.worker_app
valid_until_ms = None
access_token_expiry = None
if self.session_lifetime is not None:
if is_guest:
raise Exception(
"session_lifetime is not currently implemented for guest access"
)
valid_until_ms = self.clock.time_msec() + self.session_lifetime
access_token_expiry = self.clock.time_msec() + self.session_lifetime

refresh_token = None
refresh_token_id = None
Expand All @@ -808,33 +809,60 @@ class and RegisterDeviceReplicationServlet.
user_id, device_id, initial_display_name
)
if is_guest:
assert valid_until_ms is None
assert access_token_expiry is None
access_token = self.macaroon_gen.generate_guest_access_token(user_id)
else:
if should_issue_refresh_token:
now_ms = self.clock.time_msec()

# Set the expiry time of the refreshable access token
access_token_expiry = now_ms + self.refreshable_access_token_lifetime

# Set the refresh token expiry time (if configured)
refresh_token_expiry = None
if self.refresh_token_lifetime is not None:
refresh_token_expiry = now_ms + self.refresh_token_lifetime

# Set an ultimate session expiry time (if configured)
ultimate_session_expiry_ts = None
if self.session_lifetime is not None:
ultimate_session_expiry_ts = now_ms + self.session_lifetime

# Also ensure that the issued tokens don't outlive the
# session.
# (It would be weird to configure a homeserver with a shorter
# session lifetime than token lifetime, but may as well handle
# it.)
access_token_expiry = min(
access_token_expiry, ultimate_session_expiry_ts
)
if refresh_token_expiry is not None:
refresh_token_expiry = min(
refresh_token_expiry, ultimate_session_expiry_ts
)

(
refresh_token,
refresh_token_id,
) = await self._auth_handler.create_refresh_token_for_user_id(
user_id,
device_id=registered_device_id,
)
valid_until_ms = (
self.clock.time_msec() + self.refreshable_access_token_lifetime
expiry_ts=refresh_token_expiry,
ultimate_session_expiry_ts=ultimate_session_expiry_ts,
)

access_token = await self._auth_handler.create_access_token_for_user_id(
user_id,
device_id=registered_device_id,
valid_until_ms=valid_until_ms,
valid_until_ms=access_token_expiry,
is_appservice_ghost=is_appservice_ghost,
refresh_token_id=refresh_token_id,
)

return {
"device_id": registered_device_id,
"access_token": access_token,
"valid_until_ms": valid_until_ms,
"valid_until_ms": access_token_expiry,
"refresh_token": refresh_token,
}

Expand Down
52 changes: 37 additions & 15 deletions synapse/rest/client/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@

import logging
import re
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Tuple
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
)

from typing_extensions import TypedDict

Expand Down Expand Up @@ -458,6 +468,7 @@ def __init__(self, hs: "HomeServer"):
self.refreshable_access_token_lifetime = (
hs.config.registration.refreshable_access_token_lifetime
)
self.refresh_token_lifetime = hs.config.registration.refresh_token_lifetime

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

valid_until_ms = (
self._clock.time_msec() + self.refreshable_access_token_lifetime
)
access_token, refresh_token = await self._auth_handler.refresh_token(
token, valid_until_ms
)
expires_in_ms = valid_until_ms - self._clock.time_msec()
return (
200,
{
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in_ms": expires_in_ms,
},
now = self._clock.time_msec()
access_valid_until_ms = None
if self.refreshable_access_token_lifetime is not None:
access_valid_until_ms = now + self.refreshable_access_token_lifetime
refresh_valid_until_ms = None
if self.refresh_token_lifetime is not None:
refresh_valid_until_ms = now + self.refresh_token_lifetime

(
access_token,
refresh_token,
actual_access_token_expiry,
) = await self._auth_handler.refresh_token(
token, access_valid_until_ms, refresh_valid_until_ms
)

response: Dict[str, Union[str, int]] = {
"access_token": access_token,
"refresh_token": refresh_token,
}

# expires_in_ms is only present if the token expires
if actual_access_token_expiry is not None:
response["expires_in_ms"] = actual_access_token_expiry - now

return 200, response


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