Skip to content

Dedicated MAS API #18520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jul 21, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
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/18520.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Dedicated internal API for Matrix Authentication Service to Synapse communication.
3 changes: 3 additions & 0 deletions synapse/_pydantic_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
conint,
constr,
parse_obj_as,
root_validator,
validator,
)
from pydantic.v1.error_wrappers import ErrorWrapper
Expand All @@ -68,6 +69,7 @@
conint,
constr,
parse_obj_as,
root_validator,
validator,
)
from pydantic.error_wrappers import ErrorWrapper
Expand All @@ -92,4 +94,5 @@
"StrictStr",
"ValidationError",
"validator",
"root_validator",
)
24 changes: 21 additions & 3 deletions synapse/api/auth/msc3861_delegated.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,12 @@ async def _introspect_token(
async def is_server_admin(self, requester: Requester) -> bool:
return "urn:synapse:admin:*" in requester.scope

def _is_access_token_the_admin_token(self, token: str) -> bool:
admin_token = self._admin_token()
if admin_token is None:
return False
return token == admin_token

async def get_user_by_req(
self,
request: SynapseRequest,
Expand Down Expand Up @@ -434,7 +440,7 @@ async def _wrapped_get_user_by_req(
requester = await self.get_user_by_access_token(access_token, allow_expired)

# Do not record requests from MAS using the virtual `__oidc_admin` user.
if access_token != self._admin_token():
if not self._is_access_token_the_admin_token(access_token):
await self._record_request(request, requester)

if not allow_guest and requester.is_guest:
Expand Down Expand Up @@ -470,13 +476,25 @@ async def get_user_by_req_experimental_feature(

raise UnrecognizedRequestError(code=404)

def is_request_using_the_admin_token(self, request: SynapseRequest) -> bool:
"""
Check if the request is using the admin token.
Args:
request: The request to check.
Returns:
True if the request is using the admin token, False otherwise.
"""
access_token = self.get_access_token_from_request(request)
return self._is_access_token_the_admin_token(access_token)

async def get_user_by_access_token(
self,
token: str,
allow_expired: bool = False,
) -> Requester:
admin_token = self._admin_token()
if admin_token is not None and token == admin_token:
if self._is_access_token_the_admin_token(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.
Comment on lines 498 to 500
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has this plan changed? It looks like the new approach uses a shared secret

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will likely get refactored as part of stabilising MAS support, getting it out of experimental features. The plan is to keep using a shared secret both ways as it simplifies configuration and avoids weird roundtrips

Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/synapse/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from synapse.rest.synapse.client.rendezvous import MSC4108RendezvousSessionResource
from synapse.rest.synapse.client.sso_register import SsoRegisterResource
from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource
from synapse.rest.synapse.mas import MasResource

if TYPE_CHECKING:
from synapse.server import HomeServer
Expand Down Expand Up @@ -60,6 +61,7 @@ def build_synapse_client_resource_tree(hs: "HomeServer") -> Mapping[str, Resourc
from synapse.rest.synapse.client.jwks import JwksResource

resources["/_synapse/jwks"] = JwksResource(hs)
resources["/_synapse/mas"] = MasResource(hs)

# provider-specific SSO bits. Only load these if they are enabled, since they
# rely on optional dependencies.
Expand Down
71 changes: 71 additions & 0 deletions synapse/rest/synapse/mas/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl_3.0.html>.
#
#


import logging
from typing import TYPE_CHECKING

from twisted.web.resource import Resource

from synapse.rest.synapse.mas.devices import (
MasCreateDeviceResource,
MasDeleteDeviceResource,
MasSyncDevicesResource,
MasUpdateDeviceDisplayNameResource,
)
from synapse.rest.synapse.mas.users import (
MasAllowCrossSigningResetResource,
MasDeleteUserResource,
MasIsLocalpartAvailableResource,
MasProvisionUserResource,
MasQueryUserResource,
MasReactivateUserResource,
MasSetDisplayNameResource,
MasUnsetDisplayNameResource,
)

if TYPE_CHECKING:
from synapse.server import HomeServer


logger = logging.getLogger(__name__)


class MasResource(Resource):
"""
Provides endpoints for MAS to manage user accounts and devices.

All endpoints are mounted under the path `/_synapse/mas/` and only work
using the MAS admin token.
"""

def __init__(self, hs: "HomeServer"):
Resource.__init__(self)
self.putChild(b"query_user", MasQueryUserResource(hs))
self.putChild(b"provision_user", MasProvisionUserResource(hs))
self.putChild(b"is_localpart_available", MasIsLocalpartAvailableResource(hs))
self.putChild(b"delete_user", MasDeleteUserResource(hs))
self.putChild(b"create_device", MasCreateDeviceResource(hs))
self.putChild(b"delete_device", MasDeleteDeviceResource(hs))
self.putChild(
b"update_device_display_name", MasUpdateDeviceDisplayNameResource(hs)
)
self.putChild(b"sync_devices", MasSyncDevicesResource(hs))
self.putChild(b"reactivate_user", MasReactivateUserResource(hs))
self.putChild(b"set_displayname", MasSetDisplayNameResource(hs))
self.putChild(b"unset_displayname", MasUnsetDisplayNameResource(hs))
self.putChild(
b"allow_cross_signing_reset", MasAllowCrossSigningResetResource(hs)
)
43 changes: 43 additions & 0 deletions synapse/rest/synapse/mas/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl_3.0.html>.
#
#


from typing import TYPE_CHECKING, cast

from synapse.api.errors import SynapseError
from synapse.http.server import DirectServeJsonResource

if TYPE_CHECKING:
from synapse.app.generic_worker import GenericWorkerStore
from synapse.http.site import SynapseRequest
from synapse.server import HomeServer


class MasBaseResource(DirectServeJsonResource):
def __init__(self, hs: "HomeServer"):
# Importing this module requires authlib, which is an optional
# dependency but required if msc3861 is enabled
from synapse.api.auth.msc3861_delegated import MSC3861DelegatedAuth
Comment on lines +30 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. This is backed up by

# Only allow enabling MSC3861 if authlib is installed
if value and not HAS_AUTHLIB:
raise ConfigError(
"MSC3861 is enabled but authlib is not installed. "
"Please install authlib to use MSC3861.",
("experimental", "msc3861", "enabled"),
)


DirectServeJsonResource.__init__(self, extract_context=True)
auth = hs.get_auth()
assert isinstance(auth, MSC3861DelegatedAuth)
self.msc3861_auth = auth
self.store = cast("GenericWorkerStore", hs.get_datastores().main)
self.hostname = hs.hostname

def assert_mas_request(self, request: "SynapseRequest") -> None:
if not self.msc3861_auth.is_request_using_the_admin_token(request):
raise SynapseError(403, "This endpoint must only be called by MAS")
Loading
Loading