From 376cbf6d705b7ad26507d262a1fac316026052f2 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 7 Jan 2026 10:12:37 -0300 Subject: [PATCH] comms: mint guest sessions via MAS --- .../guest-register-configmap.yaml | 190 +++++++++++------- .../guest-register-deployment.yaml | 27 +-- services/communication/synapse-rendered.yaml | 11 - 3 files changed, 133 insertions(+), 95 deletions(-) diff --git a/services/communication/guest-register-configmap.yaml b/services/communication/guest-register-configmap.yaml index 68ea2db..057b0fe 100644 --- a/services/communication/guest-register-configmap.yaml +++ b/services/communication/guest-register-configmap.yaml @@ -5,6 +5,7 @@ metadata: name: matrix-guest-register data: server.py: | + import base64 import json import os import random @@ -12,11 +13,12 @@ data: from http.server import BaseHTTPRequestHandler, HTTPServer from urllib import error, parse, request - MATRIX_BASE = os.environ.get("MATRIX_BASE", "http://othrys-synapse-matrix-synapse:8008").rstrip("/") + MAS_BASE = os.environ.get("MAS_BASE", "http://matrix-authentication-service:8080").rstrip("/") SERVER_NAME = os.environ.get("MATRIX_SERVER_NAME", "live.bstein.dev") - AS_TOKEN = os.environ["AS_TOKEN"] - HS_TOKEN = os.environ["HS_TOKEN"] + MAS_ADMIN_CLIENT_ID = os.environ["MAS_ADMIN_CLIENT_ID"] + MAS_ADMIN_CLIENT_SECRET_FILE = os.environ.get("MAS_ADMIN_CLIENT_SECRET_FILE", "/etc/mas/admin-client/client_secret") + MAS_ADMIN_SCOPE = os.environ.get("MAS_ADMIN_SCOPE", "urn:mas:admin") RATE_WINDOW_SEC = int(os.environ.get("RATE_WINDOW_SEC", "60")) RATE_MAX = int(os.environ.get("RATE_MAX", "30")) @@ -25,14 +27,14 @@ data: ADJ = ["brisk", "calm", "eager", "gentle", "merry", "nifty", "rapid", "sunny", "witty", "zesty"] NOUN = ["otter", "falcon", "comet", "ember", "grove", "harbor", "meadow", "raven", "river", "summit"] - def _json(method, url, *, token=None, body=None, timeout=20): - headers = {"Content-Type": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" + def _json(method, url, *, headers=None, body=None, timeout=20): + hdrs = {"Content-Type": "application/json"} + if headers: + hdrs.update(headers) data = None if body is not None: data = json.dumps(body).encode() - req = request.Request(url, data=data, headers=headers, method=method) + req = request.Request(url, data=data, headers=hdrs, method=method) try: with request.urlopen(req, timeout=timeout) as resp: raw = resp.read() @@ -46,40 +48,96 @@ data: payload = {} return e.code, payload + def _form(method, url, *, headers=None, fields=None, timeout=20): + hdrs = {"Content-Type": "application/x-www-form-urlencoded"} + if headers: + hdrs.update(headers) + data = parse.urlencode(fields or {}).encode() + req = request.Request(url, data=data, headers=hdrs, method=method) + try: + with request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + payload = json.loads(raw.decode()) if raw else {} + return resp.status, payload + except error.HTTPError as e: + raw = e.read() + try: + payload = json.loads(raw.decode()) if raw else {} + except Exception: + payload = {} + return e.code, payload + + _admin_token = None + _admin_token_at = 0.0 + + def _mas_admin_access_token(now): + global _admin_token, _admin_token_at + if _admin_token and (now - _admin_token_at) < 300: + return _admin_token + + with open(MAS_ADMIN_CLIENT_SECRET_FILE, encoding="utf-8") as fh: + client_secret = fh.read().strip() + basic = base64.b64encode(f"{MAS_ADMIN_CLIENT_ID}:{client_secret}".encode()).decode() + + status, payload = _form( + "POST", + f"{MAS_BASE}/oauth2/token", + headers={"Authorization": f"Basic {basic}"}, + fields={"grant_type": "client_credentials", "scope": MAS_ADMIN_SCOPE}, + timeout=20, + ) + if status != 200 or "access_token" not in payload: + raise RuntimeError("mas_admin_token_failed") + + _admin_token = payload["access_token"] + _admin_token_at = now + return _admin_token + + def _gql(admin_token, query, variables): + status, payload = _json( + "POST", + f"{MAS_BASE}/graphql", + headers={"Authorization": f"Bearer {admin_token}"}, + body={"query": query, "variables": variables}, + timeout=20, + ) + if status != 200: + raise RuntimeError("gql_http_failed") + if payload.get("errors"): + raise RuntimeError("gql_error") + return payload.get("data") or {} + def _generate_localpart(): return "guest-" + secrets.token_hex(6) def _generate_displayname(): return f"{random.choice(ADJ)}-{random.choice(NOUN)}" - def _set_displayname(access_token, user_id, displayname): - try: - _json( - "PUT", - f"{MATRIX_BASE}/_matrix/client/v3/profile/{parse.quote(user_id)}/displayname", - token=access_token, - body={"displayname": displayname}, - timeout=15, - ) - except Exception: - return - - def _register_user(localpart): - url = f"{MATRIX_BASE}/_matrix/client/v3/register?access_token={parse.quote(AS_TOKEN)}" - status, payload = _json( - "POST", - url, - body={ - "type": "m.login.application_service", - "username": localpart, - "inhibit_login": False, - "initial_device_display_name": "Guest session", - }, - timeout=25, + def _add_user(admin_token, username): + data = _gql( + admin_token, + "mutation($input:AddUserInput!){addUser(input:$input){status user{id}}}", + {"input": {"username": username, "skipHomeserverCheck": True}}, ) - if status != 200 or "access_token" not in payload: - raise RuntimeError("register_failed") - return payload + res = data.get("addUser") or {} + status = res.get("status") + user_id = (res.get("user") or {}).get("id") + return status, user_id + + def _set_display_name(admin_token, user_id, displayname): + _gql( + admin_token, + "mutation($input:SetDisplayNameInput!){setDisplayName(input:$input){status}}", + {"input": {"userId": user_id, "displayName": displayname}}, + ) + + def _create_oauth2_session(admin_token, user_id, scope): + data = _gql( + admin_token, + "mutation($input:CreateOAuth2SessionInput!){createOauth2Session(input:$input){accessToken}}", + {"input": {"userId": user_id, "scope": scope, "permanent": False}}, + ) + return (data.get("createOauth2Session") or {}).get("accessToken") def _rate_check(ip, now): win, cnt = _rate.get(ip, (now, 0)) @@ -91,17 +149,6 @@ data: _rate[ip] = (win, cnt + 1) return True - def _is_appservice_auth(auth_header): - if not auth_header: - return False - parts = auth_header.split(" ", 1) - if len(parts) != 2: - return False - scheme, token = parts - if scheme.lower() != "bearer": - return False - return secrets.compare_digest(token, HS_TOKEN) - class Handler(BaseHTTPRequestHandler): server_version = "matrix-guest-register" @@ -124,31 +171,12 @@ data: self.end_headers() def do_GET(self): # noqa: N802 - parsed = parse.urlparse(self.path) - - if parsed.path in ("/healthz", "/"): + if self.path in ("/healthz", "/"): return self._send_json(200, {"ok": True}) - - if parsed.path.startswith("/_matrix/app/v1/users/"): - if not _is_appservice_auth(self.headers.get("authorization", "")): - return self._send_json(401, {"errcode": "M_UNAUTHORIZED", "error": "unauthorized"}) - return self._send_json(200, {}) - - if parsed.path.startswith("/_matrix/app/v1/rooms/"): - if not _is_appservice_auth(self.headers.get("authorization", "")): - return self._send_json(401, {"errcode": "M_UNAUTHORIZED", "error": "unauthorized"}) - return self._send_json(200, {}) - return self._send_json(404, {"errcode": "M_NOT_FOUND", "error": "not_found"}) def do_POST(self): # noqa: N802 parsed = parse.urlparse(self.path) - - if parsed.path.startswith("/_matrix/app/v1/transactions/"): - if not _is_appservice_auth(self.headers.get("authorization", "")): - return self._send_json(401, {"errcode": "M_UNAUTHORIZED", "error": "unauthorized"}) - return self._send_json(200, {}) - if parsed.path not in ("/_matrix/client/v3/register", "/_matrix/client/r0/register"): return self._send_json(404, {"errcode": "M_NOT_FOUND", "error": "not_found"}) @@ -173,17 +201,35 @@ data: _ = self.rfile.read(length) if length else b"{}" try: + admin_token = _mas_admin_access_token(now) displayname = _generate_displayname() - localpart = _generate_localpart() - login_payload = _register_user(localpart) - _set_displayname(login_payload.get("access_token"), login_payload.get("user_id"), displayname) + + localpart = None + mas_user_id = None + for _ in range(5): + localpart = _generate_localpart() + status, mas_user_id = _add_user(admin_token, localpart) + if status == "ADDED": + break + mas_user_id = None + if not mas_user_id or not localpart: + raise RuntimeError("add_user_failed") + + try: + _set_display_name(admin_token, mas_user_id, displayname) + except Exception: + pass + + access_token = _create_oauth2_session(admin_token, mas_user_id, "openid email") + if not access_token: + raise RuntimeError("session_failed") except Exception: return self._send_json(502, {"errcode": "M_UNKNOWN", "error": "guest_provision_failed"}) resp = { - "user_id": login_payload.get("user_id") or f"@{localpart}:{SERVER_NAME}", - "access_token": login_payload.get("access_token"), - "device_id": login_payload.get("device_id"), + "user_id": f"@{localpart}:{SERVER_NAME}", + "access_token": access_token, + "device_id": "g-" + secrets.token_hex(6), "home_server": SERVER_NAME, } return self._send_json(200, resp) diff --git a/services/communication/guest-register-deployment.yaml b/services/communication/guest-register-deployment.yaml index 985e1eb..790cda9 100644 --- a/services/communication/guest-register-deployment.yaml +++ b/services/communication/guest-register-deployment.yaml @@ -35,20 +35,14 @@ spec: value: "1" - name: PORT value: "8080" - - name: MATRIX_BASE - value: http://othrys-synapse-matrix-synapse:8008 + - name: MAS_BASE + value: http://matrix-authentication-service:8080 + - name: MAS_ADMIN_CLIENT_ID + value: 01KDXMVQBQ5JNY6SEJPZW6Z8BM + - name: MAS_ADMIN_CLIENT_SECRET_FILE + value: /etc/mas/admin-client/client_secret - name: MATRIX_SERVER_NAME value: live.bstein.dev - - name: AS_TOKEN - valueFrom: - secretKeyRef: - name: synapse-guest-appservice-runtime - key: as_token - - name: HS_TOKEN - valueFrom: - secretKeyRef: - name: synapse-guest-appservice-runtime - key: hs_token - name: RATE_WINDOW_SEC value: "60" - name: RATE_MAX @@ -83,6 +77,9 @@ spec: mountPath: /app/server.py subPath: server.py readOnly: true + - name: mas-admin-client + mountPath: /etc/mas/admin-client + readOnly: true command: - python - /app/server.py @@ -93,3 +90,9 @@ spec: items: - key: server.py path: server.py + - name: mas-admin-client + secret: + secretName: mas-admin-client-runtime + items: + - key: client_secret + path: client_secret diff --git a/services/communication/synapse-rendered.yaml b/services/communication/synapse-rendered.yaml index be6084e..22b68cf 100644 --- a/services/communication/synapse-rendered.yaml +++ b/services/communication/synapse-rendered.yaml @@ -313,8 +313,6 @@ data: ## Registration ## enable_registration: false - app_service_config_files: - - /synapse/appservices/guest-register.yaml ## Metrics ### @@ -795,9 +793,6 @@ spec: mountPath: /synapse/secrets - name: signingkey mountPath: /synapse/keys - - name: appservices - mountPath: /synapse/appservices - readOnly: true - name: media mountPath: /synapse/data - name: tmpdir @@ -822,12 +817,6 @@ spec: items: - key: "signing.key" path: signing.key - - name: appservices - secret: - secretName: synapse-guest-appservice-runtime - items: - - key: registration.yaml - path: guest-register.yaml - name: tmpconf emptyDir: {} - name: tmpdir