diff --git a/changelog.d/12986.misc b/changelog.d/12986.misc
new file mode 100644
index 0000000000..937b888023
--- /dev/null
+++ b/changelog.d/12986.misc
@@ -0,0 +1 @@
+Refactor macaroon tokens generation and move the unsubscribe link in notification emails to `/_synapse/client/unsubscribe`.
diff --git a/synapse/api/auth.py b/synapse/api/auth.py
index c037ccb984..6e6eaf3805 100644
--- a/synapse/api/auth.py
+++ b/synapse/api/auth.py
@@ -33,8 +33,6 @@ from synapse.http.site import SynapseRequest
from synapse.logging.opentracing import active_span, force_tracing, start_active_span
from synapse.storage.databases.main.registration import TokenLookupResult
from synapse.types import Requester, UserID, create_requester
-from synapse.util.caches.lrucache import LruCache
-from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry
if TYPE_CHECKING:
from synapse.server import HomeServer
@@ -46,10 +44,6 @@ logger = logging.getLogger(__name__)
GUEST_DEVICE_ID = "guest_device"
-class _InvalidMacaroonException(Exception):
- pass
-
-
class Auth:
"""
This class contains functions for authenticating users of our client-server API.
@@ -61,14 +55,10 @@ class Auth:
self.store = hs.get_datastores().main
self._account_validity_handler = hs.get_account_validity_handler()
self._storage_controllers = hs.get_storage_controllers()
-
- self.token_cache: LruCache[str, Tuple[str, bool]] = LruCache(
- 10000, "token_cache"
- )
+ self._macaroon_generator = hs.get_macaroon_generator()
self._track_appservice_user_ips = hs.config.appservice.track_appservice_user_ips
self._track_puppeted_user_ips = hs.config.api.track_puppeted_user_ips
- self._macaroon_secret_key = hs.config.key.macaroon_secret_key
self._force_tracing_for_users = hs.config.tracing.force_tracing_for_users
async def check_user_in_room(
@@ -123,7 +113,6 @@ class Auth:
self,
request: SynapseRequest,
allow_guest: bool = False,
- rights: str = "access",
allow_expired: bool = False,
) -> Requester:
"""Get a registered user's ID.
@@ -132,7 +121,6 @@ class Auth:
request: An HTTP request with an access_token query parameter.
allow_guest: If False, will raise an AuthError if the user making the
request is a guest.
- rights: The operation being performed; the access token must allow this
allow_expired: If True, allow the request through even if the account
is expired, or session token lifetime has ended. Note that
/login will deliver access tokens regardless of expiration.
@@ -147,7 +135,7 @@ class Auth:
parent_span = active_span()
with start_active_span("get_user_by_req"):
requester = await self._wrapped_get_user_by_req(
- request, allow_guest, rights, allow_expired
+ request, allow_guest, allow_expired
)
if parent_span:
@@ -173,7 +161,6 @@ class Auth:
self,
request: SynapseRequest,
allow_guest: bool,
- rights: str,
allow_expired: bool,
) -> Requester:
"""Helper for get_user_by_req
@@ -211,7 +198,7 @@ class Auth:
return requester
user_info = await self.get_user_by_access_token(
- access_token, rights, allow_expired=allow_expired
+ access_token, allow_expired=allow_expired
)
token_id = user_info.token_id
is_guest = user_info.is_guest
@@ -391,15 +378,12 @@ class Auth:
async def get_user_by_access_token(
self,
token: str,
- rights: str = "access",
allow_expired: bool = False,
) -> TokenLookupResult:
"""Validate access token and get user_id from it
Args:
token: The access token to get the user by
- rights: The operation being performed; the access token must
- allow this
allow_expired: If False, raises an InvalidClientTokenError
if the token is expired
@@ -410,70 +394,55 @@ class Auth:
is invalid
"""
- if rights == "access":
- # First look in the database to see if the access token is present
- # as an opaque token.
- r = await self.store.get_user_by_access_token(token)
- if r:
- valid_until_ms = r.valid_until_ms
- if (
- not allow_expired
- and valid_until_ms is not None
- and valid_until_ms < self.clock.time_msec()
- ):
- # there was a valid access token, but it has expired.
- # soft-logout the user.
- raise InvalidClientTokenError(
- msg="Access token has expired", soft_logout=True
- )
+ # First look in the database to see if the access token is present
+ # as an opaque token.
+ r = await self.store.get_user_by_access_token(token)
+ if r:
+ valid_until_ms = r.valid_until_ms
+ if (
+ not allow_expired
+ and valid_until_ms is not None
+ and valid_until_ms < self.clock.time_msec()
+ ):
+ # there was a valid access token, but it has expired.
+ # soft-logout the user.
+ raise InvalidClientTokenError(
+ msg="Access token has expired", soft_logout=True
+ )
- return r
+ return r
# If the token isn't found in the database, then it could still be a
- # macaroon, so we check that here.
+ # macaroon for a guest, so we check that here.
try:
- user_id, guest = self._parse_and_validate_macaroon(token, rights)
-
- if rights == "access":
- if not guest:
- # non-guest access tokens must be in the database
- logger.warning("Unrecognised access token - not in store.")
- raise InvalidClientTokenError()
-
- # Guest access tokens are not stored in the database (there can
- # only be one access token per guest, anyway).
- #
- # In order to prevent guest access tokens being used as regular
- # user access tokens (and hence getting around the invalidation
- # process), we look up the user id and check that it is indeed
- # a guest user.
- #
- # It would of course be much easier to store guest access
- # tokens in the database as well, but that would break existing
- # guest tokens.
- stored_user = await self.store.get_user_by_id(user_id)
- if not stored_user:
- raise InvalidClientTokenError("Unknown user_id %s" % user_id)
- if not stored_user["is_guest"]:
- raise InvalidClientTokenError(
- "Guest access token used for regular user"
- )
-
- ret = TokenLookupResult(
- user_id=user_id,
- is_guest=True,
- # all guests get the same device id
- device_id=GUEST_DEVICE_ID,
+ user_id = self._macaroon_generator.verify_guest_token(token)
+
+ # Guest access tokens are not stored in the database (there can
+ # only be one access token per guest, anyway).
+ #
+ # In order to prevent guest access tokens being used as regular
+ # user access tokens (and hence getting around the invalidation
+ # process), we look up the user id and check that it is indeed
+ # a guest user.
+ #
+ # It would of course be much easier to store guest access
+ # tokens in the database as well, but that would break existing
+ # guest tokens.
+ stored_user = await self.store.get_user_by_id(user_id)
+ if not stored_user:
+ raise InvalidClientTokenError("Unknown user_id %s" % user_id)
+ if not stored_user["is_guest"]:
+ raise InvalidClientTokenError(
+ "Guest access token used for regular user"
)
- elif rights == "delete_pusher":
- # We don't store these tokens in the database
- ret = TokenLookupResult(user_id=user_id, is_guest=False)
- else:
- raise RuntimeError("Unknown rights setting %s", rights)
- return ret
+ return TokenLookupResult(
+ user_id=user_id,
+ is_guest=True,
+ # all guests get the same device id
+ device_id=GUEST_DEVICE_ID,
+ )
except (
- _InvalidMacaroonException,
pymacaroons.exceptions.MacaroonException,
TypeError,
ValueError,
@@ -485,78 +454,6 @@ class Auth:
)
raise InvalidClientTokenError("Invalid access token passed.")
- def _parse_and_validate_macaroon(
- self, token: str, rights: str = "access"
- ) -> Tuple[str, bool]:
- """Takes a macaroon and tries to parse and validate it. This is cached
- if and only if rights == access and there isn't an expiry.
-
- On invalid macaroon raises _InvalidMacaroonException
-
- Returns:
- (user_id, is_guest)
- """
- if rights == "access":
- cached = self.token_cache.get(token, None)
- if cached:
- return cached
-
- try:
- macaroon = pymacaroons.Macaroon.deserialize(token)
- except Exception: # deserialize can throw more-or-less anything
- # The access token doesn't look like a macaroon.
- raise _InvalidMacaroonException()
-
- try:
- user_id = get_value_from_macaroon(macaroon, "user_id")
-
- guest = False
- for caveat in macaroon.caveats:
- if caveat.caveat_id == "guest = true":
- guest = True
-
- self.validate_macaroon(macaroon, rights, user_id=user_id)
- except (
- pymacaroons.exceptions.MacaroonException,
- KeyError,
- TypeError,
- ValueError,
- ):
- raise InvalidClientTokenError("Invalid macaroon passed.")
-
- if rights == "access":
- self.token_cache[token] = (user_id, guest)
-
- return user_id, guest
-
- def validate_macaroon(
- self, macaroon: pymacaroons.Macaroon, type_string: str, user_id: str
- ) -> None:
- """
- validate that a Macaroon is understood by and was signed by this server.
-
- Args:
- macaroon: The macaroon to validate
- type_string: The kind of token required (e.g. "access", "delete_pusher")
- user_id: The user_id required
- """
- v = pymacaroons.Verifier()
-
- # the verifier runs a test for every caveat on the macaroon, to check
- # that it is met for the current request. Each caveat must match at
- # least one of the predicates specified by satisfy_exact or
- # specify_general.
- v.satisfy_exact("gen = 1")
- v.satisfy_exact("type = " + type_string)
- v.satisfy_exact("user_id = %s" % user_id)
- v.satisfy_exact("guest = true")
- satisfy_expiry(v, self.clock.time_msec)
-
- # access_tokens include a nonce for uniqueness: any value is acceptable
- v.satisfy_general(lambda c: c.startswith("nonce = "))
-
- v.verify(macaroon, self._macaroon_secret_key)
-
def get_appservice_by_req(self, request: SynapseRequest) -> ApplicationService:
token = self.get_access_token_from_request(request)
service = self.store.get_app_service_by_token(token)
diff --git a/synapse/config/key.py b/synapse/config/key.py
index ada65f6dd6..b250912e38 100644
--- a/synapse/config/key.py
+++ b/synapse/config/key.py
@@ -159,16 +159,18 @@ class KeyConfig(Config):
)
)
- self.macaroon_secret_key = config.get(
+ macaroon_secret_key: Optional[str] = config.get(
"macaroon_secret_key", self.root.registration.registration_shared_secret
)
- if not self.macaroon_secret_key:
+ if not macaroon_secret_key:
# Unfortunately, there are people out there that don't have this
# set. Lets just be "nice" and derive one from their secret key.
logger.warning("Config is missing macaroon_secret_key")
seed = bytes(self.signing_key[0])
self.macaroon_secret_key = hashlib.sha256(seed).digest()
+ else:
+ self.macaroon_secret_key = macaroon_secret_key.encode("utf-8")
# a secret which is used to calculate HMACs for form values, to stop
# falsification of values
diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py
index 60d13040a2..3d83236b0c 100644
--- a/synapse/handlers/auth.py
+++ b/synapse/handlers/auth.py
@@ -37,9 +37,7 @@ from typing import (
import attr
import bcrypt
-import pymacaroons
import unpaddedbase64
-from pymacaroons.exceptions import MacaroonVerificationFailedException
from twisted.internet.defer import CancelledError
from twisted.web.server import Request
@@ -69,7 +67,7 @@ from synapse.storage.roommember import ProfileInfo
from synapse.types import JsonDict, Requester, UserID
from synapse.util import stringutils as stringutils
from synapse.util.async_helpers import delay_cancellation, maybe_awaitable
-from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry
+from synapse.util.macaroons import LoginTokenAttributes
from synapse.util.msisdn import phone_number_to_msisdn
from synapse.util.stringutils import base62_encode
from synapse.util.threepids import canonicalise_email
@@ -180,19 +178,6 @@ class SsoLoginExtraAttributes:
extra_attributes: JsonDict
-@attr.s(slots=True, frozen=True, auto_attribs=True)
-class LoginTokenAttributes:
- """Data we store in a short-term login token"""
-
- user_id: str
-
- auth_provider_id: str
- """The SSO Identity Provider that the user authenticated with, to get this token."""
-
- auth_provider_session_id: Optional[str]
- """The session ID advertised by the SSO Identity Provider."""
-
-
class AuthHandler:
SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000
@@ -1831,98 +1816,6 @@ class AuthHandler:
return urllib.parse.urlunparse(url_parts)
-@attr.s(slots=True, auto_attribs=True)
-class MacaroonGenerator:
- hs: "HomeServer"
-
- def generate_guest_access_token(self, user_id: str) -> str:
- macaroon = self._generate_base_macaroon(user_id)
- macaroon.add_first_party_caveat("type = access")
- # Include a nonce, to make sure that each login gets a different
- # access token.
- macaroon.add_first_party_caveat(
- "nonce = %s" % (stringutils.random_string_with_symbols(16),)
- )
- macaroon.add_first_party_caveat("guest = true")
- return macaroon.serialize()
-
- def generate_short_term_login_token(
- self,
- user_id: str,
- auth_provider_id: str,
- auth_provider_session_id: Optional[str] = None,
- duration_in_ms: int = (2 * 60 * 1000),
- ) -> str:
- macaroon = self._generate_base_macaroon(user_id)
- macaroon.add_first_party_caveat("type = login")
- now = self.hs.get_clock().time_msec()
- expiry = now + duration_in_ms
- macaroon.add_first_party_caveat("time < %d" % (expiry,))
- macaroon.add_first_party_caveat("auth_provider_id = %s" % (auth_provider_id,))
- if auth_provider_session_id is not None:
- macaroon.add_first_party_caveat(
- "auth_provider_session_id = %s" % (auth_provider_session_id,)
- )
- return macaroon.serialize()
-
- def verify_short_term_login_token(self, token: str) -> LoginTokenAttributes:
- """Verify a short-term-login macaroon
-
- Checks that the given token is a valid, unexpired short-term-login token
- minted by this server.
-
- Args:
- token: the login token to verify
-
- Returns:
- the user_id that this token is valid for
-
- Raises:
- MacaroonVerificationFailedException if the verification failed
- """
- macaroon = pymacaroons.Macaroon.deserialize(token)
- user_id = get_value_from_macaroon(macaroon, "user_id")
- auth_provider_id = get_value_from_macaroon(macaroon, "auth_provider_id")
-
- auth_provider_session_id: Optional[str] = None
- try:
- auth_provider_session_id = get_value_from_macaroon(
- macaroon, "auth_provider_session_id"
- )
- except MacaroonVerificationFailedException:
- pass
-
- v = pymacaroons.Verifier()
- v.satisfy_exact("gen = 1")
- v.satisfy_exact("type = login")
- v.satisfy_general(lambda c: c.startswith("user_id = "))
- v.satisfy_general(lambda c: c.startswith("auth_provider_id = "))
- v.satisfy_general(lambda c: c.startswith("auth_provider_session_id = "))
- satisfy_expiry(v, self.hs.get_clock().time_msec)
- v.verify(macaroon, self.hs.config.key.macaroon_secret_key)
-
- return LoginTokenAttributes(
- user_id=user_id,
- auth_provider_id=auth_provider_id,
- auth_provider_session_id=auth_provider_session_id,
- )
-
- def generate_delete_pusher_token(self, user_id: str) -> str:
- macaroon = self._generate_base_macaroon(user_id)
- macaroon.add_first_party_caveat("type = delete_pusher")
- return macaroon.serialize()
-
- def _generate_base_macaroon(self, user_id: str) -> pymacaroons.Macaroon:
- macaroon = pymacaroons.Macaroon(
- location=self.hs.config.server.server_name,
- identifier="key",
- key=self.hs.config.key.macaroon_secret_key,
- )
- macaroon.add_first_party_caveat("gen = 1")
- macaroon.add_first_party_caveat("user_id = %s" % (user_id,))
- return macaroon
-
-
def load_legacy_password_auth_providers(hs: "HomeServer") -> None:
module_api = hs.get_module_api()
for module, config in hs.config.authproviders.password_providers:
diff --git a/synapse/handlers/oidc.py b/synapse/handlers/oidc.py
index 9de61d554f..d7a8226900 100644
--- a/synapse/handlers/oidc.py
+++ b/synapse/handlers/oidc.py
@@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Any, Dict, Generic, List, Optional, TypeVar, U
from urllib.parse import urlencode, urlparse
import attr
-import pymacaroons
from authlib.common.security import generate_token
from authlib.jose import JsonWebToken, jwt
from authlib.oauth2.auth import ClientAuth
@@ -44,7 +43,7 @@ from synapse.logging.context import make_deferred_yieldable
from synapse.types import JsonDict, UserID, map_username_to_mxid_localpart
from synapse.util import Clock, json_decoder
from synapse.util.caches.cached_call import RetryOnExceptionCachedCall
-from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry
+from synapse.util.macaroons import MacaroonGenerator, OidcSessionData
from synapse.util.templates import _localpart_from_email_filter
if TYPE_CHECKING:
@@ -105,9 +104,10 @@ class OidcHandler:
# we should not have been instantiated if there is no configured provider.
assert provider_confs
- self._token_generator = OidcSessionTokenGenerator(hs)
+ self._macaroon_generator = hs.get_macaroon_generator()
self._providers: Dict[str, "OidcProvider"] = {
- p.idp_id: OidcProvider(hs, self._token_generator, p) for p in provider_confs
+ p.idp_id: OidcProvider(hs, self._macaroon_generator, p)
+ for p in provider_confs
}
async def load_metadata(self) -> None:
@@ -216,7 +216,7 @@ class OidcHandler:
# Deserialize the session token and verify it.
try:
- session_data = self._token_generator.verify_oidc_session_token(
+ session_data = self._macaroon_generator.verify_oidc_session_token(
session, state
)
except (MacaroonInitException, MacaroonDeserializationException, KeyError) as e:
@@ -271,12 +271,12 @@ class OidcProvider:
def __init__(
self,
hs: "HomeServer",
- token_generator: "OidcSessionTokenGenerator",
+ macaroon_generator: MacaroonGenerator,
provider: OidcProviderConfig,
):
self._store = hs.get_datastores().main
- self._token_generator = token_generator
+ self._macaroon_generaton = macaroon_generator
self._config = provider
self._callback_url: str = hs.config.oidc.oidc_callback_url
@@ -761,7 +761,7 @@ class OidcProvider:
if not client_redirect_url:
client_redirect_url = b""
- cookie = self._token_generator.generate_oidc_session_token(
+ cookie = self._macaroon_generaton.generate_oidc_session_token(
state=state,
session_data=OidcSessionData(
idp_id=self.idp_id,
@@ -1112,121 +1112,6 @@ class JwtClientSecret:
return self._cached_secret
-class OidcSessionTokenGenerator:
- """Methods for generating and checking OIDC Session cookies."""
-
- def __init__(self, hs: "HomeServer"):
- self._clock = hs.get_clock()
- self._server_name = hs.hostname
- self._macaroon_secret_key = hs.config.key.macaroon_secret_key
-
- def generate_oidc_session_token(
- self,
- state: str,
- session_data: "OidcSessionData",
- duration_in_ms: int = (60 * 60 * 1000),
- ) -> str:
- """Generates a signed token storing data about an OIDC session.
-
- When Synapse initiates an authorization flow, it creates a random state
- and a random nonce. Those parameters are given to the provider and
- should be verified when the client comes back from the provider.
- It is also used to store the client_redirect_url, which is used to
- complete the SSO login flow.
-
- Args:
- state: The ``state`` parameter passed to the OIDC provider.
- session_data: data to include in the session token.
- duration_in_ms: An optional duration for the token in milliseconds.
- Defaults to an hour.
-
- Returns:
- A signed macaroon token with the session information.
- """
- macaroon = pymacaroons.Macaroon(
- location=self._server_name,
- identifier="key",
- key=self._macaroon_secret_key,
- )
- macaroon.add_first_party_caveat("gen = 1")
- macaroon.add_first_party_caveat("type = session")
- macaroon.add_first_party_caveat("state = %s" % (state,))
- macaroon.add_first_party_caveat("idp_id = %s" % (session_data.idp_id,))
- macaroon.add_first_party_caveat("nonce = %s" % (session_data.nonce,))
- macaroon.add_first_party_caveat(
- "client_redirect_url = %s" % (session_data.client_redirect_url,)
- )
- macaroon.add_first_party_caveat(
- "ui_auth_session_id = %s" % (session_data.ui_auth_session_id,)
- )
- now = self._clock.time_msec()
- expiry = now + duration_in_ms
- macaroon.add_first_party_caveat("time < %d" % (expiry,))
-
- return macaroon.serialize()
-
- def verify_oidc_session_token(
- self, session: bytes, state: str
- ) -> "OidcSessionData":
- """Verifies and extract an OIDC session token.
-
- This verifies that a given session token was issued by this homeserver
- and extract the nonce and client_redirect_url caveats.
-
- Args:
- session: The session token to verify
- state: The state the OIDC provider gave back
-
- Returns:
- The data extracted from the session cookie
-
- Raises:
- KeyError if an expected caveat is missing from the macaroon.
- """
- macaroon = pymacaroons.Macaroon.deserialize(session)
-
- v = pymacaroons.Verifier()
- v.satisfy_exact("gen = 1")
- v.satisfy_exact("type = session")
- v.satisfy_exact("state = %s" % (state,))
- v.satisfy_general(lambda c: c.startswith("nonce = "))
- v.satisfy_general(lambda c: c.startswith("idp_id = "))
- v.satisfy_general(lambda c: c.startswith("client_redirect_url = "))
- v.satisfy_general(lambda c: c.startswith("ui_auth_session_id = "))
- satisfy_expiry(v, self._clock.time_msec)
-
- v.verify(macaroon, self._macaroon_secret_key)
-
- # Extract the session data from the token.
- nonce = get_value_from_macaroon(macaroon, "nonce")
- idp_id = get_value_from_macaroon(macaroon, "idp_id")
- client_redirect_url = get_value_from_macaroon(macaroon, "client_redirect_url")
- ui_auth_session_id = get_value_from_macaroon(macaroon, "ui_auth_session_id")
- return OidcSessionData(
- nonce=nonce,
- idp_id=idp_id,
- client_redirect_url=client_redirect_url,
- ui_auth_session_id=ui_auth_session_id,
- )
-
-
-@attr.s(frozen=True, slots=True, auto_attribs=True)
-class OidcSessionData:
- """The attributes which are stored in a OIDC session cookie"""
-
- # the Identity Provider being used
- idp_id: str
-
- # The `nonce` parameter passed to the OIDC provider.
- nonce: str
-
- # The URL the client gave when it initiated the flow. ("" if this is a UI Auth)
- client_redirect_url: str
-
- # The session ID of the ongoing UI Auth ("" if this is a login)
- ui_auth_session_id: str
-
-
class UserAttributeDict(TypedDict):
localpart: Optional[str]
confirm_localpart: bool
diff --git a/synapse/push/mailer.py b/synapse/push/mailer.py
index 015c19b2d9..c2575ba3d9 100644
--- a/synapse/push/mailer.py
+++ b/synapse/push/mailer.py
@@ -860,13 +860,14 @@ class Mailer:
A link to unsubscribe from email notifications.
"""
params = {
- "access_token": self.macaroon_gen.generate_delete_pusher_token(user_id),
+ "access_token": self.macaroon_gen.generate_delete_pusher_token(
+ user_id, app_id, email_address
+ ),
"app_id": app_id,
"pushkey": email_address,
}
- # XXX: make r0 once API is stable
- return "%s_matrix/client/unstable/pushers/remove?%s" % (
+ return "%s_synapse/client/unsubscribe?%s" % (
self.hs.config.server.public_baseurl,
urllib.parse.urlencode(params),
)
diff --git a/synapse/rest/client/pusher.py b/synapse/rest/client/pusher.py
index d6487c31dd..9a1f10f4be 100644
--- a/synapse/rest/client/pusher.py
+++ b/synapse/rest/client/pusher.py
@@ -1,4 +1,5 @@
# Copyright 2014-2016 OpenMarket Ltd
+# Copyright 2022 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -15,17 +16,17 @@
import logging
from typing import TYPE_CHECKING, Tuple
-from synapse.api.errors import Codes, StoreError, SynapseError
-from synapse.http.server import HttpServer, respond_with_html_bytes
+from synapse.api.errors import Codes, SynapseError
+from synapse.http.server import HttpServer
from synapse.http.servlet import (
RestServlet,
assert_params_in_dict,
parse_json_object_from_request,
- parse_string,
)
from synapse.http.site import SynapseRequest
from synapse.push import PusherConfigException
from synapse.rest.client._base import client_patterns
+from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource
from synapse.types import JsonDict
if TYPE_CHECKING:
@@ -132,48 +133,21 @@ class PushersSetRestServlet(RestServlet):
return 200, {}
-class PushersRemoveRestServlet(RestServlet):
+class LegacyPushersRemoveRestServlet(UnsubscribeResource, RestServlet):
"""
- To allow pusher to be delete by clicking a link (ie. GET request)
+ A servlet to handle legacy "email unsubscribe" links, forwarding requests to the ``UnsubscribeResource``
+
+ This should be kept for some time, so unsubscribe links in past emails stay valid.
"""
- PATTERNS = client_patterns("/pushers/remove$", v1=True)
- SUCCESS_HTML = b"<html><body>You have been unsubscribed</body><html>"
-
- def __init__(self, hs: "HomeServer"):
- super().__init__()
- self.hs = hs
- self.notifier = hs.get_notifier()
- self.auth = hs.get_auth()
- self.pusher_pool = self.hs.get_pusherpool()
+ PATTERNS = client_patterns("/pushers/remove$", releases=[], v1=False, unstable=True)
async def on_GET(self, request: SynapseRequest) -> None:
- requester = await self.auth.get_user_by_req(request, rights="delete_pusher")
- user = requester.user
-
- app_id = parse_string(request, "app_id", required=True)
- pushkey = parse_string(request, "pushkey", required=True)
-
- try:
- await self.pusher_pool.remove_pusher(
- app_id=app_id, pushkey=pushkey, user_id=user.to_string()
- )
- except StoreError as se:
- if se.code != 404:
- # This is fine: they're already unsubscribed
- raise
-
- self.notifier.on_new_replication_data()
-
- respond_with_html_bytes(
- request,
- 200,
- PushersRemoveRestServlet.SUCCESS_HTML,
- )
- return None
+ # Forward the request to the UnsubscribeResource
+ await self._async_render(request)
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
PushersRestServlet(hs).register(http_server)
PushersSetRestServlet(hs).register(http_server)
- PushersRemoveRestServlet(hs).register(http_server)
+ LegacyPushersRemoveRestServlet(hs).register(http_server)
diff --git a/synapse/rest/synapse/client/__init__.py b/synapse/rest/synapse/client/__init__.py
index 6ad558f5d1..e55924f597 100644
--- a/synapse/rest/synapse/client/__init__.py
+++ b/synapse/rest/synapse/client/__init__.py
@@ -20,6 +20,7 @@ from synapse.rest.synapse.client.new_user_consent import NewUserConsentResource
from synapse.rest.synapse.client.pick_idp import PickIdpResource
from synapse.rest.synapse.client.pick_username import pick_username_resource
from synapse.rest.synapse.client.sso_register import SsoRegisterResource
+from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource
if TYPE_CHECKING:
from synapse.server import HomeServer
@@ -41,6 +42,8 @@ def build_synapse_client_resource_tree(hs: "HomeServer") -> Mapping[str, Resourc
"/_synapse/client/pick_username": pick_username_resource(hs),
"/_synapse/client/new_user_consent": NewUserConsentResource(hs),
"/_synapse/client/sso_register": SsoRegisterResource(hs),
+ # Unsubscribe to notification emails link
+ "/_synapse/client/unsubscribe": UnsubscribeResource(hs),
}
# provider-specific SSO bits. Only load these if they are enabled, since they
diff --git a/synapse/rest/synapse/client/unsubscribe.py b/synapse/rest/synapse/client/unsubscribe.py
new file mode 100644
index 0000000000..60321018f9
--- /dev/null
+++ b/synapse/rest/synapse/client/unsubscribe.py
@@ -0,0 +1,64 @@
+# Copyright 2022 The Matrix.org Foundation C.I.C.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import TYPE_CHECKING
+
+from synapse.api.errors import StoreError
+from synapse.http.server import DirectServeHtmlResource, respond_with_html_bytes
+from synapse.http.servlet import parse_string
+from synapse.http.site import SynapseRequest
+
+if TYPE_CHECKING:
+ from synapse.server import HomeServer
+
+
+class UnsubscribeResource(DirectServeHtmlResource):
+ """
+ To allow pusher to be delete by clicking a link (ie. GET request)
+ """
+
+ SUCCESS_HTML = b"<html><body>You have been unsubscribed</body><html>"
+
+ def __init__(self, hs: "HomeServer"):
+ super().__init__()
+ self.notifier = hs.get_notifier()
+ self.auth = hs.get_auth()
+ self.pusher_pool = hs.get_pusherpool()
+ self.macaroon_generator = hs.get_macaroon_generator()
+
+ async def _async_render_GET(self, request: SynapseRequest) -> None:
+ token = parse_string(request, "access_token", required=True)
+ app_id = parse_string(request, "app_id", required=True)
+ pushkey = parse_string(request, "pushkey", required=True)
+
+ user_id = self.macaroon_generator.verify_delete_pusher_token(
+ token, app_id, pushkey
+ )
+
+ try:
+ await self.pusher_pool.remove_pusher(
+ app_id=app_id, pushkey=pushkey, user_id=user_id
+ )
+ except StoreError as se:
+ if se.code != 404:
+ # This is fine: they're already unsubscribed
+ raise
+
+ self.notifier.on_new_replication_data()
+
+ respond_with_html_bytes(
+ request,
+ 200,
+ UnsubscribeResource.SUCCESS_HTML,
+ )
diff --git a/synapse/server.py b/synapse/server.py
index a6a415aeab..181984a1a4 100644
--- a/synapse/server.py
+++ b/synapse/server.py
@@ -56,7 +56,7 @@ from synapse.handlers.account_data import AccountDataHandler
from synapse.handlers.account_validity import AccountValidityHandler
from synapse.handlers.admin import AdminHandler
from synapse.handlers.appservice import ApplicationServicesHandler
-from synapse.handlers.auth import AuthHandler, MacaroonGenerator, PasswordAuthProvider
+from synapse.handlers.auth import AuthHandler, PasswordAuthProvider
from synapse.handlers.cas import CasHandler
from synapse.handlers.deactivate_account import DeactivateAccountHandler
from synapse.handlers.device import DeviceHandler, DeviceWorkerHandler
@@ -130,6 +130,7 @@ from synapse.streams.events import EventSources
from synapse.types import DomainSpecificString, ISynapseReactor
from synapse.util import Clock
from synapse.util.distributor import Distributor
+from synapse.util.macaroons import MacaroonGenerator
from synapse.util.ratelimitutils import FederationRateLimiter
from synapse.util.stringutils import random_string
@@ -492,7 +493,9 @@ class HomeServer(metaclass=abc.ABCMeta):
@cache_in_self
def get_macaroon_generator(self) -> MacaroonGenerator:
- return MacaroonGenerator(self)
+ return MacaroonGenerator(
+ self.get_clock(), self.hostname, self.config.key.macaroon_secret_key
+ )
@cache_in_self
def get_device_handler(self):
diff --git a/synapse/util/macaroons.py b/synapse/util/macaroons.py
index 84e4f6ff55..df77edcce2 100644
--- a/synapse/util/macaroons.py
+++ b/synapse/util/macaroons.py
@@ -17,8 +17,14 @@
from typing import Callable, Optional
+import attr
import pymacaroons
from pymacaroons.exceptions import MacaroonVerificationFailedException
+from typing_extensions import Literal
+
+from synapse.util import Clock, stringutils
+
+MacaroonType = Literal["access", "delete_pusher", "session", "login"]
def get_value_from_macaroon(macaroon: pymacaroons.Macaroon, key: str) -> str:
@@ -86,3 +92,305 @@ def satisfy_expiry(v: pymacaroons.Verifier, get_time_ms: Callable[[], int]) -> N
return time_msec < expiry
v.satisfy_general(verify_expiry_caveat)
+
+
+@attr.s(frozen=True, slots=True, auto_attribs=True)
+class OidcSessionData:
+ """The attributes which are stored in a OIDC session cookie"""
+
+ idp_id: str
+ """The Identity Provider being used"""
+
+ nonce: str
+ """The `nonce` parameter passed to the OIDC provider."""
+
+ client_redirect_url: str
+ """The URL the client gave when it initiated the flow. ("" if this is a UI Auth)"""
+
+ ui_auth_session_id: str
+ """The session ID of the ongoing UI Auth ("" if this is a login)"""
+
+
+@attr.s(slots=True, frozen=True, auto_attribs=True)
+class LoginTokenAttributes:
+ """Data we store in a short-term login token"""
+
+ user_id: str
+
+ auth_provider_id: str
+ """The SSO Identity Provider that the user authenticated with, to get this token."""
+
+ auth_provider_session_id: Optional[str]
+ """The session ID advertised by the SSO Identity Provider."""
+
+
+class MacaroonGenerator:
+ def __init__(self, clock: Clock, location: str, secret_key: bytes):
+ self._clock = clock
+ self._location = location
+ self._secret_key = secret_key
+
+ def generate_guest_access_token(self, user_id: str) -> str:
+ """Generate a guest access token for the given user ID
+
+ Args:
+ user_id: The user ID for which the guest token should be generated.
+
+ Returns:
+ A signed access token for that guest user.
+ """
+ nonce = stringutils.random_string_with_symbols(16)
+ macaroon = self._generate_base_macaroon("access")
+ macaroon.add_first_party_caveat(f"user_id = {user_id}")
+ macaroon.add_first_party_caveat(f"nonce = {nonce}")
+ macaroon.add_first_party_caveat("guest = true")
+ return macaroon.serialize()
+
+ def generate_delete_pusher_token(
+ self, user_id: str, app_id: str, pushkey: str
+ ) -> str:
+ """Generate a signed token used for unsubscribing from email notifications
+
+ Args:
+ user_id: The user for which this token will be valid.
+ app_id: The app_id for this pusher.
+ pushkey: The unique identifier of this pusher.
+
+ Returns:
+ A signed token which can be used in unsubscribe links.
+ """
+ macaroon = self._generate_base_macaroon("delete_pusher")
+ macaroon.add_first_party_caveat(f"user_id = {user_id}")
+ macaroon.add_first_party_caveat(f"app_id = {app_id}")
+ macaroon.add_first_party_caveat(f"pushkey = {pushkey}")
+ return macaroon.serialize()
+
+ def generate_short_term_login_token(
+ self,
+ user_id: str,
+ auth_provider_id: str,
+ auth_provider_session_id: Optional[str] = None,
+ duration_in_ms: int = (2 * 60 * 1000),
+ ) -> str:
+ """Generate a short-term login token used during SSO logins
+
+ Args:
+ user_id: The user for which the token is valid.
+ auth_provider_id: The SSO IdP the user used.
+ auth_provider_session_id: The session ID got during login from the SSO IdP.
+
+ Returns:
+ A signed token valid for using as a ``m.login.token`` token.
+ """
+ now = self._clock.time_msec()
+ expiry = now + duration_in_ms
+ macaroon = self._generate_base_macaroon("login")
+ macaroon.add_first_party_caveat(f"user_id = {user_id}")
+ macaroon.add_first_party_caveat(f"time < {expiry}")
+ macaroon.add_first_party_caveat(f"auth_provider_id = {auth_provider_id}")
+ if auth_provider_session_id is not None:
+ macaroon.add_first_party_caveat(
+ f"auth_provider_session_id = {auth_provider_session_id}"
+ )
+ return macaroon.serialize()
+
+ def generate_oidc_session_token(
+ self,
+ state: str,
+ session_data: OidcSessionData,
+ duration_in_ms: int = (60 * 60 * 1000),
+ ) -> str:
+ """Generates a signed token storing data about an OIDC session.
+
+ When Synapse initiates an authorization flow, it creates a random state
+ and a random nonce. Those parameters are given to the provider and
+ should be verified when the client comes back from the provider.
+ It is also used to store the client_redirect_url, which is used to
+ complete the SSO login flow.
+
+ Args:
+ state: The ``state`` parameter passed to the OIDC provider.
+ session_data: data to include in the session token.
+ duration_in_ms: An optional duration for the token in milliseconds.
+ Defaults to an hour.
+
+ Returns:
+ A signed macaroon token with the session information.
+ """
+ now = self._clock.time_msec()
+ expiry = now + duration_in_ms
+ macaroon = self._generate_base_macaroon("session")
+ macaroon.add_first_party_caveat(f"state = {state}")
+ macaroon.add_first_party_caveat(f"idp_id = {session_data.idp_id}")
+ macaroon.add_first_party_caveat(f"nonce = {session_data.nonce}")
+ macaroon.add_first_party_caveat(
+ f"client_redirect_url = {session_data.client_redirect_url}"
+ )
+ macaroon.add_first_party_caveat(
+ f"ui_auth_session_id = {session_data.ui_auth_session_id}"
+ )
+ macaroon.add_first_party_caveat(f"time < {expiry}")
+
+ return macaroon.serialize()
+
+ def verify_short_term_login_token(self, token: str) -> LoginTokenAttributes:
+ """Verify a short-term-login macaroon
+
+ Checks that the given token is a valid, unexpired short-term-login token
+ minted by this server.
+
+ Args:
+ token: The login token to verify.
+
+ Returns:
+ A set of attributes carried by this token, including the
+ ``user_id`` and informations about the SSO IDP used during that
+ login.
+
+ Raises:
+ MacaroonVerificationFailedException if the verification failed
+ """
+ macaroon = pymacaroons.Macaroon.deserialize(token)
+
+ v = self._base_verifier("login")
+ v.satisfy_general(lambda c: c.startswith("user_id = "))
+ v.satisfy_general(lambda c: c.startswith("auth_provider_id = "))
+ v.satisfy_general(lambda c: c.startswith("auth_provider_session_id = "))
+ satisfy_expiry(v, self._clock.time_msec)
+ v.verify(macaroon, self._secret_key)
+
+ user_id = get_value_from_macaroon(macaroon, "user_id")
+ auth_provider_id = get_value_from_macaroon(macaroon, "auth_provider_id")
+
+ auth_provider_session_id: Optional[str] = None
+ try:
+ auth_provider_session_id = get_value_from_macaroon(
+ macaroon, "auth_provider_session_id"
+ )
+ except MacaroonVerificationFailedException:
+ pass
+
+ return LoginTokenAttributes(
+ user_id=user_id,
+ auth_provider_id=auth_provider_id,
+ auth_provider_session_id=auth_provider_session_id,
+ )
+
+ def verify_guest_token(self, token: str) -> str:
+ """Verify a guest access token macaroon
+
+ Checks that the given token is a valid, unexpired guest access token
+ minted by this server.
+
+ Args:
+ token: The access token to verify.
+
+ Returns:
+ The ``user_id`` that this token is valid for.
+
+ Raises:
+ MacaroonVerificationFailedException if the verification failed
+ """
+ macaroon = pymacaroons.Macaroon.deserialize(token)
+ user_id = get_value_from_macaroon(macaroon, "user_id")
+
+ # At some point, Synapse would generate macaroons without the "guest"
+ # caveat for regular users. Because of how macaroon verification works,
+ # to avoid validating those as guest tokens, we explicitely verify if
+ # the macaroon includes the "guest = true" caveat.
+ is_guest = any(
+ (caveat.caveat_id == "guest = true" for caveat in macaroon.caveats)
+ )
+
+ if not is_guest:
+ raise MacaroonVerificationFailedException("Macaroon is not a guest token")
+
+ v = self._base_verifier("access")
+ v.satisfy_exact("guest = true")
+ v.satisfy_general(lambda c: c.startswith("user_id = "))
+ v.satisfy_general(lambda c: c.startswith("nonce = "))
+ satisfy_expiry(v, self._clock.time_msec)
+ v.verify(macaroon, self._secret_key)
+
+ return user_id
+
+ def verify_delete_pusher_token(self, token: str, app_id: str, pushkey: str) -> str:
+ """Verify a token from an email unsubscribe link
+
+ Args:
+ token: The token to verify.
+ app_id: The app_id of the pusher to delete.
+ pushkey: The unique identifier of the pusher to delete.
+
+ Return:
+ The ``user_id`` for which this token is valid.
+
+ Raises:
+ MacaroonVerificationFailedException if the verification failed
+ """
+ macaroon = pymacaroons.Macaroon.deserialize(token)
+ user_id = get_value_from_macaroon(macaroon, "user_id")
+
+ v = self._base_verifier("delete_pusher")
+ v.satisfy_exact(f"app_id = {app_id}")
+ v.satisfy_exact(f"pushkey = {pushkey}")
+ v.satisfy_general(lambda c: c.startswith("user_id = "))
+ v.verify(macaroon, self._secret_key)
+
+ return user_id
+
+ def verify_oidc_session_token(self, session: bytes, state: str) -> OidcSessionData:
+ """Verifies and extract an OIDC session token.
+
+ This verifies that a given session token was issued by this homeserver
+ and extract the nonce and client_redirect_url caveats.
+
+ Args:
+ session: The session token to verify
+ state: The state the OIDC provider gave back
+
+ Returns:
+ The data extracted from the session cookie
+
+ Raises:
+ KeyError if an expected caveat is missing from the macaroon.
+ """
+ macaroon = pymacaroons.Macaroon.deserialize(session)
+
+ v = self._base_verifier("session")
+ v.satisfy_exact(f"state = {state}")
+ v.satisfy_general(lambda c: c.startswith("nonce = "))
+ v.satisfy_general(lambda c: c.startswith("idp_id = "))
+ v.satisfy_general(lambda c: c.startswith("client_redirect_url = "))
+ v.satisfy_general(lambda c: c.startswith("ui_auth_session_id = "))
+ satisfy_expiry(v, self._clock.time_msec)
+
+ v.verify(macaroon, self._secret_key)
+
+ # Extract the session data from the token.
+ nonce = get_value_from_macaroon(macaroon, "nonce")
+ idp_id = get_value_from_macaroon(macaroon, "idp_id")
+ client_redirect_url = get_value_from_macaroon(macaroon, "client_redirect_url")
+ ui_auth_session_id = get_value_from_macaroon(macaroon, "ui_auth_session_id")
+ return OidcSessionData(
+ nonce=nonce,
+ idp_id=idp_id,
+ client_redirect_url=client_redirect_url,
+ ui_auth_session_id=ui_auth_session_id,
+ )
+
+ def _generate_base_macaroon(self, type: MacaroonType) -> pymacaroons.Macaroon:
+ macaroon = pymacaroons.Macaroon(
+ location=self._location,
+ identifier="key",
+ key=self._secret_key,
+ )
+ macaroon.add_first_party_caveat("gen = 1")
+ macaroon.add_first_party_caveat(f"type = {type}")
+ return macaroon
+
+ def _base_verifier(self, type: MacaroonType) -> pymacaroons.Verifier:
+ v = pymacaroons.Verifier()
+ v.satisfy_exact("gen = 1")
+ v.satisfy_exact(f"type = {type}")
+ return v
diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py
index 54af9089e9..dfcfaf79b6 100644
--- a/tests/api/test_auth.py
+++ b/tests/api/test_auth.py
@@ -313,9 +313,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
self.assertEqual(self.store.insert_client_ip.call_count, 2)
def test_get_user_from_macaroon(self):
- self.store.get_user_by_access_token = simple_async_mock(
- TokenLookupResult(user_id="@baldrick:matrix.org", device_id="device")
- )
+ self.store.get_user_by_access_token = simple_async_mock(None)
user_id = "@baldrick:matrix.org"
macaroon = pymacaroons.Macaroon(
@@ -323,17 +321,14 @@ class AuthTestCase(unittest.HomeserverTestCase):
identifier="key",
key=self.hs.config.key.macaroon_secret_key,
)
+ # "Legacy" macaroons should not work for regular users not in the database
macaroon.add_first_party_caveat("gen = 1")
macaroon.add_first_party_caveat("type = access")
macaroon.add_first_party_caveat("user_id = %s" % (user_id,))
- user_info = self.get_success(
- self.auth.get_user_by_access_token(macaroon.serialize())
+ serialized = macaroon.serialize()
+ self.get_failure(
+ self.auth.get_user_by_access_token(serialized), InvalidClientTokenError
)
- self.assertEqual(user_id, user_info.user_id)
-
- # TODO: device_id should come from the macaroon, but currently comes
- # from the db.
- self.assertEqual(user_info.device_id, "device")
def test_get_guest_user_from_macaroon(self):
self.store.get_user_by_id = simple_async_mock({"is_guest": True})
diff --git a/tests/handlers/test_oidc.py b/tests/handlers/test_oidc.py
index 1231aed944..e6cd3af7b7 100644
--- a/tests/handlers/test_oidc.py
+++ b/tests/handlers/test_oidc.py
@@ -25,7 +25,7 @@ from synapse.handlers.sso import MappingException
from synapse.server import HomeServer
from synapse.types import JsonDict, UserID
from synapse.util import Clock
-from synapse.util.macaroons import get_value_from_macaroon
+from synapse.util.macaroons import OidcSessionData, get_value_from_macaroon
from tests.test_utils import FakeResponse, get_awaitable_result, simple_async_mock
from tests.unittest import HomeserverTestCase, override_config
@@ -1227,7 +1227,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
) -> str:
from synapse.handlers.oidc import OidcSessionData
- return self.handler._token_generator.generate_oidc_session_token(
+ return self.handler._macaroon_generator.generate_oidc_session_token(
state=state,
session_data=OidcSessionData(
idp_id="oidc",
@@ -1251,7 +1251,6 @@ async def _make_callback_with_userinfo(
userinfo: the OIDC userinfo dict
client_redirect_url: the URL to redirect to on success.
"""
- from synapse.handlers.oidc import OidcSessionData
handler = hs.get_oidc_handler()
provider = handler._providers["oidc"]
@@ -1260,7 +1259,7 @@ async def _make_callback_with_userinfo(
provider._fetch_userinfo = simple_async_mock(return_value=userinfo) # type: ignore[assignment]
state = "state"
- session = handler._token_generator.generate_oidc_session_token(
+ session = handler._macaroon_generator.generate_oidc_session_token(
state=state,
session_data=OidcSessionData(
idp_id="oidc",
diff --git a/tests/test_state.py b/tests/test_state.py
index 95f81bebae..b005dd8d0f 100644
--- a/tests/test_state.py
+++ b/tests/test_state.py
@@ -11,7 +11,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Collection, Dict, List, Optional
+from typing import Collection, Dict, List, Optional, cast
from unittest.mock import Mock
from twisted.internet import defer
@@ -22,6 +22,8 @@ from synapse.api.room_versions import RoomVersions
from synapse.events import make_event_from_dict
from synapse.events.snapshot import EventContext
from synapse.state import StateHandler, StateResolutionHandler
+from synapse.util import Clock
+from synapse.util.macaroons import MacaroonGenerator
from tests import unittest
@@ -190,13 +192,18 @@ class StateTestCase(unittest.TestCase):
"get_clock",
"get_state_resolution_handler",
"get_account_validity_handler",
+ "get_macaroon_generator",
"hostname",
]
)
+ clock = cast(Clock, MockClock())
hs.config = default_config("tesths", True)
hs.get_datastores.return_value = Mock(main=self.dummy_store)
hs.get_state_handler.return_value = None
- hs.get_clock.return_value = MockClock()
+ hs.get_clock.return_value = clock
+ hs.get_macaroon_generator.return_value = MacaroonGenerator(
+ clock, "tesths", b"verysecret"
+ )
hs.get_auth.return_value = Auth(hs)
hs.get_state_resolution_handler = lambda: StateResolutionHandler(hs)
hs.get_storage_controllers.return_value = storage_controllers
diff --git a/tests/unittest.py b/tests/unittest.py
index e7f255b4fa..c645dd3563 100644
--- a/tests/unittest.py
+++ b/tests/unittest.py
@@ -315,7 +315,7 @@ class HomeserverTestCase(TestCase):
"is_guest": False,
}
- async def get_user_by_req(request, allow_guest=False, rights="access"):
+ async def get_user_by_req(request, allow_guest=False):
assert self.helper.auth_user_id is not None
return create_requester(
UserID.from_string(self.helper.auth_user_id),
diff --git a/tests/util/test_macaroons.py b/tests/util/test_macaroons.py
new file mode 100644
index 0000000000..32125f7bb7
--- /dev/null
+++ b/tests/util/test_macaroons.py
@@ -0,0 +1,146 @@
+# Copyright 2022 The Matrix.org Foundation C.I.C.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from pymacaroons.exceptions import MacaroonVerificationFailedException
+
+from synapse.util.macaroons import MacaroonGenerator, OidcSessionData
+
+from tests.server import get_clock
+from tests.unittest import TestCase
+
+
+class MacaroonGeneratorTestCase(TestCase):
+ def setUp(self):
+ self.reactor, hs_clock = get_clock()
+ self.macaroon_generator = MacaroonGenerator(hs_clock, "tesths", b"verysecret")
+ self.other_macaroon_generator = MacaroonGenerator(
+ hs_clock, "tesths", b"anothersecretkey"
+ )
+
+ def test_guest_access_token(self):
+ """Test the generation and verification of guest access tokens"""
+ token = self.macaroon_generator.generate_guest_access_token("@user:tesths")
+ user_id = self.macaroon_generator.verify_guest_token(token)
+ self.assertEqual(user_id, "@user:tesths")
+
+ # Raises with another secret key
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.other_macaroon_generator.verify_guest_token(token)
+
+ # Check that an old access token without the guest caveat does not work
+ macaroon = self.macaroon_generator._generate_base_macaroon("access")
+ macaroon.add_first_party_caveat(f"user_id = {user_id}")
+ macaroon.add_first_party_caveat("nonce = 0123456789abcdef")
+ token = macaroon.serialize()
+
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.macaroon_generator.verify_guest_token(token)
+
+ def test_delete_pusher_token(self):
+ """Test the generation and verification of delete_pusher tokens"""
+ token = self.macaroon_generator.generate_delete_pusher_token(
+ "@user:tesths", "m.mail", "john@example.com"
+ )
+ user_id = self.macaroon_generator.verify_delete_pusher_token(
+ token, "m.mail", "john@example.com"
+ )
+ self.assertEqual(user_id, "@user:tesths")
+
+ # Raises with another secret key
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.other_macaroon_generator.verify_delete_pusher_token(
+ token, "m.mail", "john@example.com"
+ )
+
+ # Raises when verifying for another pushkey
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.macaroon_generator.verify_delete_pusher_token(
+ token, "m.mail", "other@example.com"
+ )
+
+ # Raises when verifying for another app_id
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.macaroon_generator.verify_delete_pusher_token(
+ token, "somethingelse", "john@example.com"
+ )
+
+ # Check that an old token without the app_id and pushkey still works
+ macaroon = self.macaroon_generator._generate_base_macaroon("delete_pusher")
+ macaroon.add_first_party_caveat("user_id = @user:tesths")
+ token = macaroon.serialize()
+ user_id = self.macaroon_generator.verify_delete_pusher_token(
+ token, "m.mail", "john@example.com"
+ )
+ self.assertEqual(user_id, "@user:tesths")
+
+ def test_short_term_login_token(self):
+ """Test the generation and verification of short-term login tokens"""
+ token = self.macaroon_generator.generate_short_term_login_token(
+ user_id="@user:tesths",
+ auth_provider_id="oidc",
+ auth_provider_session_id="sid",
+ duration_in_ms=2 * 60 * 1000,
+ )
+
+ info = self.macaroon_generator.verify_short_term_login_token(token)
+ self.assertEqual(info.user_id, "@user:tesths")
+ self.assertEqual(info.auth_provider_id, "oidc")
+ self.assertEqual(info.auth_provider_session_id, "sid")
+
+ # Raises with another secret key
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.other_macaroon_generator.verify_short_term_login_token(token)
+
+ # Wait a minute
+ self.reactor.pump([60])
+ # Shouldn't raise
+ self.macaroon_generator.verify_short_term_login_token(token)
+ # Wait another minute
+ self.reactor.pump([60])
+ # Should raise since it expired
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.macaroon_generator.verify_short_term_login_token(token)
+
+ def test_oidc_session_token(self):
+ """Test the generation and verification of OIDC session cookies"""
+ state = "arandomstate"
+ session_data = OidcSessionData(
+ idp_id="oidc",
+ nonce="nonce",
+ client_redirect_url="https://example.com/",
+ ui_auth_session_id="",
+ )
+ token = self.macaroon_generator.generate_oidc_session_token(
+ state, session_data, duration_in_ms=2 * 60 * 1000
+ ).encode("utf-8")
+ info = self.macaroon_generator.verify_oidc_session_token(token, state)
+ self.assertEqual(session_data, info)
+
+ # Raises with another secret key
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.other_macaroon_generator.verify_oidc_session_token(token, state)
+
+ # Should raise with another state
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.macaroon_generator.verify_oidc_session_token(token, "anotherstate")
+
+ # Wait a minute
+ self.reactor.pump([60])
+ # Shouldn't raise
+ self.macaroon_generator.verify_oidc_session_token(token, state)
+ # Wait another minute
+ self.reactor.pump([60])
+ # Should raise since it expired
+ with self.assertRaises(MacaroonVerificationFailedException):
+ self.macaroon_generator.verify_oidc_session_token(token, state)
|