diff --git a/mocks/mock-google-oidc/.gitignore b/mocks/mock-google-oidc/.gitignore new file mode 100644 index 000000000..504afef81 --- /dev/null +++ b/mocks/mock-google-oidc/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/mocks/mock-google-oidc/README.md b/mocks/mock-google-oidc/README.md new file mode 100644 index 000000000..ad00cc2a6 --- /dev/null +++ b/mocks/mock-google-oidc/README.md @@ -0,0 +1,73 @@ +# Mock Google OIDC Provider + +A lightweight mock OpenID Connect provider that simulates Google login for local MAS development. It lets you test the upstream OAuth2 / "Sign in with Google" flow without needing real Google credentials. + +## Prerequisites + +- Node.js 18+ +- MAS configured with an upstream OAuth2 provider pointing to this mock + +## Quick start + +```sh +cd mock-google-oidc +npm install +npm start +``` + +The server starts at `http://localhost:5556`. + +## How it works + +The mock uses [`oidc-provider`](https://github.com/panva/node-oidc-provider) to run a standards-compliant OIDC server with dev interactions enabled. When MAS redirects to the mock for login, you'll see a simple form where you can enter any email-like value (e.g. `taylor@gmail.com`). The mock will return that as the authenticated user with synthetic profile claims. + +## MAS configuration + +Add an upstream OAuth2 provider entry in your MAS `config.yaml`: + +```yaml +upstream_oauth2: + providers: + - id: "01JQ0FAKEG00G1E0D1CPR0V1D3" + human_name: "Google" + issuer: "http://localhost:5556" + client_id: "mas-dev" + client_secret: "mas-dev-secret" + token_endpoint_auth_method: "client_secret_post" + scope: "openid email profile" + claims_imports: + localpart: + action: suggest + template: "{{ user.preferred_username }}" + displayname: + action: suggest + template: "{{ user.name }}" + email: + action: suggest + template: "{{ user.email }}" + set_email_verification: always +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MOCK_OIDC_ISSUER` | `http://localhost:5556` | The issuer URL | +| `MAS_PROVIDER_ID` | `01JQ0FAKEG00G1E0D1CPR0V1D3` | The upstream provider ID configured in MAS | +| `MOCK_OIDC_CLIENT_ID` | `mas-dev` | OAuth2 client ID | +| `MOCK_OIDC_CLIENT_SECRET` | `mas-dev-secret` | OAuth2 client secret | + +## Running with the full dev stack + +```sh +# Terminal 1: Mock Synapse (handles homeserver API calls) +python3 mock_synapse.py + +# Terminal 2: Mock Google OIDC +cd mock-google-oidc && npm start + +# Terminal 3: MAS +cargo run -- server -c config.yaml +``` + +Then visit `http://[::]:8080/login` and click "Sign in with Google". diff --git a/mocks/mock-google-oidc/package.json b/mocks/mock-google-oidc/package.json new file mode 100644 index 000000000..a17d78baa --- /dev/null +++ b/mocks/mock-google-oidc/package.json @@ -0,0 +1,12 @@ +{ + "name": "mock-google-oidc", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs" + }, + "dependencies": { + "jose": "^6.0.11", + "oidc-provider": "^9.5.1" + } +} diff --git a/mocks/mock-google-oidc/server.mjs b/mocks/mock-google-oidc/server.mjs new file mode 100644 index 000000000..a8b8efeca --- /dev/null +++ b/mocks/mock-google-oidc/server.mjs @@ -0,0 +1,77 @@ +import { exportJWK, generateKeyPair } from 'jose'; +import { Provider } from 'oidc-provider'; + +const ISSUER = process.env.MOCK_OIDC_ISSUER ?? 'http://localhost:5556'; +const PROVIDER_ID = + process.env.MAS_PROVIDER_ID ?? '01JQ0FAKEG00G1E0D1CPR0V1D3'; +const CLIENT_ID = process.env.MOCK_OIDC_CLIENT_ID ?? 'mas-dev'; +const CLIENT_SECRET = + process.env.MOCK_OIDC_CLIENT_SECRET ?? 'mas-dev-secret'; + +const redirectUris = [ + `http://localhost:8080/upstream/callback/${PROVIDER_ID}`, + `http://127.0.0.1:8080/upstream/callback/${PROVIDER_ID}`, + `http://[::]:8080/upstream/callback/${PROVIDER_ID}`, +]; + +const { privateKey } = await generateKeyPair('RS256', { extractable: true }); +const jwk = await exportJWK(privateKey); +jwk.use = 'sig'; +jwk.alg = 'RS256'; +jwk.kid = 'mock-google-rs256'; + +const configuration = { + clients: [ + { + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + redirect_uris: redirectUris, + response_types: ['code'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'client_secret_post', + }, + ], + jwks: { keys: [jwk] }, + claims: { + openid: ['sub'], + email: ['email', 'email_verified'], + profile: ['name', 'preferred_username', 'picture'], + }, + cookies: { + keys: ['mock-google-oidc-dev-key-1', 'mock-google-oidc-dev-key-2'], + }, + features: { + devInteractions: { enabled: true }, + rpInitiatedLogout: { enabled: false }, + }, + async findAccount(_ctx, sub) { + const email = sub.includes('@') ? sub : `${sub}@gmail.com`; + const preferredUsername = email.split('@')[0]; + + return { + accountId: sub, + async claims(_use, _scope) { + return { + sub, + email, + email_verified: true, + name: 'Taylor Google User', + preferred_username: preferredUsername, + picture: + 'https://www.gstatic.com/images/branding/product/1x/avatar_circle_blue_512dp.png', + }; + }, + }; + }, +}; + +const provider = new Provider(ISSUER, configuration); + +provider.listen(5556, () => { + console.log(`Mock Google OIDC running at ${ISSUER}`); + console.log('Configured redirect URIs:'); + for (const uri of redirectUris) { + console.log(` - ${uri}`); + } + console.log('Use any email-like value in the dev interaction login form.'); +}); diff --git a/mocks/mock_synapse.py b/mocks/mock_synapse.py new file mode 100644 index 000000000..d0bc9c518 --- /dev/null +++ b/mocks/mock_synapse.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +Mock Synapse server for testing MAS UI without a real homeserver. + +Handles both Modern (_synapse/mas/...) and Legacy (_synapse/admin/...) +API endpoints that MAS calls on Synapse. + +Usage: + python3 mock_synapse.py + +Then start MAS normally: + cargo run -- server -c config.yaml +""" + +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs +import json +import sys + + +# In-memory store of provisioned users: localpart -> user data +provisioned_users = {} + + +class MockSynapseHandler(BaseHTTPRequestHandler): + + def _send_json(self, data, status=200): + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + if length: + return json.loads(self.rfile.read(length)) + return {} + + # ── GET endpoints ────────────────────────────────────────────── + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") + qs = parse_qs(parsed.query) + + # Modern: check if localpart is available + if path == "/_synapse/mas/is_localpart_available": + localpart = qs.get("localpart", [""])[0] + if localpart in provisioned_users: + print(f" [mock] is_localpart_available: {localpart} → in use") + self._send_json({"errcode": "M_USER_IN_USE", "error": "User ID already taken"}, 409) + else: + print(f" [mock] is_localpart_available: {localpart} → available") + self._send_json({}) # 200 = available + return + + # Modern: query user info + if path == "/_synapse/mas/query_user": + localpart = qs.get("localpart", [""])[0] + if localpart in provisioned_users: + user = provisioned_users[localpart] + print(f" [mock] query_user: {localpart} → found") + self._send_json({ + "user_id": f"@{localpart}:localhost:8008", + "display_name": user.get("display_name"), + "avatar_url": user.get("avatar_url"), + "is_suspended": False, + "is_deactivated": False, + }) + else: + print(f" [mock] query_user: {localpart} → not found (404)") + self._send_json({"errcode": "M_NOT_FOUND", "error": "User not found"}, 404) + return + + # Legacy: username available + if path == "/_synapse/admin/v1/username_available": + username = qs.get("username", [""])[0] + print(f" [mock] username_available: {username} → available=true") + self._send_json({"available": True}) + return + + # Legacy: get user info + if "/_synapse/admin/v2/users/" in path and "/devices" not in path: + print(f" [mock] get user → not found (404)") + self._send_json({"errcode": "M_NOT_FOUND", "error": "User not found"}, 404) + return + + # Legacy: list devices + if "/_synapse/admin/v2/users/" in path and path.endswith("/devices"): + print(f" [mock] list devices → empty") + self._send_json({"devices": []}) + return + + # Matrix client versions + if path == "/_matrix/client/versions": + self._send_json({"versions": ["v1.1", "v1.2", "v1.3"]}) + return + + # Catch-all + print(f" [mock] Unhandled GET: {self.path}") + self._send_json({}) + + # ── POST endpoints ───────────────────────────────────────────── + + def do_POST(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") + body = self._read_body() + + # Modern: provision user + if path == "/_synapse/mas/provision_user": + localpart = body.get("localpart", "?") + is_new = localpart not in provisioned_users + provisioned_users[localpart] = { + "display_name": body.get("set_displayname"), + "avatar_url": body.get("set_avatar_url"), + "emails": body.get("set_emails", []), + } + status = 201 if is_new else 200 + print(f" [mock] provision_user: {localpart} → {status} {'created' if is_new else 'updated'}") + self._send_json({}, status) + return + + # Modern: upsert device + if path == "/_synapse/mas/upsert_device": + print(f" [mock] upsert_device: {body.get('device_id', '?')} → ok") + self._send_json({}) + return + + # Modern: update device display name + if path == "/_synapse/mas/update_device_display_name": + print(f" [mock] update_device_display_name → ok") + self._send_json({}) + return + + # Modern: delete device + if path == "/_synapse/mas/delete_device": + print(f" [mock] delete_device → ok") + self._send_json({}) + return + + # Modern: sync devices + if path == "/_synapse/mas/sync_devices": + print(f" [mock] sync_devices → ok") + self._send_json({}) + return + + # Modern: delete user + if path == "/_synapse/mas/delete_user": + print(f" [mock] delete_user → ok") + self._send_json({}) + return + + # Modern: reactivate user + if path == "/_synapse/mas/reactivate_user": + print(f" [mock] reactivate_user → ok") + self._send_json({}) + return + + # Modern: set displayname + if path == "/_synapse/mas/set_displayname": + localpart = body.get("localpart", "?") + if localpart in provisioned_users: + provisioned_users[localpart]["display_name"] = body.get("displayname") + print(f" [mock] set_displayname: {localpart} → ok") + self._send_json({}) + return + + # Modern: unset displayname + if path == "/_synapse/mas/unset_displayname": + localpart = body.get("localpart", "?") + if localpart in provisioned_users: + provisioned_users[localpart]["display_name"] = None + print(f" [mock] unset_displayname: {localpart} → ok") + self._send_json({}) + return + + # Modern: allow cross-signing reset + if path == "/_synapse/mas/allow_cross_signing_reset": + print(f" [mock] allow_cross_signing_reset → ok") + self._send_json({}) + return + + # Legacy: create device + if "/_synapse/admin/v2/users/" in path and path.endswith("/devices"): + print(f" [mock] legacy create device → 201") + self._send_json({}, 201) + return + + # Legacy: bulk delete devices + if "/_synapse/admin/v2/users/" in path and "delete_devices" in path: + print(f" [mock] legacy bulk delete devices → ok") + self._send_json({}) + return + + # Legacy: deactivate user + if "/_synapse/admin/v1/deactivate/" in path: + print(f" [mock] legacy deactivate → ok") + self._send_json({}) + return + + # Legacy: allow cross-signing reset + if "_allow_cross_signing_replacement_without_uia" in path: + print(f" [mock] legacy allow cross-signing reset → ok") + self._send_json({}) + return + + # Catch-all + print(f" [mock] Unhandled POST: {path} body={body}") + self._send_json({}) + + # ── PUT endpoints ────────────────────────────────────────────── + + def do_PUT(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") + body = self._read_body() + + # Legacy: create/update user + if "/_synapse/admin/v2/users/" in path and "/devices/" not in path: + print(f" [mock] legacy put user → 201 created") + self._send_json({}, 201) + return + + # Legacy: update device display name + if "/_synapse/admin/v2/users/" in path and "/devices/" in path: + print(f" [mock] legacy update device display name → ok") + self._send_json({}) + return + + # Client API: set displayname + if "/_matrix/client/v3/profile/" in path and "/displayname" in path: + print(f" [mock] set profile displayname → ok") + self._send_json({}) + return + + # Catch-all + print(f" [mock] Unhandled PUT: {path} body={body}") + self._send_json({}) + + # ── DELETE endpoints ─────────────────────────────────────────── + + def do_DELETE(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") + + # Legacy: delete device + if "/_synapse/admin/v2/users/" in path and "/devices/" in path: + print(f" [mock] legacy delete device → ok") + self._send_json({}) + return + + # Catch-all + print(f" [mock] Unhandled DELETE: {path}") + self._send_json({}) + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8008 + server = HTTPServer(("localhost", port), MockSynapseHandler) + print(f"Mock Synapse running on http://localhost:{port}") + print("Press Ctrl+C to stop\n") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.")