|
- # Copyright 2023 The Matrix.org Foundation.
- #
- # 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.
- import logging
- from typing import TYPE_CHECKING, Any, Dict, List, Optional
- from urllib.parse import urlencode
-
- from authlib.oauth2 import ClientAuth
- from authlib.oauth2.auth import encode_client_secret_basic, encode_client_secret_post
- from authlib.oauth2.rfc7523 import ClientSecretJWT, PrivateKeyJWT, private_key_jwt_sign
- from authlib.oauth2.rfc7662 import IntrospectionToken
- from authlib.oidc.discovery import OpenIDProviderMetadata, get_well_known_url
- from prometheus_client import Histogram
-
- from twisted.web.client import readBody
- from twisted.web.http_headers import Headers
-
- from synapse.api.auth.base import BaseAuth
- from synapse.api.errors import (
- AuthError,
- HttpResponseException,
- InvalidClientTokenError,
- OAuthInsufficientScopeError,
- StoreError,
- SynapseError,
- )
- from synapse.http.site import SynapseRequest
- from synapse.logging.context import make_deferred_yieldable
- from synapse.types import Requester, UserID, create_requester
- from synapse.util import json_decoder
- from synapse.util.caches.cached_call import RetryOnExceptionCachedCall
-
- if TYPE_CHECKING:
- from synapse.server import HomeServer
-
- logger = logging.getLogger(__name__)
-
- introspection_response_timer = Histogram(
- "synapse_api_auth_delegated_introspection_response",
- "Time taken to get a response for an introspection request",
- ["code"],
- )
-
-
- # Scope as defined by MSC2967
- # https://github.com/matrix-org/matrix-spec-proposals/pull/2967
- SCOPE_MATRIX_API = "urn:matrix:org.matrix.msc2967.client:api:*"
- SCOPE_MATRIX_GUEST = "urn:matrix:org.matrix.msc2967.client:api:guest"
- SCOPE_MATRIX_DEVICE_PREFIX = "urn:matrix:org.matrix.msc2967.client:device:"
-
- # Scope which allows access to the Synapse admin API
- SCOPE_SYNAPSE_ADMIN = "urn:synapse:admin:*"
-
-
- def scope_to_list(scope: str) -> List[str]:
- """Convert a scope string to a list of scope tokens"""
- return scope.strip().split(" ")
-
-
- class PrivateKeyJWTWithKid(PrivateKeyJWT): # type: ignore[misc]
- """An implementation of the private_key_jwt client auth method that includes a kid header.
-
- This is needed because some providers (Keycloak) require the kid header to figure
- out which key to use to verify the signature.
- """
-
- def sign(self, auth: Any, token_endpoint: str) -> bytes:
- return private_key_jwt_sign(
- auth.client_secret,
- client_id=auth.client_id,
- token_endpoint=token_endpoint,
- claims=self.claims,
- header={"kid": auth.client_secret["kid"]},
- )
-
-
- class MSC3861DelegatedAuth(BaseAuth):
- AUTH_METHODS = {
- "client_secret_post": encode_client_secret_post,
- "client_secret_basic": encode_client_secret_basic,
- "client_secret_jwt": ClientSecretJWT(),
- "private_key_jwt": PrivateKeyJWTWithKid(),
- }
-
- EXTERNAL_ID_PROVIDER = "oauth-delegated"
-
- def __init__(self, hs: "HomeServer"):
- super().__init__(hs)
-
- self._config = hs.config.experimental.msc3861
- auth_method = MSC3861DelegatedAuth.AUTH_METHODS.get(
- self._config.client_auth_method.value, None
- )
- # Those assertions are already checked when parsing the config
- assert self._config.enabled, "OAuth delegation is not enabled"
- assert self._config.issuer, "No issuer provided"
- assert self._config.client_id, "No client_id provided"
- assert auth_method is not None, "Invalid client_auth_method provided"
-
- self._clock = hs.get_clock()
- self._http_client = hs.get_proxied_http_client()
- self._hostname = hs.hostname
- self._admin_token = self._config.admin_token
-
- self._issuer_metadata = RetryOnExceptionCachedCall(self._load_metadata)
-
- if isinstance(auth_method, PrivateKeyJWTWithKid):
- # Use the JWK as the client secret when using the private_key_jwt method
- assert self._config.jwk, "No JWK provided"
- self._client_auth = ClientAuth(
- self._config.client_id, self._config.jwk, auth_method
- )
- else:
- # Else use the client secret
- assert self._config.client_secret, "No client_secret provided"
- self._client_auth = ClientAuth(
- self._config.client_id, self._config.client_secret, auth_method
- )
-
- async def _load_metadata(self) -> OpenIDProviderMetadata:
- if self._config.issuer_metadata is not None:
- return OpenIDProviderMetadata(**self._config.issuer_metadata)
- url = get_well_known_url(self._config.issuer, external=True)
- response = await self._http_client.get_json(url)
- metadata = OpenIDProviderMetadata(**response)
- # metadata.validate_introspection_endpoint()
- return metadata
-
- async def _introspect_token(self, token: str) -> IntrospectionToken:
- """
- Send a token to the introspection endpoint and returns the introspection response
-
- Parameters:
- token: The token to introspect
-
- Raises:
- HttpResponseException: If the introspection endpoint returns a non-2xx response
- ValueError: If the introspection endpoint returns an invalid JSON response
- JSONDecodeError: If the introspection endpoint returns a non-JSON response
- Exception: If the HTTP request fails
-
- Returns:
- The introspection response
- """
- metadata = await self._issuer_metadata.get()
- introspection_endpoint = metadata.get("introspection_endpoint")
- raw_headers: Dict[str, str] = {
- "Content-Type": "application/x-www-form-urlencoded",
- "User-Agent": str(self._http_client.user_agent, "utf-8"),
- "Accept": "application/json",
- }
-
- args = {"token": token, "token_type_hint": "access_token"}
- body = urlencode(args, True)
-
- # Fill the body/headers with credentials
- uri, raw_headers, body = self._client_auth.prepare(
- method="POST", uri=introspection_endpoint, headers=raw_headers, body=body
- )
- headers = Headers({k: [v] for (k, v) in raw_headers.items()})
-
- # Do the actual request
- # We're not using the SimpleHttpClient util methods as we don't want to
- # check the HTTP status code, and we do the body encoding ourselves.
-
- start_time = self._clock.time()
- try:
- response = await self._http_client.request(
- method="POST",
- uri=uri,
- data=body.encode("utf-8"),
- headers=headers,
- )
-
- resp_body = await make_deferred_yieldable(readBody(response))
- except Exception:
- end_time = self._clock.time()
- introspection_response_timer.labels("ERR").observe(end_time - start_time)
- raise
-
- end_time = self._clock.time()
- introspection_response_timer.labels(response.code).observe(
- end_time - start_time
- )
-
- if response.code < 200 or response.code >= 300:
- raise HttpResponseException(
- response.code,
- response.phrase.decode("ascii", errors="replace"),
- resp_body,
- )
-
- resp = json_decoder.decode(resp_body.decode("utf-8"))
-
- if not isinstance(resp, dict):
- raise ValueError(
- "The introspection endpoint returned an invalid JSON response."
- )
-
- return IntrospectionToken(**resp)
-
- async def is_server_admin(self, requester: Requester) -> bool:
- return "urn:synapse:admin:*" in requester.scope
-
- async def get_user_by_req(
- self,
- request: SynapseRequest,
- allow_guest: bool = False,
- allow_expired: bool = False,
- allow_locked: bool = False,
- ) -> Requester:
- access_token = self.get_access_token_from_request(request)
-
- requester = await self.get_appservice_user(request, access_token)
- if not requester:
- # TODO: we probably want to assert the allow_guest inside this call
- # so that we don't provision the user if they don't have enough permission:
- requester = await self.get_user_by_access_token(access_token, allow_expired)
-
- if not allow_guest and requester.is_guest:
- raise OAuthInsufficientScopeError([SCOPE_MATRIX_API])
-
- request.requester = requester
-
- return requester
-
- async def get_user_by_access_token(
- self,
- token: str,
- allow_expired: bool = False,
- ) -> Requester:
- if self._admin_token is not None and token == self._admin_token:
- # XXX: This is a temporary solution so that the admin API can be called by
- # the OIDC provider. This will be removed once we have OIDC client
- # credentials grant support in matrix-authentication-service.
- logging.info("Admin toked used")
- # XXX: that user doesn't exist and won't be provisioned.
- # This is mostly fine for admin calls, but we should also think about doing
- # requesters without a user_id.
- admin_user = UserID("__oidc_admin", self._hostname)
- return create_requester(
- user_id=admin_user,
- scope=["urn:synapse:admin:*"],
- )
-
- try:
- introspection_result = await self._introspect_token(token)
- except Exception:
- logger.exception("Failed to introspect token")
- raise SynapseError(503, "Unable to introspect the access token")
-
- logger.info(f"Introspection result: {introspection_result!r}")
-
- # TODO: introspection verification should be more extensive, especially:
- # - verify the audience
- if not introspection_result.get("active"):
- raise InvalidClientTokenError("Token is not active")
-
- # Let's look at the scope
- scope: List[str] = scope_to_list(introspection_result.get("scope", ""))
-
- # Determine type of user based on presence of particular scopes
- has_user_scope = SCOPE_MATRIX_API in scope
- has_guest_scope = SCOPE_MATRIX_GUEST in scope
-
- if not has_user_scope and not has_guest_scope:
- raise InvalidClientTokenError("No scope in token granting user rights")
-
- # Match via the sub claim
- sub: Optional[str] = introspection_result.get("sub")
- if sub is None:
- raise InvalidClientTokenError(
- "Invalid sub claim in the introspection result"
- )
-
- user_id_str = await self.store.get_user_by_external_id(
- MSC3861DelegatedAuth.EXTERNAL_ID_PROVIDER, sub
- )
- if user_id_str is None:
- # If we could not find a user via the external_id, it either does not exist,
- # or the external_id was never recorded
-
- # TODO: claim mapping should be configurable
- username: Optional[str] = introspection_result.get("username")
- if username is None or not isinstance(username, str):
- raise AuthError(
- 500,
- "Invalid username claim in the introspection result",
- )
- user_id = UserID(username, self._hostname)
-
- # First try to find a user from the username claim
- user_info = await self.store.get_user_by_id(user_id=user_id.to_string())
- if user_info is None:
- # If the user does not exist, we should create it on the fly
- # TODO: we could use SCIM to provision users ahead of time and listen
- # for SCIM SET events if those ever become standard:
- # https://datatracker.ietf.org/doc/html/draft-hunt-scim-notify-00
-
- # TODO: claim mapping should be configurable
- # If present, use the name claim as the displayname
- name: Optional[str] = introspection_result.get("name")
-
- await self.store.register_user(
- user_id=user_id.to_string(), create_profile_with_displayname=name
- )
-
- # And record the sub as external_id
- await self.store.record_user_external_id(
- MSC3861DelegatedAuth.EXTERNAL_ID_PROVIDER, sub, user_id.to_string()
- )
- else:
- user_id = UserID.from_string(user_id_str)
-
- # Find device_ids in scope
- # We only allow a single device_id in the scope, so we find them all in the
- # scope list, and raise if there are more than one. The OIDC server should be
- # the one enforcing valid scopes, so we raise a 500 if we find an invalid scope.
- device_ids = [
- tok[len(SCOPE_MATRIX_DEVICE_PREFIX) :]
- for tok in scope
- if tok.startswith(SCOPE_MATRIX_DEVICE_PREFIX)
- ]
-
- if len(device_ids) > 1:
- raise AuthError(
- 500,
- "Multiple device IDs in scope",
- )
-
- device_id = device_ids[0] if device_ids else None
- if device_id is not None:
- # Sanity check the device_id
- if len(device_id) > 255 or len(device_id) < 1:
- raise AuthError(
- 500,
- "Invalid device ID in scope",
- )
-
- # Create the device on the fly if it does not exist
- try:
- await self.store.get_device(
- user_id=user_id.to_string(), device_id=device_id
- )
- except StoreError:
- await self.store.store_device(
- user_id=user_id.to_string(),
- device_id=device_id,
- initial_device_display_name="OIDC-native client",
- )
-
- # TODO: there is a few things missing in the requester here, which still need
- # to be figured out, like:
- # - impersonation, with the `authenticated_entity`, which is used for
- # rate-limiting, MAU limits, etc.
- # - shadow-banning, with the `shadow_banned` flag
- # - a proper solution for appservices, which still needs to be figured out in
- # the context of MSC3861
- return create_requester(
- user_id=user_id,
- device_id=device_id,
- scope=scope,
- is_guest=(has_guest_scope and not has_user_scope),
- )
|