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.
This commit is contained in:
Andrew Morgan
2026-01-16 16:37:11 +00:00
parent 9f4ca6f7e6
commit d45fd845b3
4 changed files with 62 additions and 8 deletions
@@ -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
@@ -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)
@@ -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(
@@ -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]