You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

375 lines
15 KiB

  1. # Copyright 2023 The Matrix.org Foundation.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. import logging
  15. from typing import TYPE_CHECKING, Any, Dict, List, Optional
  16. from urllib.parse import urlencode
  17. from authlib.oauth2 import ClientAuth
  18. from authlib.oauth2.auth import encode_client_secret_basic, encode_client_secret_post
  19. from authlib.oauth2.rfc7523 import ClientSecretJWT, PrivateKeyJWT, private_key_jwt_sign
  20. from authlib.oauth2.rfc7662 import IntrospectionToken
  21. from authlib.oidc.discovery import OpenIDProviderMetadata, get_well_known_url
  22. from prometheus_client import Histogram
  23. from twisted.web.client import readBody
  24. from twisted.web.http_headers import Headers
  25. from synapse.api.auth.base import BaseAuth
  26. from synapse.api.errors import (
  27. AuthError,
  28. HttpResponseException,
  29. InvalidClientTokenError,
  30. OAuthInsufficientScopeError,
  31. StoreError,
  32. SynapseError,
  33. )
  34. from synapse.http.site import SynapseRequest
  35. from synapse.logging.context import make_deferred_yieldable
  36. from synapse.types import Requester, UserID, create_requester
  37. from synapse.util import json_decoder
  38. from synapse.util.caches.cached_call import RetryOnExceptionCachedCall
  39. if TYPE_CHECKING:
  40. from synapse.server import HomeServer
  41. logger = logging.getLogger(__name__)
  42. introspection_response_timer = Histogram(
  43. "synapse_api_auth_delegated_introspection_response",
  44. "Time taken to get a response for an introspection request",
  45. ["code"],
  46. )
  47. # Scope as defined by MSC2967
  48. # https://github.com/matrix-org/matrix-spec-proposals/pull/2967
  49. SCOPE_MATRIX_API = "urn:matrix:org.matrix.msc2967.client:api:*"
  50. SCOPE_MATRIX_GUEST = "urn:matrix:org.matrix.msc2967.client:api:guest"
  51. SCOPE_MATRIX_DEVICE_PREFIX = "urn:matrix:org.matrix.msc2967.client:device:"
  52. # Scope which allows access to the Synapse admin API
  53. SCOPE_SYNAPSE_ADMIN = "urn:synapse:admin:*"
  54. def scope_to_list(scope: str) -> List[str]:
  55. """Convert a scope string to a list of scope tokens"""
  56. return scope.strip().split(" ")
  57. class PrivateKeyJWTWithKid(PrivateKeyJWT): # type: ignore[misc]
  58. """An implementation of the private_key_jwt client auth method that includes a kid header.
  59. This is needed because some providers (Keycloak) require the kid header to figure
  60. out which key to use to verify the signature.
  61. """
  62. def sign(self, auth: Any, token_endpoint: str) -> bytes:
  63. return private_key_jwt_sign(
  64. auth.client_secret,
  65. client_id=auth.client_id,
  66. token_endpoint=token_endpoint,
  67. claims=self.claims,
  68. header={"kid": auth.client_secret["kid"]},
  69. )
  70. class MSC3861DelegatedAuth(BaseAuth):
  71. AUTH_METHODS = {
  72. "client_secret_post": encode_client_secret_post,
  73. "client_secret_basic": encode_client_secret_basic,
  74. "client_secret_jwt": ClientSecretJWT(),
  75. "private_key_jwt": PrivateKeyJWTWithKid(),
  76. }
  77. EXTERNAL_ID_PROVIDER = "oauth-delegated"
  78. def __init__(self, hs: "HomeServer"):
  79. super().__init__(hs)
  80. self._config = hs.config.experimental.msc3861
  81. auth_method = MSC3861DelegatedAuth.AUTH_METHODS.get(
  82. self._config.client_auth_method.value, None
  83. )
  84. # Those assertions are already checked when parsing the config
  85. assert self._config.enabled, "OAuth delegation is not enabled"
  86. assert self._config.issuer, "No issuer provided"
  87. assert self._config.client_id, "No client_id provided"
  88. assert auth_method is not None, "Invalid client_auth_method provided"
  89. self._clock = hs.get_clock()
  90. self._http_client = hs.get_proxied_http_client()
  91. self._hostname = hs.hostname
  92. self._admin_token = self._config.admin_token
  93. self._issuer_metadata = RetryOnExceptionCachedCall(self._load_metadata)
  94. if isinstance(auth_method, PrivateKeyJWTWithKid):
  95. # Use the JWK as the client secret when using the private_key_jwt method
  96. assert self._config.jwk, "No JWK provided"
  97. self._client_auth = ClientAuth(
  98. self._config.client_id, self._config.jwk, auth_method
  99. )
  100. else:
  101. # Else use the client secret
  102. assert self._config.client_secret, "No client_secret provided"
  103. self._client_auth = ClientAuth(
  104. self._config.client_id, self._config.client_secret, auth_method
  105. )
  106. async def _load_metadata(self) -> OpenIDProviderMetadata:
  107. if self._config.issuer_metadata is not None:
  108. return OpenIDProviderMetadata(**self._config.issuer_metadata)
  109. url = get_well_known_url(self._config.issuer, external=True)
  110. response = await self._http_client.get_json(url)
  111. metadata = OpenIDProviderMetadata(**response)
  112. # metadata.validate_introspection_endpoint()
  113. return metadata
  114. async def _introspect_token(self, token: str) -> IntrospectionToken:
  115. """
  116. Send a token to the introspection endpoint and returns the introspection response
  117. Parameters:
  118. token: The token to introspect
  119. Raises:
  120. HttpResponseException: If the introspection endpoint returns a non-2xx response
  121. ValueError: If the introspection endpoint returns an invalid JSON response
  122. JSONDecodeError: If the introspection endpoint returns a non-JSON response
  123. Exception: If the HTTP request fails
  124. Returns:
  125. The introspection response
  126. """
  127. metadata = await self._issuer_metadata.get()
  128. introspection_endpoint = metadata.get("introspection_endpoint")
  129. raw_headers: Dict[str, str] = {
  130. "Content-Type": "application/x-www-form-urlencoded",
  131. "User-Agent": str(self._http_client.user_agent, "utf-8"),
  132. "Accept": "application/json",
  133. }
  134. args = {"token": token, "token_type_hint": "access_token"}
  135. body = urlencode(args, True)
  136. # Fill the body/headers with credentials
  137. uri, raw_headers, body = self._client_auth.prepare(
  138. method="POST", uri=introspection_endpoint, headers=raw_headers, body=body
  139. )
  140. headers = Headers({k: [v] for (k, v) in raw_headers.items()})
  141. # Do the actual request
  142. # We're not using the SimpleHttpClient util methods as we don't want to
  143. # check the HTTP status code, and we do the body encoding ourselves.
  144. start_time = self._clock.time()
  145. try:
  146. response = await self._http_client.request(
  147. method="POST",
  148. uri=uri,
  149. data=body.encode("utf-8"),
  150. headers=headers,
  151. )
  152. resp_body = await make_deferred_yieldable(readBody(response))
  153. except Exception:
  154. end_time = self._clock.time()
  155. introspection_response_timer.labels("ERR").observe(end_time - start_time)
  156. raise
  157. end_time = self._clock.time()
  158. introspection_response_timer.labels(response.code).observe(
  159. end_time - start_time
  160. )
  161. if response.code < 200 or response.code >= 300:
  162. raise HttpResponseException(
  163. response.code,
  164. response.phrase.decode("ascii", errors="replace"),
  165. resp_body,
  166. )
  167. resp = json_decoder.decode(resp_body.decode("utf-8"))
  168. if not isinstance(resp, dict):
  169. raise ValueError(
  170. "The introspection endpoint returned an invalid JSON response."
  171. )
  172. return IntrospectionToken(**resp)
  173. async def is_server_admin(self, requester: Requester) -> bool:
  174. return "urn:synapse:admin:*" in requester.scope
  175. async def get_user_by_req(
  176. self,
  177. request: SynapseRequest,
  178. allow_guest: bool = False,
  179. allow_expired: bool = False,
  180. allow_locked: bool = False,
  181. ) -> Requester:
  182. access_token = self.get_access_token_from_request(request)
  183. requester = await self.get_appservice_user(request, access_token)
  184. if not requester:
  185. # TODO: we probably want to assert the allow_guest inside this call
  186. # so that we don't provision the user if they don't have enough permission:
  187. requester = await self.get_user_by_access_token(access_token, allow_expired)
  188. if not allow_guest and requester.is_guest:
  189. raise OAuthInsufficientScopeError([SCOPE_MATRIX_API])
  190. request.requester = requester
  191. return requester
  192. async def get_user_by_access_token(
  193. self,
  194. token: str,
  195. allow_expired: bool = False,
  196. ) -> Requester:
  197. if self._admin_token is not None and token == self._admin_token:
  198. # XXX: This is a temporary solution so that the admin API can be called by
  199. # the OIDC provider. This will be removed once we have OIDC client
  200. # credentials grant support in matrix-authentication-service.
  201. logging.info("Admin toked used")
  202. # XXX: that user doesn't exist and won't be provisioned.
  203. # This is mostly fine for admin calls, but we should also think about doing
  204. # requesters without a user_id.
  205. admin_user = UserID("__oidc_admin", self._hostname)
  206. return create_requester(
  207. user_id=admin_user,
  208. scope=["urn:synapse:admin:*"],
  209. )
  210. try:
  211. introspection_result = await self._introspect_token(token)
  212. except Exception:
  213. logger.exception("Failed to introspect token")
  214. raise SynapseError(503, "Unable to introspect the access token")
  215. logger.info(f"Introspection result: {introspection_result!r}")
  216. # TODO: introspection verification should be more extensive, especially:
  217. # - verify the audience
  218. if not introspection_result.get("active"):
  219. raise InvalidClientTokenError("Token is not active")
  220. # Let's look at the scope
  221. scope: List[str] = scope_to_list(introspection_result.get("scope", ""))
  222. # Determine type of user based on presence of particular scopes
  223. has_user_scope = SCOPE_MATRIX_API in scope
  224. has_guest_scope = SCOPE_MATRIX_GUEST in scope
  225. if not has_user_scope and not has_guest_scope:
  226. raise InvalidClientTokenError("No scope in token granting user rights")
  227. # Match via the sub claim
  228. sub: Optional[str] = introspection_result.get("sub")
  229. if sub is None:
  230. raise InvalidClientTokenError(
  231. "Invalid sub claim in the introspection result"
  232. )
  233. user_id_str = await self.store.get_user_by_external_id(
  234. MSC3861DelegatedAuth.EXTERNAL_ID_PROVIDER, sub
  235. )
  236. if user_id_str is None:
  237. # If we could not find a user via the external_id, it either does not exist,
  238. # or the external_id was never recorded
  239. # TODO: claim mapping should be configurable
  240. username: Optional[str] = introspection_result.get("username")
  241. if username is None or not isinstance(username, str):
  242. raise AuthError(
  243. 500,
  244. "Invalid username claim in the introspection result",
  245. )
  246. user_id = UserID(username, self._hostname)
  247. # First try to find a user from the username claim
  248. user_info = await self.store.get_user_by_id(user_id=user_id.to_string())
  249. if user_info is None:
  250. # If the user does not exist, we should create it on the fly
  251. # TODO: we could use SCIM to provision users ahead of time and listen
  252. # for SCIM SET events if those ever become standard:
  253. # https://datatracker.ietf.org/doc/html/draft-hunt-scim-notify-00
  254. # TODO: claim mapping should be configurable
  255. # If present, use the name claim as the displayname
  256. name: Optional[str] = introspection_result.get("name")
  257. await self.store.register_user(
  258. user_id=user_id.to_string(), create_profile_with_displayname=name
  259. )
  260. # And record the sub as external_id
  261. await self.store.record_user_external_id(
  262. MSC3861DelegatedAuth.EXTERNAL_ID_PROVIDER, sub, user_id.to_string()
  263. )
  264. else:
  265. user_id = UserID.from_string(user_id_str)
  266. # Find device_ids in scope
  267. # We only allow a single device_id in the scope, so we find them all in the
  268. # scope list, and raise if there are more than one. The OIDC server should be
  269. # the one enforcing valid scopes, so we raise a 500 if we find an invalid scope.
  270. device_ids = [
  271. tok[len(SCOPE_MATRIX_DEVICE_PREFIX) :]
  272. for tok in scope
  273. if tok.startswith(SCOPE_MATRIX_DEVICE_PREFIX)
  274. ]
  275. if len(device_ids) > 1:
  276. raise AuthError(
  277. 500,
  278. "Multiple device IDs in scope",
  279. )
  280. device_id = device_ids[0] if device_ids else None
  281. if device_id is not None:
  282. # Sanity check the device_id
  283. if len(device_id) > 255 or len(device_id) < 1:
  284. raise AuthError(
  285. 500,
  286. "Invalid device ID in scope",
  287. )
  288. # Create the device on the fly if it does not exist
  289. try:
  290. await self.store.get_device(
  291. user_id=user_id.to_string(), device_id=device_id
  292. )
  293. except StoreError:
  294. await self.store.store_device(
  295. user_id=user_id.to_string(),
  296. device_id=device_id,
  297. initial_device_display_name="OIDC-native client",
  298. )
  299. # TODO: there is a few things missing in the requester here, which still need
  300. # to be figured out, like:
  301. # - impersonation, with the `authenticated_entity`, which is used for
  302. # rate-limiting, MAU limits, etc.
  303. # - shadow-banning, with the `shadow_banned` flag
  304. # - a proper solution for appservices, which still needs to be figured out in
  305. # the context of MSC3861
  306. return create_requester(
  307. user_id=user_id,
  308. device_id=device_id,
  309. scope=scope,
  310. is_guest=(has_guest_scope and not has_user_scope),
  311. )