From d45fd845b3e10bad90b07eb3912f2f36a4aca11f Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 16 Jan 2026 16:37:11 +0000 Subject: [PATCH] Add `client_secret_filepath` option To allow supplying the `client_secret` in a file, instead of directly in the configuration. I've found that ESS prefer this method in the past, so the entire config does not need to be a secret. --- modules/restricted-guests/synapse/README.md | 3 ++ .../synapse/synapse_guest_module/config.py | 4 +-- .../synapse_guest_module/guest_module.py | 29 ++++++++++++++-- .../synapse_guest_module/mas_admin_client.py | 34 +++++++++++++++++-- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/modules/restricted-guests/synapse/README.md b/modules/restricted-guests/synapse/README.md index e3ed2ccac8..c03c874865 100644 --- a/modules/restricted-guests/synapse/README.md +++ b/modules/restricted-guests/synapse/README.md @@ -51,6 +51,7 @@ API](https://element-hq.github.io/matrix-authentication-service/topics/admin-api - `oauth_base_url` - Base URL for MAS' OAuth endpoints (defaults to `admin_api_base_url` if not set). Trailing slashes will be automatically stripped. - `client_id` - client ID for the automated tool. Must be a valid [ULID](https://github.com/ulid/spec). Generate one [here](https://ulidtools.com/). - `client_secret` - client secret for the automated tool. Ideally long and cryptographically secure. Keep it a secret! + - `client_secret_filepath` - path to a plaintext file containing the client secret. If set, this is used instead of `client_secret`. Example configuration: @@ -70,6 +71,8 @@ modules: # https://ulidtools.com/ client_id: 000000000000000000000G0EST client_secret: your-client-secret + # Alternatively, load the secret from a file: + # client_secret_filepath: /run/secrets/mas-client-secret ``` Enable [the Admin API on a MAS diff --git a/modules/restricted-guests/synapse/synapse_guest_module/config.py b/modules/restricted-guests/synapse/synapse_guest_module/config.py index 5e54f2f642..7841d1433e 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/config.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/config.py @@ -17,8 +17,8 @@ class MasConfig: admin_api_base_url: str oauth_base_url: str client_id: str - # TODO: Add a filepath option for the secret as well. - client_secret: str + client_secret: Optional[str] = None + client_secret_filepath: Optional[str] = None @attr.s(frozen=True, auto_attribs=True) diff --git a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py index 3fd463fb98..525482294b 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py @@ -120,14 +120,37 @@ class GuestModule: raise ConfigError("Config option 'mas.client_id' is required and must be a string") client_secret = mas_config.get("client_secret") - if not isinstance(client_secret, str) or len(client_secret.strip()) == 0: - raise ConfigError("Config option 'mas.client_secret' is required and must be a string") + if client_secret is not None: + if not isinstance(client_secret, str) or len(client_secret.strip()) == 0: + raise ConfigError( + "Config option 'mas.client_secret' must be a string" + ) + client_secret = client_secret.strip() + + client_secret_filepath = mas_config.get("client_secret_filepath") + if client_secret_filepath is not None: + if not isinstance(client_secret_filepath, str) or len(client_secret_filepath.strip()) == 0: + raise ConfigError( + "Config option 'mas.client_secret_filepath' must be a string" + ) + client_secret_filepath = client_secret_filepath.strip() + + if client_secret is None and client_secret_filepath is None: + raise ConfigError( + "Config option 'mas.client_secret' or 'mas.client_secret_filepath' is required" + ) + + if client_secret is not None and client_secret_filepath is not None: + raise ConfigError( + "Config option 'mas.client_secret' and 'mas.client_secret_filepath' are mutually exclusive" + ) mas = MasConfig( admin_api_base_url.strip(), oauth_base_url.strip(), client_id.strip(), - client_secret.strip(), + client_secret, + client_secret_filepath, ) return GuestModuleConfig( diff --git a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py index 299481da68..a650450531 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -18,9 +18,10 @@ class MasAdminClient: def __init__(self, api: ModuleApi, config: MasConfig): self._api = api self._config = config - # Strip trailing any slashes if present + # Strip any trailing slashes if present self._admin_api_base_url = config.admin_api_base_url.rstrip("/") self._oauth_base_url = config.oauth_base_url.rstrip("/") + self._client_secret = self._load_client_secret() async def create_user(self, username: str) -> str: """Creates a new user in MAS with the given username. @@ -56,7 +57,7 @@ class MasAdminClient: "actor_user_id": mas_user_id, "expires_in": expires_in, "scope": "openid urn:matrix:client:api:*", - "human_name": "guest user", + "human_name": "guest session", } response = await self._api.http_client.post_json_get_json( @@ -92,7 +93,7 @@ class MasAdminClient: async def request_admin_token(self) -> str: url = self._build_oauth_url("/oauth2/token") basic_auth = base64.b64encode( - f"{self._config.client_id}:{self._config.client_secret}".encode("utf-8") + f"{self._config.client_id}:{self._client_secret}".encode("utf-8") ).decode("ascii") headers = { "Authorization": [f"Basic {basic_auth}"], @@ -108,6 +109,33 @@ class MasAdminClient: if not isinstance(access_token, str) or len(access_token) == 0: raise ValueError("MAS token response missing access_token") return access_token + + def _load_client_secret(self) -> str: + """Source the MAS client secret from either configuration or a file.""" + if self._config.client_secret_filepath is not None: + try: + with open( + self._config.client_secret_filepath, "r", encoding="utf-8" + ) as secret_file: + client_secret = secret_file.read().strip() + except Exception as err: + raise ValueError( + f"Failed to read MAS client secret file: {err}" + ) from err + + if len(client_secret) == 0: + raise ValueError("MAS client secret file is empty") + + return client_secret + + if self._config.client_secret is None: + raise ValueError("MAS client secret is not configured") + + client_secret = self._config.client_secret.strip() + if len(client_secret) == 0: + raise ValueError("MAS client secret is empty") + + return client_secret async def _post_urlencoded_get_json( self, url: str, data: Dict[str, str], headers: Dict[str, Any]