atlogin + MatrixAuthentificationService + Matrix
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add nix vm to test login on matrix with atproto

edouardparis dae19063

+2412
+1
.gitignore
··· 1 + matrix-test.qcow2
+155
README.md
··· 1 + # Matrix Synapse + Element Web + ATLogin 2 + 3 + A NixOS infrastructure for Matrix with Bluesky/ATProto authentication via ATLogin. 4 + 5 + > **Warning**: The VM uses hardcoded dummy secrets for local testing only. Do not use these values in production. 6 + 7 + ## VM Test Environment 8 + 9 + ### Quick Start 10 + 11 + **Without tunnel** (localhost only - won't work with external PDS): 12 + ```bash 13 + nix run .#vm 14 + ``` 15 + 16 + **With Tailscale** (recommended - works with Element X on phone): 17 + 18 + ```bash 19 + TAILSCALE_HOSTNAME=matrix-test.tail1234.ts.net \ 20 + TAILSCALE_AUTHKEY=tskey-auth-xxx \ 21 + nix run --impure .#vm 22 + ``` 23 + 24 + Then: 25 + - From your computer: open http://localhost:8080 or https://matrix-test.tail1234.ts.net 26 + - From your phone: use `https://matrix-test.tail1234.ts.net` as homeserver 27 + 28 + **With ngrok** (alternative): 29 + 30 + ```bash 31 + # Terminal 1: Start ngrok 32 + ngrok http 9411 33 + 34 + # Terminal 2: Start the VM with the ngrok URL 35 + ATLOGIN_URL=https://abc123.ngrok-free.app nix run --impure .#vm 36 + ``` 37 + 38 + Then open http://localhost:8080 in your browser. 39 + 40 + ### Services 41 + 42 + | Service | URL | Description | 43 + |-------------|--------------------------|--------------------------------| 44 + | Element Web | http://localhost:8080 | Matrix web client | 45 + | Synapse API | http://localhost:8008 | Matrix homeserver | 46 + | ATLogin | http://localhost:9411 | ATProto OIDC provider | 47 + 48 + ### Why is a tunnel needed? 49 + 50 + ATProto OAuth uses Pushed Authorization Requests (PAR). When you authenticate: 51 + 52 + 1. You click "Sign in with Bluesky" in Element 53 + 2. ATLogin starts an OAuth flow with your PDS (e.g., bsky.social) 54 + 3. **Your PDS needs to fetch ATLogin's client-metadata.json to validate the client** 55 + 4. If ATLogin is on localhost, the PDS can't reach it 56 + 57 + Tailscale (with HTTPS enabled) or ngrok provides a URL that the PDS can reach. 58 + 59 + ### Tailscale (Recommended) 60 + 61 + Tailscale Funnel exposes the VM publicly with HTTPS. Benefits over ngrok: 62 + - Stable URL (doesn't change on restart) 63 + - HTTPS with auto-generated certificates 64 + - Access from any device 65 + 66 + #### 1. Enable HTTPS in Tailscale Admin 67 + 68 + 1. Go to [Tailscale Admin Console](https://login.tailscale.com/admin/settings/features) 69 + 2. Enable "HTTPS Certificates" for your tailnet 70 + 71 + #### 2. Get an Auth Key 72 + 73 + 1. Go to [Tailscale Keys](https://login.tailscale.com/admin/settings/keys) 74 + 2. Generate a new auth key (reusable + ephemeral recommended for test VMs) 75 + 3. Copy the key (starts with `tskey-auth-`) 76 + 77 + #### 3. Find Your Hostname 78 + 79 + Your hostname will be: `<machine-name>.<tailnet-name>.ts.net` 80 + 81 + For example: `matrix-test.tail1234.ts.net` 82 + 83 + #### 4. Start the VM 84 + 85 + ```bash 86 + TAILSCALE_HOSTNAME=matrix-test.tail1234.ts.net \ 87 + TAILSCALE_AUTHKEY=tskey-auth-xxx \ 88 + nix run --impure .#vm 89 + ``` 90 + 91 + The VM will: 92 + - Auto-join your tailnet on boot 93 + - Generate HTTPS certificate via `tailscale cert` 94 + - Set all URLs to use your Tailscale hostname 95 + 96 + #### 5. Test with Element X 97 + 98 + On your phone: 99 + 1. Install Element X 100 + 2. Enter homeserver: `https://matrix-test.tail1234.ts.net` 101 + 3. Sign in with Bluesky 102 + 103 + ### Step-by-Step with ngrok 104 + 105 + #### 1. Install ngrok 106 + 107 + ```bash 108 + # macOS 109 + brew install ngrok 110 + 111 + # Or download from https://ngrok.com/download 112 + ``` 113 + 114 + #### 2. Start ngrok 115 + 116 + ```bash 117 + ngrok http 9411 118 + ``` 119 + 120 + You'll see output like: 121 + ``` 122 + Forwarding https://abc123.ngrok-free.app -> http://localhost:9411 123 + ``` 124 + 125 + Copy that `https://...ngrok-free.app` URL. 126 + 127 + #### 3. Start the VM 128 + 129 + ```bash 130 + ATLOGIN_URL=https://abc123.ngrok-free.app nix run .#vm 131 + ``` 132 + 133 + The VM will automatically configure ATLogin and Synapse to use this public URL. 134 + 135 + #### 4. Test 136 + 137 + 1. Open http://localhost:8080 138 + 2. Click "Sign in with Bluesky" 139 + 3. Enter your Bluesky handle (e.g., `alice.bsky.social`) 140 + 4. Authenticate with your Bluesky account 141 + 5. You're logged into Matrix! 142 + 143 + ### VM Controls 144 + 145 + - **Quit VM**: Press `Ctrl+A X` 146 + - **Console**: Auto-logs in as root 147 + - **Logs**: `journalctl -u atlogin -f` or `journalctl -u matrix-synapse -f` 148 + 149 + ### Ports 150 + 151 + The VM forwards these ports to your host: 152 + 153 + - `8080` → `80` (nginx/Element Web) 154 + - `8008` → `8008` (Synapse) 155 + - `9411` → `9411` (ATLogin)
+44
flake.lock
··· 1 + { 2 + "nodes": { 3 + "atlogin": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1768427678, 7 + "narHash": "sha256-E4B1zj3jYxVw9LKxLkJjNwa72UfrrkRJj4sxPnHhdsA=", 8 + "owner": "apenwarr", 9 + "repo": "atlogin", 10 + "rev": "943b11de2f5592a6680a826c67e763f292c664ff", 11 + "type": "github" 12 + }, 13 + "original": { 14 + "owner": "apenwarr", 15 + "repo": "atlogin", 16 + "type": "github" 17 + } 18 + }, 19 + "nixpkgs": { 20 + "locked": { 21 + "lastModified": 1772433332, 22 + "narHash": "sha256-izhTDFKsg6KeVBxJS9EblGeQ8y+O8eCa6RcW874vxEc=", 23 + "owner": "NixOS", 24 + "repo": "nixpkgs", 25 + "rev": "cf59864ef8aa2e178cccedbe2c178185b0365705", 26 + "type": "github" 27 + }, 28 + "original": { 29 + "owner": "NixOS", 30 + "ref": "nixos-unstable", 31 + "repo": "nixpkgs", 32 + "type": "github" 33 + } 34 + }, 35 + "root": { 36 + "inputs": { 37 + "atlogin": "atlogin", 38 + "nixpkgs": "nixpkgs" 39 + } 40 + } 41 + }, 42 + "root": "root", 43 + "version": 7 44 + }
+81
flake.nix
··· 1 + { 2 + description = "Matrix Synapse and Element Web with ATLogin authentication"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + atlogin = { 7 + url = "github:apenwarr/atlogin"; 8 + flake = false; 9 + }; 10 + }; 11 + 12 + outputs = { 13 + self, 14 + nixpkgs, 15 + atlogin, 16 + ... 17 + }: let 18 + system = "x86_64-linux"; 19 + pkgs = nixpkgs.legacyPackages.${system}; 20 + pkgsUnfree = import nixpkgs { 21 + inherit system; 22 + config.allowUnfree = true; 23 + }; 24 + 25 + # Packages 26 + synapsePlugins = import ./packages/synapse-plugins { inherit pkgs; }; 27 + loginPage = import ./packages/login-page { inherit pkgs; }; 28 + in { 29 + # NixOS configurations 30 + nixosConfigurations.vm-test = nixpkgs.lib.nixosSystem { 31 + inherit system; 32 + modules = [ 33 + ./modules/atlogin.nix 34 + ./modules/synapse-oidc.nix 35 + ./modules/matrix-auth-service.nix 36 + ./modules/synapse-mas.nix 37 + ./hosts/vm-test 38 + { 39 + vm-test = { 40 + atloginSrc = atlogin; 41 + synapsePlugins = [synapsePlugins.avatarSync]; 42 + loginPage = loginPage; 43 + }; 44 + } 45 + ]; 46 + }; 47 + 48 + # Packages 49 + packages.${system} = { 50 + vm = self.nixosConfigurations.vm-test.config.vm-test.wrappedVm; 51 + default = self.nixosConfigurations.vm-test.config.vm-test.wrappedVm; 52 + }; 53 + 54 + # Apps 55 + apps.${system} = { 56 + vm = { 57 + type = "app"; 58 + program = "${self.nixosConfigurations.vm-test.config.vm-test.wrappedVm}/bin/run-matrix-test-vm"; 59 + }; 60 + default = { 61 + type = "app"; 62 + program = "${self.nixosConfigurations.vm-test.config.vm-test.wrappedVm}/bin/run-matrix-test-vm"; 63 + }; 64 + }; 65 + 66 + # Export modules for reuse 67 + nixosModules = { 68 + atlogin = ./modules/atlogin.nix; 69 + synapse-oidc = ./modules/synapse-oidc.nix; 70 + matrix-auth-service = ./modules/matrix-auth-service.nix; 71 + synapse-mas = ./modules/synapse-mas.nix; 72 + }; 73 + 74 + # Dev shell with ngrok 75 + devShells.${system}.default = pkgs.mkShell { 76 + packages = [ 77 + pkgsUnfree.ngrok 78 + ]; 79 + }; 80 + }; 81 + }
+618
hosts/vm-test/default.nix
··· 1 + # Test VM configuration for local development 2 + # Uses Matrix Authentication Service (MAS) for Element X compatibility 3 + { config, pkgs, lib, modulesPath, ... }: 4 + 5 + let 6 + # Port configuration 7 + atloginPort = 9411; 8 + masPort = 8081; 9 + masInternalPort = 8082; 10 + synapsePort = 8008; 11 + 12 + # Client IDs 13 + atloginClientId = "matrix-synapse"; # Legacy - ATLogin direct client 14 + masClientId = "mas-atlogin-client"; # MAS's client ID with ATLogin 15 + 16 + # Test secrets (NOT for production!) 17 + testSecret = "test-secret-for-local-dev"; 18 + # MAS encryption secret must be hex-encoded (at least 32 bytes = 64 hex chars) 19 + # This is hex for "test-encryption-secret-32-bytes!" 20 + masEncryptionSecret = "746573742d656e6372797074696f6e2d7365637265742d33322d627974657321"; 21 + masSynapseSecret = "mas-synapse-shared-secret-for-testing"; 22 + 23 + # Upstream provider ULID (generated once, keep stable) 24 + atloginProviderId = "01JAYS74TCG3BTWKADN5Q4518C"; 25 + 26 + # Tailscale configuration (requires --impure) 27 + # TAILSCALE_AUTHKEY: Auth key from https://login.tailscale.com/admin/settings/keys 28 + # TAILSCALE_HOSTNAME: Your Funnel hostname (e.g., "matrix-test.tail1234.ts.net") 29 + tailscaleAuthKey = let 30 + envKey = builtins.getEnv "TAILSCALE_AUTHKEY"; 31 + in 32 + if envKey != "" then envKey else null; 33 + 34 + tailscaleHostname = let 35 + envHostname = builtins.getEnv "TAILSCALE_HOSTNAME"; 36 + in 37 + if envHostname != "" then envHostname else null; 38 + 39 + # Determine base URL based on Tailscale or localhost 40 + # With Tailscale Funnel, we get HTTPS on the public hostname 41 + publicBaseUrl = if tailscaleHostname != null 42 + then "https://${tailscaleHostname}" 43 + else "http://localhost:8080"; 44 + 45 + # Read ATLOGIN_URL from environment at build time (requires --impure) 46 + # Priority: ATLOGIN_URL env > Tailscale Funnel > localhost 47 + atloginUrl = let 48 + envUrl = builtins.getEnv "ATLOGIN_URL"; 49 + in 50 + if envUrl != "" then envUrl 51 + else if tailscaleHostname != null then "https://${tailscaleHostname}/atlogin" 52 + else null; 53 + 54 + effectiveAtloginUrl = if atloginUrl != null 55 + then atloginUrl 56 + else "http://localhost:${toString atloginPort}"; 57 + 58 + # MAS public URL (accessible from browser) 59 + masPublicUrl = "${publicBaseUrl}/auth/"; 60 + 61 + # Write dummy secrets to files for testing 62 + dummySecretFile = secret: pkgs.writeText "dummy-secret" secret; 63 + in { 64 + imports = [ 65 + (modulesPath + "/virtualisation/qemu-vm.nix") 66 + # Modules imported via flake.nix: 67 + # - atlogin.nix 68 + # - synapse-oidc.nix (disabled, kept for reference) 69 + # - matrix-auth-service.nix 70 + # - synapse-mas.nix 71 + ]; 72 + 73 + # Options set by flake.nix 74 + options.vm-test = { 75 + atloginSrc = lib.mkOption { type = lib.types.path; }; 76 + synapsePlugins = lib.mkOption { type = lib.types.listOf lib.types.package; }; 77 + loginPage = lib.mkOption { type = lib.types.package; }; 78 + 79 + # VM wrapper script 80 + wrappedVm = lib.mkOption { 81 + type = lib.types.package; 82 + readOnly = true; 83 + description = "Wrapped VM script with environment setup"; 84 + }; 85 + }; 86 + 87 + config = { 88 + # VM wrapper script 89 + vm-test.wrappedVm = pkgs.writeShellScriptBin "run-matrix-test-vm" '' 90 + export LD_LIBRARY_PATH="" 91 + export QEMU_AUDIO_DRV=none 92 + 93 + ${if tailscaleHostname != null then '' 94 + echo "Tailscale HTTPS enabled!" 95 + echo " URL: https://${tailscaleHostname}" 96 + echo " ATLogin: https://${tailscaleHostname}/atlogin" 97 + echo "" 98 + echo "Access from Element X on your phone (on same tailnet):" 99 + echo " Homeserver: https://${tailscaleHostname}" 100 + echo "" 101 + ${if tailscaleAuthKey != null then '' 102 + echo "Tailscale auth key: configured (will auto-join tailnet)" 103 + '' else '' 104 + echo "NOTE: No TAILSCALE_AUTHKEY - run 'tailscale up' in VM to authenticate" 105 + ''} 106 + '' else if atloginUrl != null then '' 107 + echo "ATLogin URL (built-in): ${atloginUrl}" 108 + echo "" 109 + echo "Using ngrok/custom URL for ATLogin" 110 + '' else '' 111 + echo "No ATLOGIN_URL or TAILSCALE_HOSTNAME configured" 112 + echo "ATLogin will use localhost (won't work with external PDS)" 113 + echo "" 114 + echo "Options:" 115 + echo " 1. Tailscale: TAILSCALE_HOSTNAME=matrix-test.xxx.ts.net TAILSCALE_AUTHKEY=tskey-xxx nix run --impure .#vm" 116 + echo " 2. ngrok: ATLOGIN_URL=https://xxx.ngrok-free.app nix run --impure .#vm" 117 + ''} 118 + 119 + echo "" 120 + echo "This VM uses Matrix Authentication Service (MAS) for Element X compatibility!" 121 + echo "" 122 + 123 + exec ${config.system.build.vm}/bin/run-matrix-test-vm "$@" 124 + ''; 125 + 126 + system.stateVersion = "24.11"; 127 + networking.hostName = "matrix-test"; 128 + networking.firewall.allowedTCPPorts = [80 443 8080 synapsePort atloginPort masPort]; 129 + networking.nameservers = ["8.8.8.8" "1.1.1.1"]; 130 + 131 + # ========================================================================= 132 + # Tailscale - VPN for phone access + Funnel for public ATLogin 133 + # ========================================================================= 134 + services.tailscale = { 135 + enable = true; 136 + openFirewall = true; 137 + # Auth key for automatic authentication (optional) 138 + authKeyFile = lib.mkIf (tailscaleAuthKey != null) 139 + (pkgs.writeText "tailscale-authkey" tailscaleAuthKey); 140 + }; 141 + 142 + # Enable Funnel to expose HTTPS publicly (required for ATLogin/PDS) 143 + systemd.services.tailscale-funnel = lib.mkIf (tailscaleHostname != null) { 144 + description = "Enable Tailscale Funnel"; 145 + after = [ "tailscaled.service" "caddy.service" ]; 146 + wants = [ "tailscaled.service" "caddy.service" ]; 147 + wantedBy = [ "multi-user.target" ]; 148 + path = [ pkgs.tailscale pkgs.jq ]; 149 + script = '' 150 + # Wait for tailscale to be connected 151 + for i in $(seq 1 10); do 152 + if tailscale status --json | jq -e '.Self.Online' > /dev/null 2>&1; then 153 + break 154 + fi 155 + sleep 2 156 + done 157 + 158 + # Enable funnel - forward to Caddy HTTP (funnel terminates TLS) 159 + tailscale funnel --bg 80 160 + ''; 161 + serviceConfig = { 162 + Type = "oneshot"; 163 + RemainAfterExit = true; 164 + }; 165 + }; 166 + 167 + # VM settings 168 + virtualisation = { 169 + memorySize = 2048; 170 + cores = 2; 171 + graphics = false; 172 + qemu.options = ["-audiodev" "none,id=audio0"]; 173 + forwardPorts = [ 174 + { from = "host"; host.port = synapsePort; guest.port = synapsePort; } 175 + { from = "host"; host.port = 8080; guest.port = 80; } 176 + { from = "host"; host.port = atloginPort; guest.port = atloginPort; } 177 + { from = "host"; host.port = masPort; guest.port = masPort; } 178 + ]; 179 + }; 180 + 181 + # Debug tools 182 + environment.systemPackages = with pkgs; [ 183 + postgresql 184 + curl 185 + jq 186 + matrix-synapse 187 + synadm 188 + ]; 189 + 190 + # ========================================================================= 191 + # ATLogin - Upstream OIDC provider for ATProto/Bluesky authentication 192 + # ========================================================================= 193 + services.atlogin = { 194 + enable = true; 195 + src = config.vm-test.atloginSrc; 196 + port = atloginPort; 197 + issuer = effectiveAtloginUrl; 198 + clientName = "Matrix Local Test (via MAS)"; 199 + # Register MAS as a client 200 + clients = { 201 + ${masClientId} = { 202 + secret = testSecret; 203 + # MAS callback URL for upstream OAuth2 providers 204 + redirectUris = [ 205 + "http://localhost:8080/auth/upstream/oauth2/callback/${atloginProviderId}" 206 + "${masPublicUrl}upstream/oauth2/callback/${atloginProviderId}" 207 + ] ++ lib.optionals (tailscaleHostname != null) [ 208 + "https://${tailscaleHostname}/auth/upstream/oauth2/callback/${atloginProviderId}" 209 + ]; 210 + }; 211 + }; 212 + }; 213 + 214 + # ========================================================================= 215 + # PostgreSQL - Used by MAS and Synapse 216 + # ========================================================================= 217 + services.postgresql = { 218 + enable = true; 219 + ensureDatabases = [ "mas" ]; 220 + ensureUsers = [ 221 + { 222 + name = "mas"; 223 + ensureDBOwnership = true; 224 + } 225 + { 226 + name = "matrix-synapse"; 227 + } 228 + ]; 229 + # Allow local connections without password for testing 230 + authentication = lib.mkForce '' 231 + local all all trust 232 + host all all 127.0.0.1/32 trust 233 + host all all ::1/128 trust 234 + ''; 235 + }; 236 + 237 + # Create Synapse database with C locale (required by Synapse) 238 + systemd.services.matrix-synapse-db = { 239 + description = "Create Matrix Synapse PostgreSQL database"; 240 + after = [ "postgresql.service" ]; 241 + requires = [ "postgresql.service" ]; 242 + before = [ "matrix-synapse.service" ]; 243 + requiredBy = [ "matrix-synapse.service" ]; 244 + path = [ config.services.postgresql.package pkgs.coreutils pkgs.gnugrep ]; 245 + serviceConfig = { 246 + Type = "oneshot"; 247 + User = "postgres"; 248 + RemainAfterExit = true; 249 + }; 250 + script = '' 251 + # Wait for PostgreSQL socket 252 + for i in $(seq 1 30); do 253 + if [ -S /run/postgresql/.s.PGSQL.5432 ]; then 254 + break 255 + fi 256 + sleep 1 257 + done 258 + 259 + # Create role if it doesn't exist 260 + if ! psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='matrix-synapse'" | grep -q 1; then 261 + psql -c "CREATE ROLE \"matrix-synapse\" LOGIN;" 262 + fi 263 + 264 + # Create database if it doesn't exist 265 + if ! psql -lqt | cut -d \| -f 1 | grep -qw "matrix-synapse"; then 266 + psql -c "CREATE DATABASE \"matrix-synapse\" ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE=template0 OWNER=\"matrix-synapse\";" 267 + fi 268 + ''; 269 + }; 270 + 271 + # ========================================================================= 272 + # Matrix Authentication Service (MAS) 273 + # Acts as OIDC provider for Matrix clients, federates to ATLogin 274 + # ========================================================================= 275 + services.matrix-auth-service = { 276 + enable = true; 277 + port = masPort; 278 + internalPort = masInternalPort; 279 + publicUrl = masPublicUrl; 280 + 281 + # Database (PostgreSQL required by MAS) 282 + database.uri = "postgresql://mas@localhost/mas"; 283 + 284 + # Synapse integration 285 + matrix = { 286 + serverName = "localhost"; 287 + synapseUrl = "http://127.0.0.1:${toString synapsePort}"; 288 + secretFile = dummySecretFile masSynapseSecret; 289 + }; 290 + 291 + # Secrets (dummy values for testing) 292 + secrets = { 293 + encryptionFile = dummySecretFile masEncryptionSecret; 294 + # signingKeyFile = null; # Auto-generate 295 + }; 296 + 297 + # Disable local passwords - all auth through ATLogin 298 + passwords.enabled = false; 299 + 300 + # ATLogin as upstream OIDC provider 301 + upstreamProviders = [ 302 + { 303 + id = atloginProviderId; 304 + issuer = effectiveAtloginUrl; 305 + human_name = "Sign in with Bluesky"; 306 + brand_name = "bluesky"; 307 + 308 + client_id = masClientId; 309 + client_secret_file = dummySecretFile testSecret; 310 + 311 + token_endpoint_auth_method = "client_secret_basic"; 312 + scope = "openid profile email"; 313 + 314 + # ATLogin doesn't have standard OIDC discovery 315 + discovery_mode = "disabled"; 316 + 317 + # Manual endpoints 318 + authorization_endpoint = "${publicBaseUrl}/atlogin/authorize"; 319 + token_endpoint = "${effectiveAtloginUrl}/token"; 320 + jwks_uri = "${effectiveAtloginUrl}/.well-known/jwks.json"; 321 + userinfo_endpoint = "${effectiveAtloginUrl}/userinfo"; 322 + 323 + # Map ATLogin claims to Matrix user 324 + claims_imports = { 325 + subject = { template = "{{ user.sub }}"; }; 326 + localpart = { 327 + action = "require"; 328 + template = "{{ user.sub | lower | replace('.', '_') | replace(':', '_') }}"; 329 + }; 330 + displayname = { 331 + action = "force"; # Always use upstream value, user cannot change 332 + template = "{{ user.name or user.handle or user.sub }}"; 333 + }; 334 + }; 335 + 336 + fetch_userinfo = true; 337 + forward_login_hint = true; 338 + } 339 + ]; 340 + 341 + # Policy 342 + policy = { 343 + adminUsers = [ "@did_web_did_atproto_fr:localhost" ]; 344 + allowPasswordRegistration = false; 345 + }; 346 + # Dynamic client registration is enabled for Element Web 347 + 348 + # Branding 349 + branding.serviceName = "Matrix Local Test"; 350 + 351 + # Account settings - disable changes (managed by upstream IdP) 352 + account.displaynameChangeAllowed = false; 353 + 354 + # Enable GraphQL playground for debugging 355 + enableGraphqlPlayground = true; 356 + }; 357 + 358 + # ========================================================================= 359 + # Synapse - Delegates auth to MAS 360 + # ========================================================================= 361 + 362 + # DISABLE legacy direct OIDC 363 + services.synapse-oidc.enable = false; 364 + 365 + # ENABLE MAS integration 366 + services.synapse-mas = { 367 + enable = true; 368 + # Public issuer URL (must match MAS publicUrl - what clients see in tokens) 369 + issuer = masPublicUrl; 370 + # Internal URL for Synapse to reach MAS for token introspection 371 + masInternalUrl = "http://127.0.0.1:${toString masPort}/"; 372 + sharedSecret = masSynapseSecret; 373 + }; 374 + 375 + services.matrix-synapse = { 376 + enable = true; 377 + # MAS handles OIDC - we only need authlib for MSC3861 token introspection 378 + extras = [ "postgres" ]; 379 + plugins = config.vm-test.synapsePlugins ++ [ 380 + pkgs.python3Packages.authlib 381 + ]; 382 + settings = { 383 + server_name = "localhost"; 384 + public_baseurl = publicBaseUrl; 385 + 386 + listeners = [ 387 + { 388 + port = synapsePort; 389 + bind_addresses = ["0.0.0.0"]; 390 + type = "http"; 391 + tls = false; 392 + x_forwarded = true; 393 + resources = [ 394 + { 395 + names = ["client" "federation"]; 396 + compress = true; 397 + } 398 + ]; 399 + } 400 + ]; 401 + 402 + enable_registration = false; 403 + auto_join_rooms = ["#atproto:localhost"]; 404 + 405 + database = { 406 + name = "psycopg2"; 407 + args = { 408 + user = "matrix-synapse"; 409 + database = "matrix-synapse"; 410 + host = "/run/postgresql"; 411 + cp_min = 5; 412 + cp_max = 10; 413 + }; 414 + }; 415 + 416 + media_store_path = "/var/lib/matrix-synapse/media_store"; 417 + 418 + # Disable rate limiting for local testing 419 + rc_message.per_second = 1000; 420 + rc_message.burst_count = 1000; 421 + rc_login.address.per_second = 1000; 422 + rc_login.address.burst_count = 1000; 423 + rc_login.account.per_second = 1000; 424 + rc_login.account.burst_count = 1000; 425 + 426 + suppress_key_server_warning = true; 427 + report_stats = false; 428 + 429 + enable_set_displayname = false; 430 + enable_set_avatar_url = false; 431 + 432 + room_list_publication_rules = [ 433 + { user_id = "@did_web_did_atproto_fr:localhost"; action = "allow"; } 434 + { action = "deny"; } 435 + ]; 436 + 437 + # Hardcoded for local testing only 438 + macaroon_secret_key = "local-test-macaroon-secret-key-not-for-production"; 439 + 440 + modules = [ 441 + { 442 + module = "synapse_avatar_sync.BlueskyAvatarSync"; 443 + config = {}; 444 + } 445 + ]; 446 + }; 447 + 448 + # Use MAS config instead of OIDC config 449 + extraConfigFiles = ["/var/lib/matrix-synapse/mas-config.yaml"]; 450 + }; 451 + 452 + # ========================================================================= 453 + # Element Web - Configured with OIDC discovery for MAS 454 + # ========================================================================= 455 + environment.etc."element-web".source = pkgs.element-web.override { 456 + conf = { 457 + default_server_config = { 458 + "m.homeserver" = { 459 + base_url = publicBaseUrl; 460 + server_name = "localhost"; 461 + }; 462 + # OIDC discovery for MAS (MSC2965) 463 + "org.matrix.msc2965.authentication" = { 464 + issuer = masPublicUrl; 465 + account = "${masPublicUrl}account/"; 466 + }; 467 + }; 468 + disable_guests = true; 469 + disable_3pid_login = true; 470 + disable_login_language_selector = true; 471 + disable_custom_urls = true; # Skip server selection screen 472 + default_language = "fr"; 473 + brand = "Matrix Local Test"; 474 + embedded_pages = { 475 + login_for_welcome = true; 476 + }; 477 + # Enable native OIDC with dynamic registration 478 + features = { 479 + feature_oidc_native_flow = true; 480 + }; 481 + }; 482 + }; 483 + 484 + # ========================================================================= 485 + # Caddy - Routes for Synapse, MAS, ATLogin 486 + # ========================================================================= 487 + services.caddy = let 488 + # Shared routes for all ports 489 + sharedRoutes = '' 490 + # Matrix well-known with OIDC discovery 491 + handle /.well-known/matrix/client { 492 + header Content-Type application/json 493 + header Access-Control-Allow-Origin * 494 + respond `{ 495 + "m.homeserver": {"base_url": "${publicBaseUrl}"}, 496 + "org.matrix.msc2965.authentication": { 497 + "issuer": "${masPublicUrl}", 498 + "account": "${masPublicUrl}account/" 499 + } 500 + }` 501 + } 502 + 503 + # MAS OIDC discovery (Element X checks this) 504 + handle /.well-known/openid-configuration { 505 + reverse_proxy 127.0.0.1:${toString masPort} 506 + } 507 + 508 + # MAS endpoints (under /auth/ prefix) 509 + handle /auth/* { 510 + uri strip_prefix /auth 511 + reverse_proxy 127.0.0.1:${toString masPort} 512 + } 513 + 514 + # MAS OAuth2 endpoints (some clients access these directly) 515 + handle /oauth2/* { 516 + reverse_proxy 127.0.0.1:${toString masPort} 517 + } 518 + 519 + # Matrix API proxy (let Synapse handle /login with MSC3861) 520 + handle /_matrix/* { 521 + reverse_proxy 127.0.0.1:${toString synapsePort} 522 + } 523 + 524 + # Synapse admin API proxy 525 + handle /_synapse/* { 526 + reverse_proxy 127.0.0.1:${toString synapsePort} 527 + } 528 + 529 + # ATLogin authorize - forward to service when login_hint is present 530 + @atlogin_with_hint { 531 + path /atlogin/authorize 532 + query login_hint=* 533 + } 534 + handle @atlogin_with_hint { 535 + uri strip_prefix /atlogin 536 + reverse_proxy 127.0.0.1:${toString atloginPort} 537 + } 538 + 539 + # ATLogin authorize page (static login form) - no login_hint 540 + handle /atlogin/authorize { 541 + root * ${config.vm-test.loginPage} 542 + rewrite * /index.html 543 + file_server 544 + } 545 + 546 + # ATLogin API proxy 547 + handle /atlogin/* { 548 + uri strip_prefix /atlogin 549 + reverse_proxy 127.0.0.1:${toString atloginPort} 550 + } 551 + 552 + # Element Web (default) 553 + handle { 554 + root * /etc/element-web 555 + file_server 556 + } 557 + ''; 558 + in { 559 + enable = true; 560 + # Port 80: External access (Funnel forwards here after TLS termination) 561 + virtualHosts.":80".extraConfig = sharedRoutes; 562 + # Port 8080: Internal access for Synapse to reach issuer URL 563 + virtualHosts.":8080".extraConfig = sharedRoutes; 564 + }; 565 + 566 + # Auto-login for console 567 + services.getty.autologinUser = "root"; 568 + 569 + # Helpful message on login 570 + environment.etc."motd".text = '' 571 + 572 + ===================================================== 573 + Matrix Synapse + MAS + Element Web + ATLogin 574 + ===================================================== 575 + 576 + Architecture: 577 + Element X/Web → MAS (OIDC) → ATLogin → Your PDS 578 + 579 + Services: 580 + - Element Web: ${publicBaseUrl} 581 + - Synapse API: http://localhost:${toString synapsePort} 582 + - MAS: ${masPublicUrl} 583 + - ATLogin: ${effectiveAtloginUrl} 584 + 585 + ${if tailscaleHostname != null then '' 586 + Tailscale HTTPS enabled! 587 + URL: https://${tailscaleHostname} 588 + 589 + From Element X on your phone (on same tailnet): 590 + Homeserver: https://${tailscaleHostname} 591 + 592 + Tailscale commands: 593 + - tailscale status # Check connection 594 + - tailscale cert # Check certificate 595 + '' else '' 596 + From your host machine, open: 597 + http://localhost:8080 598 + ''} 599 + 600 + Login with your Bluesky/ATProto account! 601 + 602 + Useful commands: 603 + - systemctl status matrix-synapse 604 + - systemctl status matrix-authentication-service 605 + - systemctl status atlogin 606 + - systemctl status postgresql 607 + - systemctl status tailscaled 608 + - journalctl -u matrix-synapse -f 609 + - journalctl -u matrix-authentication-service -f 610 + - journalctl -u atlogin -f 611 + 612 + MAS GraphQL Playground (debugging): 613 + ${masPublicUrl}graphql/playground 614 + ===================================================== 615 + 616 + ''; 617 + }; 618 + }
+128
modules/atlogin.nix
··· 1 + { config, lib, pkgs, ... }: 2 + 3 + let 4 + cfg = config.services.atlogin; 5 + 6 + # Build atlogin from source 7 + atloginPkg = pkgs.buildGoModule { 8 + pname = "atlogin"; 9 + version = "unstable"; 10 + src = cfg.src; 11 + vendorHash = "sha256-Nlbex5s9sxXArNyoLa/khJcXN1S7FqFpc50LktYL3O4="; 12 + proxyVendor = true; 13 + subPackages = ["cmd/atlogin"]; 14 + }; 15 + in { 16 + options.services.atlogin = { 17 + enable = lib.mkEnableOption "ATLogin OIDC provider for ATProto/Bluesky authentication"; 18 + 19 + src = lib.mkOption { 20 + type = lib.types.path; 21 + description = "Source path for atlogin"; 22 + }; 23 + 24 + port = lib.mkOption { 25 + type = lib.types.port; 26 + default = 9411; 27 + description = "Port for ATLogin to listen on"; 28 + }; 29 + 30 + issuer = lib.mkOption { 31 + type = lib.types.str; 32 + description = "OIDC issuer URL (must be publicly accessible for ATProto OAuth)"; 33 + example = "https://auth.example.com"; 34 + }; 35 + 36 + clientName = lib.mkOption { 37 + type = lib.types.str; 38 + default = "Matrix Synapse"; 39 + description = "Client name displayed during authentication"; 40 + }; 41 + 42 + clients = lib.mkOption { 43 + type = lib.types.attrsOf (lib.types.submodule { 44 + options = { 45 + secret = lib.mkOption { 46 + type = lib.types.nullOr lib.types.str; 47 + default = null; 48 + description = "Client secret (use clientSecretFiles for file-based secrets)"; 49 + }; 50 + redirectUris = lib.mkOption { 51 + type = lib.types.listOf lib.types.str; 52 + default = []; 53 + description = "Allowed redirect URIs for this client"; 54 + }; 55 + }; 56 + }); 57 + default = {}; 58 + description = "OIDC client configuration (client_id -> {secret, redirectUris})"; 59 + example = { 60 + "matrix-synapse" = { 61 + secret = "secret-key"; 62 + redirectUris = ["http://localhost:8080/callback"]; 63 + }; 64 + }; 65 + }; 66 + 67 + clientSecretFiles = lib.mkOption { 68 + type = lib.types.attrsOf lib.types.path; 69 + default = {}; 70 + description = "OIDC client credentials from files (client_id -> secret_file_path)"; 71 + example = { "matrix-synapse" = "/run/agenix/atlogin-secret"; }; 72 + }; 73 + 74 + stateDir = lib.mkOption { 75 + type = lib.types.str; 76 + default = "/var/lib/atlogin"; 77 + description = "Directory for ATLogin state"; 78 + }; 79 + }; 80 + 81 + config = lib.mkIf cfg.enable { 82 + systemd.services.atlogin = { 83 + description = "ATLogin OIDC Provider"; 84 + wantedBy = ["multi-user.target"]; 85 + after = ["network.target"]; 86 + 87 + serviceConfig = { 88 + ExecStart = "${atloginPkg}/bin/atlogin -state-dir ${cfg.stateDir}"; 89 + StateDirectory = "atlogin"; 90 + DynamicUser = true; 91 + Restart = "always"; 92 + }; 93 + 94 + preStart = let 95 + # Build secrets JSON from inline clients (only non-null secrets) 96 + inlineSecrets = builtins.toJSON (lib.filterAttrs (id: secret: secret != null) 97 + (lib.mapAttrs (id: client: client.secret) cfg.clients)); 98 + # Build clients JSON with full config including redirect_uris (only for clients with redirectUris) 99 + clientsConfig = builtins.toJSON (lib.mapAttrs (id: client: { 100 + redirect_uris = client.redirectUris; 101 + } // lib.optionalAttrs (client.secret != null) { 102 + secret = client.secret; 103 + }) cfg.clients); 104 + # Script to read file-based secrets and merge with inline 105 + secretFileReads = lib.concatStringsSep "\n" (lib.mapAttrsToList 106 + (clientId: secretFile: '' 107 + FILE_SECRETS=$(echo "$FILE_SECRETS" | ${pkgs.jq}/bin/jq --arg id "${clientId}" --arg secret "$(cat ${secretFile})" '. + {($id): $secret}') 108 + '') 109 + cfg.clientSecretFiles); 110 + in '' 111 + FILE_SECRETS='${inlineSecrets}' 112 + ${secretFileReads} 113 + cat > ${cfg.stateDir}/config.json << EOF 114 + { 115 + "addr": ":${toString cfg.port}", 116 + "issuer": "${cfg.issuer}", 117 + "client_name": "${cfg.clientName}", 118 + "secrets": $FILE_SECRETS, 119 + "clients": ${clientsConfig} 120 + } 121 + EOF 122 + ''; 123 + }; 124 + 125 + # Export package for other modules to use 126 + environment.systemPackages = [ atloginPkg ]; 127 + }; 128 + }
+681
modules/matrix-auth-service.nix
··· 1 + { config, lib, pkgs, ... }: 2 + 3 + # Matrix Authentication Service (MAS) module 4 + # 5 + # MAS is an OIDC provider for Matrix that can federate to upstream IdPs. 6 + # This enables Element X compatibility (which requires MSC3861). 7 + # 8 + # Architecture: 9 + # Element X → MAS (OIDC Provider) → Upstream IdP (e.g., ATLogin) → User Auth 10 + # ↓ 11 + # Synapse (token introspection) 12 + 13 + let 14 + cfg = config.services.matrix-auth-service; 15 + 16 + # YAML format for config generation 17 + yamlFormat = pkgs.formats.yaml { }; 18 + 19 + # Convert Nix attrs to YAML-compatible structure for a provider 20 + # (without client_secret, which is injected at runtime) 21 + providerToConfig = provider: { 22 + id = provider.id; 23 + issuer = provider.issuer; 24 + human_name = provider.human_name; 25 + client_id = provider.client_id; 26 + token_endpoint_auth_method = provider.token_endpoint_auth_method; 27 + scope = provider.scope; 28 + discovery_mode = provider.discovery_mode; 29 + pkce_method = provider.pkce_method; 30 + claims_imports = provider.claims_imports; 31 + fetch_userinfo = provider.fetch_userinfo; 32 + forward_login_hint = provider.forward_login_hint; 33 + } // lib.optionalAttrs (provider.brand_name != null) { 34 + brand_name = provider.brand_name; 35 + } // lib.optionalAttrs (provider.authorization_endpoint != null) { 36 + authorization_endpoint = provider.authorization_endpoint; 37 + } // lib.optionalAttrs (provider.token_endpoint != null) { 38 + token_endpoint = provider.token_endpoint; 39 + } // lib.optionalAttrs (provider.jwks_uri != null) { 40 + jwks_uri = provider.jwks_uri; 41 + } // lib.optionalAttrs (provider.userinfo_endpoint != null) { 42 + userinfo_endpoint = provider.userinfo_endpoint; 43 + }; 44 + 45 + # Base configuration without secrets (used as template) 46 + baseConfig = { 47 + http = { 48 + listeners = [ 49 + { 50 + name = "web"; 51 + resources = [ 52 + { name = "discovery"; } 53 + { name = "human"; } 54 + { name = "oauth"; } 55 + { name = "compat"; } 56 + { name = "graphql"; playground = cfg.enableGraphqlPlayground; } 57 + { name = "assets"; } 58 + ]; 59 + binds = [ 60 + { address = "[::]:${toString cfg.port}"; } 61 + ]; 62 + proxy_protocol = false; 63 + } 64 + { 65 + name = "internal"; 66 + resources = [ 67 + { name = "health"; } 68 + ]; 69 + binds = [ 70 + { address = "[::]:${toString cfg.internalPort}"; } 71 + ]; 72 + proxy_protocol = false; 73 + } 74 + ]; 75 + trusted_proxies = cfg.trustedProxies; 76 + public_base = cfg.publicUrl; 77 + } // lib.optionalAttrs (cfg.issuer != null) { 78 + issuer = cfg.issuer; 79 + }; 80 + 81 + database = { 82 + uri = cfg.database.uri; 83 + max_connections = cfg.database.maxConnections; 84 + min_connections = cfg.database.minConnections; 85 + connect_timeout = cfg.database.connectTimeout; 86 + idle_timeout = cfg.database.idleTimeout; 87 + max_lifetime = cfg.database.maxLifetime; 88 + }; 89 + 90 + passwords = { 91 + enabled = cfg.passwords.enabled; 92 + } // lib.optionalAttrs (cfg.passwords.enabled) { 93 + schemes = [ 94 + { version = 1; algorithm = "argon2id"; } 95 + ]; 96 + minimum_complexity = cfg.passwords.minimumComplexity; 97 + }; 98 + 99 + matrix = { 100 + homeserver = cfg.matrix.serverName; 101 + endpoint = cfg.matrix.synapseUrl; 102 + # secret is injected at runtime 103 + }; 104 + 105 + policy = { 106 + data = { 107 + admin_users = cfg.policy.adminUsers; 108 + allow_password_registration = cfg.policy.allowPasswordRegistration; 109 + allow_public_room_directory = cfg.policy.allowPublicRoomDirectory; 110 + # Allow HTTP/localhost for development 111 + client_registration = { 112 + allow_insecure_uris = true; 113 + allow_localhost = true; 114 + }; 115 + }; 116 + }; 117 + 118 + upstream_oauth2 = { 119 + # providers with secrets are injected at runtime 120 + providers = map providerToConfig cfg.upstreamProviders; 121 + }; 122 + 123 + # OAuth2 clients - Synapse client added at runtime with secret injection 124 + # Additional clients can be configured via oauth2Clients option 125 + clients = [ 126 + # Synapse compatibility client (secret injected at runtime) 127 + { 128 + client_id = "0000000000000000000SYNAPSE"; 129 + client_auth_method = "client_secret_basic"; 130 + # client_secret injected at runtime from matrix.secretFile 131 + } 132 + ]; 133 + 134 + branding = { 135 + service_name = cfg.branding.serviceName; 136 + } // lib.optionalAttrs (cfg.branding.policyUri != null) { 137 + policy_uri = cfg.branding.policyUri; 138 + } // lib.optionalAttrs (cfg.branding.tosUri != null) { 139 + tos_uri = cfg.branding.tosUri; 140 + }; 141 + 142 + account = { 143 + email_change_allowed = cfg.account.emailChangeAllowed; 144 + displayname_change_allowed = cfg.account.displaynameChangeAllowed; 145 + password_change_allowed = cfg.account.passwordChangeAllowed; 146 + password_recovery_allowed = cfg.account.passwordRecoveryAllowed; 147 + }; 148 + } // lib.optionalAttrs cfg.email.enable { 149 + email = { 150 + from = cfg.email.from; 151 + reply_to = cfg.email.replyTo; 152 + transport = cfg.email.transport; 153 + }; 154 + }; 155 + 156 + baseConfigFile = yamlFormat.generate "mas-config-base.yaml" baseConfig; 157 + 158 + in { 159 + options.services.matrix-auth-service = { 160 + enable = lib.mkEnableOption "Matrix Authentication Service (MAS)"; 161 + 162 + package = lib.mkOption { 163 + type = lib.types.package; 164 + default = pkgs.matrix-authentication-service; 165 + description = "The MAS package to use"; 166 + }; 167 + 168 + port = lib.mkOption { 169 + type = lib.types.port; 170 + default = 8081; 171 + description = "Port for MAS web interface"; 172 + }; 173 + 174 + internalPort = lib.mkOption { 175 + type = lib.types.port; 176 + default = 8082; 177 + description = "Port for MAS internal/health endpoints"; 178 + }; 179 + 180 + publicUrl = lib.mkOption { 181 + type = lib.types.str; 182 + description = "Public URL where MAS is accessible (with trailing slash)"; 183 + example = "https://auth.example.com/"; 184 + }; 185 + 186 + issuer = lib.mkOption { 187 + type = lib.types.nullOr lib.types.str; 188 + default = null; 189 + description = "OIDC issuer URL. Defaults to publicUrl if not set."; 190 + }; 191 + 192 + trustedProxies = lib.mkOption { 193 + type = lib.types.listOf lib.types.str; 194 + default = [ "192.168.0.0/16" "172.16.0.0/12" "10.0.0.0/8" "127.0.0.1/8" "fd00::/8" "::1/128" ]; 195 + description = "List of trusted proxy CIDRs"; 196 + }; 197 + 198 + enableGraphqlPlayground = lib.mkOption { 199 + type = lib.types.bool; 200 + default = false; 201 + description = "Enable GraphQL playground (for debugging)"; 202 + }; 203 + 204 + # Database configuration 205 + database = { 206 + uri = lib.mkOption { 207 + type = lib.types.str; 208 + default = "postgresql://mas@localhost/mas"; 209 + description = "Database connection URI (MAS requires PostgreSQL). Ignored if uriFile is set."; 210 + example = "postgresql://user:pass@localhost/mas"; 211 + }; 212 + 213 + uriFile = lib.mkOption { 214 + type = lib.types.nullOr lib.types.path; 215 + default = null; 216 + description = "File containing database connection URI (for secrets management)"; 217 + example = "/run/agenix/mas-db-uri"; 218 + }; 219 + 220 + maxConnections = lib.mkOption { 221 + type = lib.types.int; 222 + default = 10; 223 + description = "Maximum database connections"; 224 + }; 225 + 226 + minConnections = lib.mkOption { 227 + type = lib.types.int; 228 + default = 0; 229 + description = "Minimum database connections"; 230 + }; 231 + 232 + connectTimeout = lib.mkOption { 233 + type = lib.types.int; 234 + default = 30; 235 + description = "Connection timeout in seconds"; 236 + }; 237 + 238 + idleTimeout = lib.mkOption { 239 + type = lib.types.int; 240 + default = 600; 241 + description = "Idle timeout in seconds"; 242 + }; 243 + 244 + maxLifetime = lib.mkOption { 245 + type = lib.types.int; 246 + default = 1800; 247 + description = "Maximum connection lifetime in seconds"; 248 + }; 249 + }; 250 + 251 + # Matrix/Synapse integration 252 + matrix = { 253 + serverName = lib.mkOption { 254 + type = lib.types.str; 255 + description = "Matrix server name (domain)"; 256 + example = "example.com"; 257 + }; 258 + 259 + synapseUrl = lib.mkOption { 260 + type = lib.types.str; 261 + default = "http://127.0.0.1:8008"; 262 + description = "Internal URL to reach Synapse admin API"; 263 + }; 264 + 265 + secretFile = lib.mkOption { 266 + type = lib.types.path; 267 + description = "Path to file containing shared secret with Synapse"; 268 + }; 269 + }; 270 + 271 + # Secrets 272 + secrets = { 273 + encryptionFile = lib.mkOption { 274 + type = lib.types.path; 275 + description = "Path to file containing encryption secret (32+ bytes, hex encoded). Generate with: openssl rand -hex 32"; 276 + }; 277 + 278 + signingKeyFile = lib.mkOption { 279 + type = lib.types.nullOr lib.types.path; 280 + default = null; 281 + description = "Path to file containing RSA/EC signing key in PEM format. If null, keys will be auto-generated."; 282 + }; 283 + }; 284 + 285 + # Password authentication 286 + passwords = { 287 + enabled = lib.mkOption { 288 + type = lib.types.bool; 289 + default = false; 290 + description = "Enable local password authentication"; 291 + }; 292 + 293 + minimumComplexity = lib.mkOption { 294 + type = lib.types.int; 295 + default = 3; 296 + description = "Minimum password complexity (0-4)"; 297 + }; 298 + }; 299 + 300 + # Upstream OAuth2/OIDC providers 301 + upstreamProviders = lib.mkOption { 302 + type = lib.types.listOf (lib.types.submodule { 303 + options = { 304 + id = lib.mkOption { 305 + type = lib.types.str; 306 + description = "Unique provider ID (ULID format). Generate at: https://www.ulidtools.com/generate"; 307 + example = "01JAYS74TCG3BTWKADN5Q4518C"; 308 + }; 309 + 310 + issuer = lib.mkOption { 311 + type = lib.types.str; 312 + description = "OIDC issuer URL of the upstream provider"; 313 + example = "https://atlogin.example.com"; 314 + }; 315 + 316 + human_name = lib.mkOption { 317 + type = lib.types.str; 318 + default = "Upstream Provider"; 319 + description = "Human-readable provider name shown in UI"; 320 + }; 321 + 322 + brand_name = lib.mkOption { 323 + type = lib.types.nullOr lib.types.str; 324 + default = null; 325 + description = "Brand identifier for styling (e.g., 'google', 'github')"; 326 + }; 327 + 328 + client_id = lib.mkOption { 329 + type = lib.types.str; 330 + description = "OAuth2 client ID registered with the upstream provider"; 331 + }; 332 + 333 + client_secret_file = lib.mkOption { 334 + type = lib.types.path; 335 + description = "Path to file containing OAuth2 client secret"; 336 + }; 337 + 338 + token_endpoint_auth_method = lib.mkOption { 339 + type = lib.types.enum [ "client_secret_basic" "client_secret_post" "client_secret_jwt" "private_key_jwt" "none" ]; 340 + default = "client_secret_basic"; 341 + description = "Token endpoint authentication method"; 342 + }; 343 + 344 + scope = lib.mkOption { 345 + type = lib.types.str; 346 + default = "openid profile email"; 347 + description = "OAuth2 scopes to request"; 348 + }; 349 + 350 + discovery_mode = lib.mkOption { 351 + type = lib.types.enum [ "oidc" "insecure" "disabled" ]; 352 + default = "oidc"; 353 + description = "OIDC discovery mode. Use 'insecure' for non-compliant IdPs, 'disabled' for manual endpoint config."; 354 + }; 355 + 356 + pkce_method = lib.mkOption { 357 + type = lib.types.enum [ "auto" "always" "never" ]; 358 + default = "auto"; 359 + description = "PKCE (Proof Key for Code Exchange) method"; 360 + }; 361 + 362 + # Manual endpoint configuration (when discovery_mode = "disabled") 363 + authorization_endpoint = lib.mkOption { 364 + type = lib.types.nullOr lib.types.str; 365 + default = null; 366 + description = "Authorization endpoint URL (required if discovery_mode = disabled)"; 367 + }; 368 + 369 + token_endpoint = lib.mkOption { 370 + type = lib.types.nullOr lib.types.str; 371 + default = null; 372 + description = "Token endpoint URL (required if discovery_mode = disabled)"; 373 + }; 374 + 375 + jwks_uri = lib.mkOption { 376 + type = lib.types.nullOr lib.types.str; 377 + default = null; 378 + description = "JWKS URI for ID token validation (required if discovery_mode = disabled)"; 379 + }; 380 + 381 + userinfo_endpoint = lib.mkOption { 382 + type = lib.types.nullOr lib.types.str; 383 + default = null; 384 + description = "Userinfo endpoint URL (optional)"; 385 + }; 386 + 387 + # Claims mapping using Jinja2 templates 388 + claims_imports = lib.mkOption { 389 + type = lib.types.attrs; 390 + default = { 391 + subject = { template = "{{ user.sub }}"; }; 392 + localpart = { 393 + action = "require"; 394 + template = "{{ user.sub | lower | replace('.', '_') | replace(':', '_') }}"; 395 + }; 396 + displayname = { 397 + action = "suggest"; 398 + template = "{{ user.name or user.handle or user.sub }}"; 399 + }; 400 + }; 401 + description = "Jinja2 templates for mapping upstream claims to Matrix user attributes"; 402 + }; 403 + 404 + fetch_userinfo = lib.mkOption { 405 + type = lib.types.bool; 406 + default = true; 407 + description = "Fetch additional claims from userinfo endpoint"; 408 + }; 409 + 410 + forward_login_hint = lib.mkOption { 411 + type = lib.types.bool; 412 + default = false; 413 + description = "Forward login_hint parameter to upstream provider"; 414 + }; 415 + }; 416 + }); 417 + default = []; 418 + description = "List of upstream OAuth2/OIDC providers (e.g., ATLogin)"; 419 + }; 420 + 421 + # Policy 422 + policy = { 423 + adminUsers = lib.mkOption { 424 + type = lib.types.listOf lib.types.str; 425 + default = []; 426 + description = "List of admin user Matrix IDs"; 427 + example = [ "@admin:example.com" ]; 428 + }; 429 + 430 + allowPasswordRegistration = lib.mkOption { 431 + type = lib.types.bool; 432 + default = false; 433 + description = "Allow password-based registration (if passwords.enabled)"; 434 + }; 435 + 436 + allowPublicRoomDirectory = lib.mkOption { 437 + type = lib.types.bool; 438 + default = true; 439 + description = "Allow public room directory access"; 440 + }; 441 + }; 442 + 443 + # OAuth2 clients (static registration for Element Web, etc.) 444 + oauth2Clients = lib.mkOption { 445 + type = lib.types.listOf (lib.types.submodule { 446 + options = { 447 + client_id = lib.mkOption { 448 + type = lib.types.str; 449 + description = "OAuth2 client ID"; 450 + }; 451 + client_auth_method = lib.mkOption { 452 + type = lib.types.enum [ "none" "client_secret_basic" "client_secret_post" "client_secret_jwt" "private_key_jwt" ]; 453 + default = "none"; 454 + description = "Client authentication method"; 455 + }; 456 + client_secret = lib.mkOption { 457 + type = lib.types.nullOr lib.types.str; 458 + default = null; 459 + description = "Client secret (if using client_secret_* auth method)"; 460 + }; 461 + redirect_uris = lib.mkOption { 462 + type = lib.types.listOf lib.types.str; 463 + description = "Allowed redirect URIs"; 464 + }; 465 + response_types = lib.mkOption { 466 + type = lib.types.listOf lib.types.str; 467 + default = [ "code" ]; 468 + description = "Allowed response types"; 469 + }; 470 + grant_types = lib.mkOption { 471 + type = lib.types.listOf lib.types.str; 472 + default = [ "authorization_code" "refresh_token" ]; 473 + description = "Allowed grant types"; 474 + }; 475 + token_endpoint_auth_method = lib.mkOption { 476 + type = lib.types.enum [ "none" "client_secret_basic" "client_secret_post" ]; 477 + default = "none"; 478 + description = "Token endpoint auth method"; 479 + }; 480 + }; 481 + }); 482 + default = []; 483 + description = "Static OAuth2 client registrations"; 484 + }; 485 + 486 + # Branding 487 + branding = { 488 + serviceName = lib.mkOption { 489 + type = lib.types.str; 490 + default = "Matrix Authentication Service"; 491 + description = "Service name displayed to users"; 492 + }; 493 + 494 + policyUri = lib.mkOption { 495 + type = lib.types.nullOr lib.types.str; 496 + default = null; 497 + description = "Privacy policy URL"; 498 + }; 499 + 500 + tosUri = lib.mkOption { 501 + type = lib.types.nullOr lib.types.str; 502 + default = null; 503 + description = "Terms of service URL"; 504 + }; 505 + }; 506 + 507 + # Email 508 + email = { 509 + enable = lib.mkOption { 510 + type = lib.types.bool; 511 + default = false; 512 + description = "Enable email sending"; 513 + }; 514 + 515 + from = lib.mkOption { 516 + type = lib.types.str; 517 + default = "noreply@example.com"; 518 + description = "From address for emails"; 519 + }; 520 + 521 + replyTo = lib.mkOption { 522 + type = lib.types.str; 523 + default = "support@example.com"; 524 + description = "Reply-to address"; 525 + }; 526 + 527 + transport = lib.mkOption { 528 + type = lib.types.str; 529 + default = "smtp"; 530 + description = "Email transport type"; 531 + }; 532 + }; 533 + 534 + # Account settings 535 + account = { 536 + emailChangeAllowed = lib.mkOption { 537 + type = lib.types.bool; 538 + default = true; 539 + description = "Allow users to change email"; 540 + }; 541 + 542 + displaynameChangeAllowed = lib.mkOption { 543 + type = lib.types.bool; 544 + default = true; 545 + description = "Allow users to change display name"; 546 + }; 547 + 548 + passwordChangeAllowed = lib.mkOption { 549 + type = lib.types.bool; 550 + default = true; 551 + description = "Allow users to change password"; 552 + }; 553 + 554 + passwordRecoveryAllowed = lib.mkOption { 555 + type = lib.types.bool; 556 + default = false; 557 + description = "Allow password recovery via email"; 558 + }; 559 + }; 560 + 561 + stateDir = lib.mkOption { 562 + type = lib.types.str; 563 + default = "/var/lib/matrix-authentication-service"; 564 + description = "State directory for MAS"; 565 + }; 566 + }; 567 + 568 + config = lib.mkIf cfg.enable { 569 + # Create state directory 570 + systemd.tmpfiles.rules = [ 571 + "d ${cfg.stateDir} 0750 matrix-authentication-service matrix-authentication-service -" 572 + ]; 573 + 574 + # User and group 575 + users.users.matrix-authentication-service = { 576 + isSystemUser = true; 577 + group = "matrix-authentication-service"; 578 + home = cfg.stateDir; 579 + }; 580 + users.groups.matrix-authentication-service = {}; 581 + 582 + systemd.services.matrix-authentication-service = { 583 + description = "Matrix Authentication Service"; 584 + wantedBy = [ "multi-user.target" ]; 585 + after = [ "network.target" "postgresql.service" ]; 586 + wants = [ "network.target" ]; 587 + requires = [ "postgresql.service" ]; 588 + 589 + serviceConfig = { 590 + Type = "simple"; 591 + User = "matrix-authentication-service"; 592 + Group = "matrix-authentication-service"; 593 + WorkingDirectory = cfg.stateDir; 594 + StateDirectory = "matrix-authentication-service"; 595 + StateDirectoryMode = "0750"; 596 + Restart = "on-failure"; 597 + RestartSec = "5s"; 598 + 599 + # Security hardening 600 + NoNewPrivileges = true; 601 + ProtectSystem = "strict"; 602 + ProtectHome = true; 603 + PrivateTmp = true; 604 + ReadWritePaths = [ cfg.stateDir ]; 605 + 606 + # Run preStart as root (+ prefix) to handle key generation and config setup 607 + ExecStartPre = let 608 + yq = "${pkgs.yq-go}/bin/yq"; 609 + openssl = "${pkgs.openssl}/bin/openssl"; 610 + setupScript = pkgs.writeShellScript "mas-setup" '' 611 + set -e 612 + 613 + # Generate signing keys if not provided and don't exist 614 + ${lib.optionalString (cfg.secrets.signingKeyFile == null) '' 615 + if [ ! -f ${cfg.stateDir}/signing-keys.yaml ]; then 616 + echo "Generating signing keys..." >&2 617 + # Generate RSA key pair for signing 618 + ${openssl} genrsa 2048 2>/dev/null > ${cfg.stateDir}/signing-key.pem 619 + PRIVATE_KEY=$(cat ${cfg.stateDir}/signing-key.pem) 620 + # Create keys array in YAML format 621 + cat > ${cfg.stateDir}/signing-keys.yaml << 'SIGNINGEOF' 622 + - kid: mas-signing-key-1 623 + key: | 624 + SIGNINGEOF 625 + echo "$PRIVATE_KEY" | sed 's/^/ /' >> ${cfg.stateDir}/signing-keys.yaml 626 + chown matrix-authentication-service:matrix-authentication-service ${cfg.stateDir}/signing-key.pem ${cfg.stateDir}/signing-keys.yaml 627 + chmod 600 ${cfg.stateDir}/signing-key.pem ${cfg.stateDir}/signing-keys.yaml 628 + fi 629 + ''} 630 + 631 + # Prepare config (start with base YAML config) 632 + cp ${baseConfigFile} ${cfg.stateDir}/config.yaml 633 + chown matrix-authentication-service:matrix-authentication-service ${cfg.stateDir}/config.yaml 634 + chmod 600 ${cfg.stateDir}/config.yaml 635 + 636 + # Inject matrix secret 637 + MATRIX_SECRET=$(cat ${cfg.matrix.secretFile}) 638 + ${yq} -i ".matrix.secret = \"$MATRIX_SECRET\"" ${cfg.stateDir}/config.yaml 639 + 640 + # Inject Synapse client secret (same as matrix secret) 641 + ${yq} -i "(.clients[] | select(.client_id == \"0000000000000000000SYNAPSE\")).client_secret = \"$MATRIX_SECRET\"" ${cfg.stateDir}/config.yaml 642 + 643 + # Inject database URI from file if specified 644 + ${lib.optionalString (cfg.database.uriFile != null) '' 645 + DB_URI=$(cat ${cfg.database.uriFile}) 646 + ${yq} -i ".database.uri = \"$DB_URI\"" ${cfg.stateDir}/config.yaml 647 + ''} 648 + 649 + # Inject encryption secret 650 + ENCRYPTION_SECRET=$(cat ${cfg.secrets.encryptionFile}) 651 + ${yq} -i ".secrets.encryption = \"$ENCRYPTION_SECRET\"" ${cfg.stateDir}/config.yaml 652 + 653 + ${lib.optionalString (cfg.secrets.signingKeyFile == null) '' 654 + # Load signing keys from YAML file 655 + if [ -f ${cfg.stateDir}/signing-keys.yaml ]; then 656 + ${yq} -i ".secrets.keys = load(\"${cfg.stateDir}/signing-keys.yaml\")" ${cfg.stateDir}/config.yaml 657 + fi 658 + ''} 659 + 660 + # Inject upstream provider secrets 661 + ${lib.concatMapStringsSep "\n" (provider: '' 662 + PROVIDER_SECRET=$(cat ${provider.client_secret_file}) 663 + ${yq} -i "(.upstream_oauth2.providers[] | select(.id == \"${provider.id}\")).client_secret = \"$PROVIDER_SECRET\"" ${cfg.stateDir}/config.yaml 664 + '') cfg.upstreamProviders} 665 + 666 + # Run database migrations 667 + ${cfg.package}/bin/mas-cli database migrate --config ${cfg.stateDir}/config.yaml 2>&1 || echo "Migration skipped or failed" >&2 668 + 669 + # Sync configuration to database 670 + ${cfg.package}/bin/mas-cli config sync --config ${cfg.stateDir}/config.yaml --prune 2>&1 || echo "Config sync skipped or failed" >&2 671 + ''; 672 + in "+${setupScript}"; # + prefix runs as root 673 + }; 674 + 675 + # Config is prepared by ExecStartPre, just run MAS 676 + script = '' 677 + exec ${cfg.package}/bin/mas-cli server --config ${cfg.stateDir}/config.yaml 678 + ''; 679 + }; 680 + }; 681 + }
+134
modules/synapse-mas.nix
··· 1 + { config, lib, pkgs, ... }: 2 + 3 + # This module configures Synapse to delegate authentication to Matrix Authentication Service (MAS) 4 + # instead of using direct OIDC configuration. This is required for Element X compatibility. 5 + # 6 + # When using MAS, Synapse no longer manages authentication directly. Instead: 7 + # - MAS handles OIDC/OAuth2 flows with upstream providers (like ATLogin) 8 + # - Synapse validates tokens by calling MAS's introspection endpoint 9 + # - MAS provisions users via Synapse's admin API 10 + 11 + let 12 + cfg = config.services.synapse-mas; 13 + in { 14 + options.services.synapse-mas = { 15 + enable = lib.mkEnableOption "Synapse MAS (Matrix Authentication Service) integration"; 16 + 17 + issuer = lib.mkOption { 18 + type = lib.types.str; 19 + description = "Public OIDC issuer URL (must match MAS publicUrl). This is what's in the tokens."; 20 + example = "http://localhost:8080/auth/"; 21 + }; 22 + 23 + masInternalUrl = lib.mkOption { 24 + type = lib.types.str; 25 + default = "http://127.0.0.1:8081/"; 26 + description = "Internal URL where Synapse can reach MAS for introspection"; 27 + example = "http://127.0.0.1:8081/"; 28 + }; 29 + 30 + sharedSecret = lib.mkOption { 31 + type = lib.types.nullOr lib.types.str; 32 + default = null; 33 + description = "Shared secret between Synapse and MAS (use sharedSecretFile for production)"; 34 + }; 35 + 36 + sharedSecretFile = lib.mkOption { 37 + type = lib.types.nullOr lib.types.path; 38 + default = null; 39 + description = "Path to file containing the shared secret"; 40 + example = "/run/agenix/mas-synapse-secret"; 41 + }; 42 + 43 + configPath = lib.mkOption { 44 + type = lib.types.str; 45 + default = "/var/lib/matrix-synapse/mas-config.yaml"; 46 + description = "Path to write the MAS config file"; 47 + }; 48 + 49 + # Additional compatibility options 50 + adminApiSharedSecret = lib.mkOption { 51 + type = lib.types.nullOr lib.types.str; 52 + default = null; 53 + description = "Admin API shared secret for MAS to provision users (use adminApiSharedSecretFile for production)"; 54 + }; 55 + 56 + adminApiSharedSecretFile = lib.mkOption { 57 + type = lib.types.nullOr lib.types.path; 58 + default = null; 59 + description = "Path to file containing admin API shared secret"; 60 + }; 61 + }; 62 + 63 + config = lib.mkIf cfg.enable { 64 + # Ensure either inline secret or file is provided 65 + assertions = [ 66 + { 67 + assertion = cfg.sharedSecret != null || cfg.sharedSecretFile != null; 68 + message = "Either synapse-mas.sharedSecret or synapse-mas.sharedSecretFile must be set"; 69 + } 70 + ]; 71 + 72 + systemd.services.synapse-mas-config = { 73 + description = "Generate Synapse MAS configuration"; 74 + wantedBy = [ "matrix-synapse.service" ]; 75 + before = [ "matrix-synapse.service" ]; 76 + serviceConfig = { 77 + Type = "oneshot"; 78 + RemainAfterExit = true; 79 + }; 80 + script = let 81 + secretSource = if cfg.sharedSecretFile != null 82 + then "$(cat ${cfg.sharedSecretFile})" 83 + else cfg.sharedSecret; 84 + in '' 85 + mkdir -p $(dirname ${cfg.configPath}) 86 + SHARED_SECRET="${secretSource}" 87 + cat > ${cfg.configPath} << 'EOF' 88 + # Matrix Authentication Service configuration 89 + # This replaces OIDC configuration when using MAS 90 + # 91 + # See: https://element-hq.github.io/matrix-authentication-service/setup/homeserver.html 92 + 93 + # Enable experimental MAS support (MSC3861) 94 + experimental_features: 95 + msc3861: 96 + enabled: true 97 + # Public issuer URL (must match what MAS advertises in tokens) 98 + issuer: PUBLIC_ISSUER_PLACEHOLDER 99 + # Internal endpoint for token introspection (Synapse -> MAS) 100 + introspection_endpoint: INTERNAL_URL_PLACEHOLDERoauth2/introspect 101 + client_id: "0000000000000000000SYNAPSE" 102 + client_auth_method: client_secret_basic 103 + client_secret: SECRET_PLACEHOLDER 104 + admin_token: SECRET_PLACEHOLDER 105 + account_management_url: "PUBLIC_ISSUER_PLACEHOLDERaccount/" 106 + 107 + # Disable legacy auth endpoints since MAS handles everything 108 + password_config: 109 + enabled: false 110 + localdb_enabled: false 111 + 112 + # Disable direct OIDC - MAS handles upstream providers 113 + oidc_providers: [] 114 + 115 + # Disable registration - MAS handles user provisioning 116 + enable_registration: false 117 + enable_registration_without_verification: false 118 + EOF 119 + # Replace placeholders with actual values 120 + ${pkgs.gnused}/bin/sed -i "s|PUBLIC_ISSUER_PLACEHOLDER|${cfg.issuer}|g" ${cfg.configPath} 121 + ${pkgs.gnused}/bin/sed -i "s|INTERNAL_URL_PLACEHOLDER|${cfg.masInternalUrl}|g" ${cfg.configPath} 122 + ${pkgs.gnused}/bin/sed -i "s|SECRET_PLACEHOLDER|$SHARED_SECRET|g" ${cfg.configPath} 123 + chown matrix-synapse:matrix-synapse ${cfg.configPath} 124 + chmod 600 ${cfg.configPath} 125 + ''; 126 + }; 127 + 128 + # Note: The Synapse configuration should include this file in extraConfigFiles 129 + # Example: 130 + # services.matrix-synapse.extraConfigFiles = [ 131 + # "/var/lib/matrix-synapse/mas-config.yaml" 132 + # ]; 133 + }; 134 + }
+147
modules/synapse-oidc.nix
··· 1 + { config, lib, pkgs, ... }: 2 + 3 + let 4 + cfg = config.services.synapse-oidc; 5 + in { 6 + options.services.synapse-oidc = { 7 + enable = lib.mkEnableOption "Synapse OIDC configuration for ATLogin"; 8 + 9 + issuer = lib.mkOption { 10 + type = lib.types.str; 11 + description = "OIDC issuer URL"; 12 + example = "https://auth.example.com"; 13 + }; 14 + 15 + clientId = lib.mkOption { 16 + type = lib.types.str; 17 + description = "OIDC client ID"; 18 + example = "matrix-synapse"; 19 + }; 20 + 21 + clientSecret = lib.mkOption { 22 + type = lib.types.nullOr lib.types.str; 23 + default = null; 24 + description = "OIDC client secret (use clientSecretFile for production)"; 25 + }; 26 + 27 + clientSecretFile = lib.mkOption { 28 + type = lib.types.nullOr lib.types.path; 29 + default = null; 30 + description = "Path to file containing OIDC client secret"; 31 + example = "/run/agenix/atlogin-secret"; 32 + }; 33 + 34 + authorizationEndpoint = lib.mkOption { 35 + type = lib.types.nullOr lib.types.str; 36 + default = null; 37 + description = "Custom authorization endpoint URL. If null, uses issuer + /authorize"; 38 + example = "http://localhost:8080/atlogin/authorize"; 39 + }; 40 + 41 + tokenEndpoint = lib.mkOption { 42 + type = lib.types.nullOr lib.types.str; 43 + default = null; 44 + description = "Custom token endpoint URL. If null, uses issuer + /token"; 45 + }; 46 + 47 + userinfoEndpoint = lib.mkOption { 48 + type = lib.types.nullOr lib.types.str; 49 + default = null; 50 + description = "Custom userinfo endpoint URL. If null, uses issuer + /userinfo"; 51 + }; 52 + 53 + jwksUri = lib.mkOption { 54 + type = lib.types.nullOr lib.types.str; 55 + default = null; 56 + description = "Custom JWKS URI. If null, uses issuer + /.well-known/jwks.json"; 57 + }; 58 + 59 + idpId = lib.mkOption { 60 + type = lib.types.str; 61 + default = "atlogin"; 62 + description = "Identity provider ID"; 63 + }; 64 + 65 + idpName = lib.mkOption { 66 + type = lib.types.str; 67 + default = "Sign in with Bluesky"; 68 + description = "Identity provider display name"; 69 + }; 70 + 71 + subjectClaim = lib.mkOption { 72 + type = lib.types.str; 73 + default = "sub"; 74 + description = "Claim to use as the subject"; 75 + }; 76 + 77 + localpartTemplate = lib.mkOption { 78 + type = lib.types.str; 79 + default = "{{ user.sub | lower | replace('.', '_') | replace(':', '_') }}"; 80 + description = "Jinja2 template for Matrix localpart"; 81 + }; 82 + 83 + displayNameTemplate = lib.mkOption { 84 + type = lib.types.str; 85 + default = "{{ user.name or user.handle or user.sub }}"; 86 + description = "Jinja2 template for display name"; 87 + }; 88 + 89 + configPath = lib.mkOption { 90 + type = lib.types.str; 91 + default = "/var/lib/matrix-synapse/oidc-config.yaml"; 92 + description = "Path to write the OIDC config file"; 93 + }; 94 + }; 95 + 96 + config = lib.mkIf cfg.enable { 97 + systemd.services.synapse-oidc-config = { 98 + description = "Generate Synapse OIDC configuration"; 99 + wantedBy = ["matrix-synapse.service"]; 100 + before = ["matrix-synapse.service"]; 101 + serviceConfig = { 102 + Type = "oneshot"; 103 + RemainAfterExit = true; 104 + }; 105 + script = let 106 + authEndpoint = if cfg.authorizationEndpoint != null 107 + then cfg.authorizationEndpoint 108 + else "${cfg.issuer}/authorize"; 109 + tokenEndpoint = if cfg.tokenEndpoint != null 110 + then cfg.tokenEndpoint 111 + else "${cfg.issuer}/token"; 112 + userinfoEndpoint = if cfg.userinfoEndpoint != null 113 + then cfg.userinfoEndpoint 114 + else "${cfg.issuer}/userinfo"; 115 + jwksUri = if cfg.jwksUri != null 116 + then cfg.jwksUri 117 + else "${cfg.issuer}/.well-known/jwks.json"; 118 + secretSource = if cfg.clientSecretFile != null 119 + then "$(cat ${cfg.clientSecretFile})" 120 + else cfg.clientSecret; 121 + in '' 122 + mkdir -p $(dirname ${cfg.configPath}) 123 + CLIENT_SECRET="${secretSource}" 124 + cat > ${cfg.configPath} << EOF 125 + oidc_providers: 126 + - idp_id: ${cfg.idpId} 127 + idp_name: "${cfg.idpName}" 128 + discover: false 129 + issuer: "${cfg.issuer}" 130 + client_id: "${cfg.clientId}" 131 + client_secret: "$CLIENT_SECRET" 132 + scopes: ["openid", "profile", "email"] 133 + authorization_endpoint: "${authEndpoint}" 134 + token_endpoint: "${tokenEndpoint}" 135 + userinfo_endpoint: "${userinfoEndpoint}" 136 + jwks_uri: "${jwksUri}" 137 + user_mapping_provider: 138 + config: 139 + subject_claim: "${cfg.subjectClaim}" 140 + localpart_template: "${cfg.localpartTemplate}" 141 + display_name_template: "${cfg.displayNameTemplate}" 142 + EOF 143 + chown matrix-synapse:matrix-synapse ${cfg.configPath} 144 + ''; 145 + }; 146 + }; 147 + }
+6
packages/login-page/default.nix
··· 1 + { pkgs }: 2 + 3 + pkgs.runCommand "login-page" {} '' 4 + mkdir -p $out 5 + cp ${./index.html} $out/index.html 6 + ''
+162
packages/login-page/index.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1"> 7 + <title>Sign in with ATProto</title> 8 + <style> 9 + * { 10 + box-sizing: border-box; 11 + } 12 + 13 + body { 14 + font-family: Georgia, "Times New Roman", serif; 15 + font-weight: 400; 16 + font-size: 18px; 17 + background: linear-gradient(135deg, #267E5C 0%, #1a5a42 100%); 18 + min-height: 100vh; 19 + display: flex; 20 + align-items: center; 21 + justify-content: center; 22 + margin: 0; 23 + padding: 20px; 24 + } 25 + 26 + .main-container { 27 + display: flex; 28 + gap: 40px; 29 + max-width: 1100px; 30 + width: 100%; 31 + align-items: stretch; 32 + } 33 + 34 + .container { 35 + background: white; 36 + padding: 40px; 37 + border-radius: 16px; 38 + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); 39 + flex: 1; 40 + } 41 + 42 + h1 { 43 + margin: 0 0 8px 0; 44 + color: #1a1a2e; 45 + font-size: 1.5em; 46 + font-style: italic; 47 + } 48 + 49 + p { 50 + color: #666; 51 + margin: 0 0 24px 0; 52 + } 53 + 54 + label { 55 + display: block; 56 + margin-bottom: 8px; 57 + font-weight: 500; 58 + color: #333; 59 + } 60 + 61 + input[type="text"] { 62 + width: 100%; 63 + padding: 12px 16px; 64 + border: 2px solid #e0e0e0; 65 + border-radius: 8px; 66 + font-size: 1em; 67 + font-family: inherit; 68 + margin-bottom: 8px; 69 + transition: border-color 0.2s; 70 + } 71 + 72 + input[type="text"]:focus { 73 + outline: none; 74 + border-color: #267E5C; 75 + } 76 + 77 + .hint { 78 + color: #888; 79 + font-size: 0.75em; 80 + margin-bottom: 24px; 81 + } 82 + 83 + button { 84 + width: 100%; 85 + padding: 14px; 86 + background: #267E5C; 87 + color: white; 88 + border: none; 89 + border-radius: 8px; 90 + font-size: 1em; 91 + font-family: inherit; 92 + font-weight: 500; 93 + cursor: pointer; 94 + transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s; 95 + } 96 + 97 + button:hover { 98 + transform: translateY(-2px); 99 + background: #1a5a42; 100 + box-shadow: 0 4px 12px rgba(38, 126, 92, 0.4); 101 + } 102 + 103 + .logo { 104 + text-align: center; 105 + margin-bottom: 24px; 106 + font-size: 48px; 107 + } 108 + 109 + @media (max-width: 768px) { 110 + .main-container { 111 + flex-direction: column; 112 + } 113 + } 114 + </style> 115 + </head> 116 + 117 + <body> 118 + <div class="main-container"> 119 + <div class="container"> 120 + <div class="logo">☁️</div> 121 + <h1>Sign in with ATProto</h1> 122 + <p>Enter your ATProto handle to continue to Matrix</p> 123 + <form id="loginForm"> 124 + <label for="handle">ATProto Handle</label> 125 + <input type="text" id="handle" name="handle" placeholder="you.bsky.social" required> 126 + <div class="hint">Enter your full handle (e.g. alice.bsky.social)</div> 127 + <button type="submit">Continue</button> 128 + </form> 129 + </div> 130 + </div> 131 + <script> 132 + const form = document.getElementById('loginForm'); 133 + const params = new URLSearchParams(window.location.search); 134 + 135 + form.addEventListener('submit', function (e) { 136 + e.preventDefault(); 137 + const handle = document.getElementById('handle').value.trim(); 138 + 139 + // Convert handle to login_hint format (handle@domain) 140 + // If user enters "alice.bsky.social", convert to "alice@bsky.social" 141 + let loginHint = handle; 142 + if (handle.includes('.') && !handle.includes('@')) { 143 + const parts = handle.split('.'); 144 + const user = parts[0]; 145 + const domain = parts.slice(1).join('.'); 146 + loginHint = user + '@' + domain; 147 + } 148 + 149 + // Build ATLogin authorize URL with all original params plus login_hint 150 + // Use relative URL so it works on any domain (localhost or production) 151 + const atloginUrl = new URL('/atlogin/authorize', window.location.origin); 152 + for (const [key, value] of params) { 153 + atloginUrl.searchParams.set(key, value); 154 + } 155 + atloginUrl.searchParams.set('login_hint', loginHint); 156 + 157 + window.location.href = atloginUrl.toString(); 158 + }); 159 + </script> 160 + </body> 161 + 162 + </html>
+239
packages/synapse-plugins/avatar-sync/__init__.py
··· 1 + """ 2 + Synapse module to sync Bluesky avatars on user registration. 3 + 4 + When a user registers via OIDC with a DID-based username, this module 5 + fetches their Bluesky avatar and sets it as their Matrix avatar. 6 + """ 7 + 8 + import logging 9 + import re 10 + from typing import Any, Dict, Optional 11 + 12 + from synapse.module_api import ModuleApi 13 + from synapse.http.client import SimpleHttpClient 14 + 15 + logger = logging.getLogger(__name__) 16 + 17 + 18 + class BlueskyAvatarSync: 19 + def __init__(self, config: Dict[str, Any], api: ModuleApi): 20 + self._api = api 21 + self._config = config 22 + self._http_client: Optional[SimpleHttpClient] = None 23 + 24 + # Register the callback for new user registration 25 + api.register_account_validity_callbacks( 26 + on_user_registration=self.on_user_registration, 27 + ) 28 + 29 + logger.info("BlueskyAvatarSync module initialized") 30 + 31 + def _get_http_client(self) -> SimpleHttpClient: 32 + """Get or create the HTTP client.""" 33 + if self._http_client is None: 34 + self._http_client = SimpleHttpClient(self._api._hs) 35 + return self._http_client 36 + 37 + @staticmethod 38 + def parse_config(config: Dict[str, Any]) -> Dict[str, Any]: 39 + """Parse and validate the module config.""" 40 + return config 41 + 42 + async def on_user_registration(self, user_id: str) -> None: 43 + """Called when a new user is registered.""" 44 + logger.info(f"New user registered: {user_id}") 45 + 46 + try: 47 + await self._sync_avatar(user_id) 48 + except Exception as e: 49 + logger.error(f"Failed to sync avatar for {user_id}: {e}", exc_info=True) 50 + 51 + async def _sync_avatar(self, user_id: str) -> None: 52 + """Fetch Bluesky avatar URL and use SSO handler to set it.""" 53 + 54 + # Extract localpart from user_id (@localpart:server) 55 + match = re.match(r"@([^:]+):", user_id) 56 + if not match: 57 + logger.warning(f"Could not parse user_id: {user_id}") 58 + return 59 + 60 + localpart = match.group(1) 61 + 62 + # Convert localpart to DID 63 + did = self._localpart_to_did(localpart) 64 + if not did: 65 + logger.info(f"User {user_id} doesn't have a DID-based username, skipping") 66 + return 67 + 68 + logger.info(f"Resolved user {user_id} to DID: {did}") 69 + 70 + http_client = self._get_http_client() 71 + 72 + # Resolve DID to PDS endpoint 73 + pds = await self._resolve_did(http_client, did) 74 + if not pds: 75 + logger.error(f"Could not resolve DID {did} to PDS") 76 + return 77 + 78 + logger.info(f"PDS endpoint for {did}: {pds}") 79 + 80 + # Get profile from PDS 81 + profile = await self._get_profile(http_client, pds, did) 82 + if not profile: 83 + logger.warning(f"Could not fetch profile for {did}") 84 + return 85 + 86 + # Extract avatar info 87 + avatar_ref = profile.get("value", {}).get("avatar") 88 + if not avatar_ref: 89 + logger.info(f"No avatar found for {did}") 90 + return 91 + 92 + avatar_cid = avatar_ref.get("ref", {}).get("$link") 93 + 94 + if not avatar_cid: 95 + logger.warning(f"Avatar ref missing CID for {did}") 96 + return 97 + 98 + avatar_mime = avatar_ref.get("mimeType", "image/jpeg") 99 + 100 + logger.info(f"Fetching avatar CID {avatar_cid} for {did}") 101 + 102 + # Download avatar 103 + avatar_data = await self._download_blob(http_client, pds, did, avatar_cid) 104 + if not avatar_data: 105 + logger.error(f"Failed to download avatar for {did}") 106 + return 107 + 108 + logger.info(f"Downloaded avatar: {len(avatar_data)} bytes") 109 + 110 + # Upload to Matrix media repo 111 + mxc_uri = await self._upload_to_matrix(user_id, avatar_data, avatar_mime) 112 + if not mxc_uri: 113 + logger.error(f"Failed to upload avatar to Matrix") 114 + return 115 + 116 + logger.info(f"Uploaded avatar to Matrix: {mxc_uri}") 117 + 118 + # Set user's avatar 119 + await self._set_avatar(user_id, mxc_uri) 120 + logger.info(f"Successfully set avatar for {user_id}") 121 + 122 + def _localpart_to_did(self, localpart: str) -> Optional[str]: 123 + """Convert a Matrix localpart back to a DID.""" 124 + if localpart.startswith("did_plc_"): 125 + return f"did:plc:{localpart[8:]}" 126 + elif localpart.startswith("did_web_"): 127 + # Convert underscores back to dots for domain 128 + domain = localpart[8:].replace("_", ".") 129 + return f"did:web:{domain}" 130 + return None 131 + 132 + async def _resolve_did( 133 + self, http_client: SimpleHttpClient, did: str 134 + ) -> Optional[str]: 135 + """Resolve a DID to its PDS endpoint.""" 136 + try: 137 + if did.startswith("did:plc:"): 138 + url = f"https://plc.directory/{did}" 139 + response = await http_client.get_json(url) 140 + 141 + for service in response.get("service", []): 142 + if service.get("type") == "AtprotoPersonalDataServer": 143 + return service.get("serviceEndpoint") 144 + return None 145 + 146 + elif did.startswith("did:web:"): 147 + domain = did[8:] 148 + url = f"https://{domain}/.well-known/did.json" 149 + response = await http_client.get_json(url) 150 + 151 + for service in response.get("service", []): 152 + if service.get("type") == "AtprotoPersonalDataServer": 153 + return service.get("serviceEndpoint") 154 + return None 155 + 156 + return None 157 + except Exception as e: 158 + logger.error(f"Error resolving DID {did}: {e}") 159 + return None 160 + 161 + async def _get_profile( 162 + self, http_client: SimpleHttpClient, pds: str, did: str 163 + ) -> Optional[Dict[str, Any]]: 164 + """Fetch Bluesky profile from PDS.""" 165 + try: 166 + url = ( 167 + f"{pds}/xrpc/com.atproto.repo.getRecord" 168 + f"?repo={did}&collection=app.bsky.actor.profile&rkey=self" 169 + ) 170 + return await http_client.get_json(url) 171 + except Exception as e: 172 + logger.error(f"Error fetching profile for {did}: {e}") 173 + return None 174 + 175 + async def _download_blob( 176 + self, http_client: SimpleHttpClient, pds: str, did: str, cid: str 177 + ) -> Optional[bytes]: 178 + """Download a blob from PDS.""" 179 + try: 180 + url = f"{pds}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}" 181 + response = await http_client.get_raw(url) 182 + return response 183 + except Exception as e: 184 + logger.error(f"Error downloading blob {cid}: {e}") 185 + return None 186 + 187 + async def _upload_to_matrix( 188 + self, user_id: str, data: bytes, content_type: str 189 + ) -> Optional[str]: 190 + """Upload media to Matrix using the media repository.""" 191 + try: 192 + import io 193 + from synapse.types import UserID 194 + 195 + media_repo = self._api._hs.get_media_repository() 196 + user_id_obj = UserID.from_string(user_id) 197 + 198 + # First create a media_id - returns (mxc_uri, unused_expires_at) 199 + result = await media_repo.create_media_id(user_id_obj) 200 + mxc_uri, _ = result 201 + # Extract media_id from mxc://server/media_id 202 + media_id = mxc_uri.split("/")[-1] 203 + logger.info(f"Created media_id: {media_id}, mxc_uri: {mxc_uri}") 204 + 205 + # Now upload the content 206 + await media_repo.create_or_update_content( 207 + media_id=media_id, 208 + media_type=content_type, 209 + upload_name="avatar.jpg", 210 + content=io.BytesIO(data), 211 + content_length=len(data), 212 + auth_user=user_id_obj, 213 + ) 214 + 215 + return str(mxc_uri) 216 + except Exception as e: 217 + logger.error(f"Error uploading to Matrix: {e}", exc_info=True) 218 + return None 219 + 220 + async def _set_avatar(self, user_id: str, mxc_uri: str) -> None: 221 + """Set the user's avatar URL.""" 222 + try: 223 + from synapse.types import UserID, create_requester 224 + 225 + target_user = UserID.from_string(user_id) 226 + # Create a requester as the user themselves 227 + requester = create_requester(target_user) 228 + 229 + profile_handler = self._api._hs.get_profile_handler() 230 + await profile_handler.set_avatar_url( 231 + target_user=target_user, 232 + requester=requester, 233 + new_avatar_url=mxc_uri, 234 + by_admin=True, 235 + ) 236 + logger.info(f"Set avatar URL for {user_id}") 237 + except Exception as e: 238 + logger.error(f"Error setting avatar: {e}", exc_info=True) 239 + raise
+16
packages/synapse-plugins/default.nix
··· 1 + { pkgs }: 2 + 3 + { 4 + # Synapse module for Bluesky avatar sync 5 + avatarSync = pkgs.python3Packages.buildPythonPackage { 6 + pname = "synapse-avatar-sync"; 7 + version = "0.1.0"; 8 + src = ./avatar-sync; 9 + format = "other"; 10 + 11 + installPhase = '' 12 + mkdir -p $out/${pkgs.python3.sitePackages}/synapse_avatar_sync 13 + cp __init__.py $out/${pkgs.python3.sitePackages}/synapse_avatar_sync/ 14 + ''; 15 + }; 16 + }