mirror of
https://mau.dev/maunium/synapse.git
synced 2024-06-28 23:42:18 +00:00
![Quentin Gliech](/assets/img/avatar_default.png)
This returns a proper 503 when the introspection endpoint is not working for some reason, which should avoid logging out clients in those cases.
333 lines
13 KiB
Python
333 lines
13 KiB
Python
# 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 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__)
|
|
|
|
|
|
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):
|
|
"""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._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.
|
|
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))
|
|
|
|
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,
|
|
) -> 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(
|
|
["urn:matrix:org.matrix.msc2967.client: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_admin_scope = "urn:synapse:admin:*" in scope
|
|
has_user_scope = "urn:matrix:org.matrix.msc2967.client:api:*" in scope
|
|
has_guest_scope = "urn:matrix:org.matrix.msc2967.client:api:guest" in scope
|
|
is_user = has_user_scope or has_admin_scope
|
|
is_guest = has_guest_scope and not is_user
|
|
|
|
if not is_user and not is_guest:
|
|
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_userinfo_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_id in scope
|
|
device_id = None
|
|
for tok in scope:
|
|
if tok.startswith("urn:matrix:org.matrix.msc2967.client:device:"):
|
|
parts = tok.split(":")
|
|
if len(parts) == 5:
|
|
device_id = parts[4]
|
|
|
|
if device_id:
|
|
# 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=is_guest,
|
|
)
|