Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

sso signup & login

+9981 -313
+40
.env.example
··· 160 160 # ALLOW_HTTP_PROXY=1 161 161 # Custom frontend directory (defaults to ./frontend/dist) 162 162 # FRONTEND_DIR=/path/to/frontend/dist 163 + # ============================================================================= 164 + # SSO / Social Login 165 + # ============================================================================= 166 + # Each provider requires ENABLED=true plus CLIENT_ID and CLIENT_SECRET. 167 + # Register your PDS as an OAuth application with each provider to get credentials. 168 + 169 + # GitHub 170 + # SSO_GITHUB_ENABLED=true 171 + # SSO_GITHUB_CLIENT_ID= 172 + # SSO_GITHUB_CLIENT_SECRET= 173 + 174 + # Discord 175 + # SSO_DISCORD_ENABLED=true 176 + # SSO_DISCORD_CLIENT_ID= 177 + # SSO_DISCORD_CLIENT_SECRET= 178 + 179 + # Google 180 + # SSO_GOOGLE_ENABLED=true 181 + # SSO_GOOGLE_CLIENT_ID= 182 + # SSO_GOOGLE_CLIENT_SECRET= 183 + 184 + # GitLab (set ISSUER for self-hosted instances) 185 + # SSO_GITLAB_ENABLED=false 186 + # SSO_GITLAB_CLIENT_ID= 187 + # SSO_GITLAB_CLIENT_SECRET= 188 + # SSO_GITLAB_ISSUER=https://gitlab.com 189 + 190 + # Generic OIDC 191 + # SSO_OIDC_ENABLED=false 192 + # SSO_OIDC_CLIENT_ID= 193 + # SSO_OIDC_CLIENT_SECRET= 194 + # SSO_OIDC_ISSUER=https://your-identity-provider.com 195 + # SSO_OIDC_NAME=Custom Provider 196 + 197 + # Apple Sign-in 198 + # SSO_APPLE_ENABLED=true 199 + # SSO_APPLE_CLIENT_ID=com.example.signin # Services ID from Apple Developer Portal 200 + # SSO_APPLE_TEAM_ID=XXXXXXXXXX # 10-character Team ID 201 + # SSO_APPLE_KEY_ID=XXXXXXXXXX # Key ID from portal 202 + # SSO_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" 163 203 CARGO_MOMMYS_LITTLE=mister 164 204 CARGO_MOMMYS_PRONOUNS=his 165 205 CARGO_MOMMYS_ROLES=daddy
+22
.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc" 22 + }
+77
.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "request_uri", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc" 29 + ] 30 + } 31 + } 32 + } 33 + }, 34 + { 35 + "ordinal": 3, 36 + "name": "provider_user_id", 37 + "type_info": "Text" 38 + }, 39 + { 40 + "ordinal": 4, 41 + "name": "provider_username", 42 + "type_info": "Text" 43 + }, 44 + { 45 + "ordinal": 5, 46 + "name": "provider_email", 47 + "type_info": "Text" 48 + }, 49 + { 50 + "ordinal": 6, 51 + "name": "created_at", 52 + "type_info": "Timestamptz" 53 + }, 54 + { 55 + "ordinal": 7, 56 + "name": "expires_at", 57 + "type_info": "Timestamptz" 58 + } 59 + ], 60 + "parameters": { 61 + "Left": [ 62 + "Text" 63 + ] 64 + }, 65 + "nullable": [ 66 + false, 67 + false, 68 + false, 69 + false, 70 + true, 71 + true, 72 + false, 73 + false 74 + ] 75 + }, 76 + "hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82" 77 + }
+15
.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET deactivated_at = $1 WHERE did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Timestamptz", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107" 15 + }
+22
.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "body", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99" 22 + }
+22
.sqlx/query-0fae1be7a75bdc58c69a9af97cad4aec23c32a9378764b8d6d7eb2cc89c562b1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT token FROM sso_pending_registration WHERE token = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "0fae1be7a75bdc58c69a9af97cad4aec23c32a9378764b8d6d7eb2cc89c562b1" 22 + }
+16
.sqlx/query-1abfd9ff7ae1de0ca048b6a67a60a7ba5cfca75af5cc4e7280fea230cf46af7e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE external_identities\n SET provider_username = COALESCE($2, provider_username),\n provider_email = COALESCE($3, provider_email),\n last_login_at = NOW(),\n updated_at = NOW()\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "1abfd9ff7ae1de0ca048b6a67a60a7ba5cfca75af5cc4e7280fea230cf46af7e" 16 + }
+22
.sqlx/query-1c84643fd6bc57c76517849a64d2d877df337e823d4c2c2b077f695bbfc9e9ac.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "1c84643fd6bc57c76517849a64d2d877df337e823d4c2c2b077f695bbfc9e9ac" 22 + }
+28
.sqlx/query-24b823043ab60f36c29029137fef30dfe33922bb06067f2fdbfc1fbb4b0a2a81.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "request_uri", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "24b823043ab60f36c29029137fef30dfe33922bb06067f2fdbfc1fbb4b0a2a81" 28 + }
+38
.sqlx/query-2841093a67480e75e1e9e4046bf3eb74afae2d04f5ea0ec17a4d433983e6d71c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id)\n VALUES ($1, $2, $3)\n RETURNING id\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + { 16 + "Custom": { 17 + "name": "sso_provider_type", 18 + "kind": { 19 + "Enum": [ 20 + "github", 21 + "discord", 22 + "google", 23 + "gitlab", 24 + "oidc", 25 + "apple" 26 + ] 27 + } 28 + } 29 + }, 30 + "Text" 31 + ] 32 + }, 33 + "nullable": [ 34 + false 35 + ] 36 + }, 37 + "hash": "2841093a67480e75e1e9e4046bf3eb74afae2d04f5ea0ec17a4d433983e6d71c" 38 + }
+14
.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5" 14 + }
+32
.sqlx/query-376b72306b50f747bc9161985ff4f50c35c53025a55ccf5e9933dc3795d29313.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc", 20 + "apple" 21 + ] 22 + } 23 + } 24 + }, 25 + "Text", 26 + "Bool" 27 + ] 28 + }, 29 + "nullable": [] 30 + }, 31 + "hash": "376b72306b50f747bc9161985ff4f50c35c53025a55ccf5e9933dc3795d29313" 32 + }
+22
.sqlx/query-3933ea5b147ab6294936de147b98e116cfae848ecd76ea5d367585eb5117f2ad.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id FROM external_identities WHERE id = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Uuid" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "3933ea5b147ab6294936de147b98e116cfae848ecd76ea5d367585eb5117f2ad" 22 + }
+16
.sqlx/query-3bed8d4843545f4a9676207513806603c50eb2af92957994abaf1c89c0294c12.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "3bed8d4843545f4a9676207513806603c50eb2af92957994abaf1c89c0294c12" 16 + }
+54
.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "subject", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "body", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "comms_type: String", 19 + "type_info": { 20 + "Custom": { 21 + "name": "comms_type", 22 + "kind": { 23 + "Enum": [ 24 + "welcome", 25 + "email_verification", 26 + "password_reset", 27 + "email_update", 28 + "account_deletion", 29 + "admin_email", 30 + "plc_operation", 31 + "two_factor_code", 32 + "channel_verification", 33 + "passkey_recovery", 34 + "legacy_login_alert", 35 + "migration_verification" 36 + ] 37 + } 38 + } 39 + } 40 + } 41 + ], 42 + "parameters": { 43 + "Left": [ 44 + "Uuid" 45 + ] 46 + }, 47 + "nullable": [ 48 + true, 49 + false, 50 + false 51 + ] 52 + }, 53 + "hash": "4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe" 54 + }
+33
.sqlx/query-44a1f3f4c515e904e9d5c616a48d7d6a59bcb5e2f415122c1bb1e5f54cacc12f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + { 10 + "Custom": { 11 + "name": "sso_provider_type", 12 + "kind": { 13 + "Enum": [ 14 + "github", 15 + "discord", 16 + "google", 17 + "gitlab", 18 + "oidc", 19 + "apple" 20 + ] 21 + } 22 + } 23 + }, 24 + "Text", 25 + "Text", 26 + "Text", 27 + "Bool" 28 + ] 29 + }, 30 + "nullable": [] 31 + }, 32 + "hash": "44a1f3f4c515e904e9d5c616a48d7d6a59bcb5e2f415122c1bb1e5f54cacc12f" 33 + }
+22
.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id FROM users WHERE email = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068" 22 + }
+40
.sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_id FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "preferred_comms_channel: String", 9 + "type_info": { 10 + "Custom": { 11 + "name": "comms_channel", 12 + "kind": { 13 + "Enum": [ 14 + "email", 15 + "discord", 16 + "telegram", 17 + "signal" 18 + ] 19 + } 20 + } 21 + } 22 + }, 23 + { 24 + "ordinal": 1, 25 + "name": "discord_id", 26 + "type_info": "Text" 27 + } 28 + ], 29 + "parameters": { 30 + "Left": [ 31 + "Text" 32 + ] 33 + }, 34 + "nullable": [ 35 + false, 36 + true 37 + ] 38 + }, 39 + "hash": "45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722" 40 + }
+15
.sqlx/query-47faf3cd805673aab801d23dee46c3e802ca3988426863424e2bc2f627d9b758.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO handle_reservations (handle, reserved_by)\n SELECT $1, $2\n WHERE NOT EXISTS (\n SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL\n )\n AND NOT EXISTS (\n SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW()\n )\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "47faf3cd805673aab801d23dee46c3e802ca3988426863424e2bc2f627d9b758" 15 + }
+28
.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "expires_at", 14 + "type_info": "Timestamptz" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88" 28 + }
+22
.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT email_verified FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "email_verified", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6" 22 + }
+22
.sqlx/query-4fef326fa2d03d04869af3fec702c901d1ecf392545a3a032438b2c1859d46cc.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT token FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "4fef326fa2d03d04869af3fec702c901d1ecf392545a3a032438b2c1859d46cc" 22 + }
+77
.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "request_uri", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc" 29 + ] 30 + } 31 + } 32 + } 33 + }, 34 + { 35 + "ordinal": 3, 36 + "name": "provider_user_id", 37 + "type_info": "Text" 38 + }, 39 + { 40 + "ordinal": 4, 41 + "name": "provider_username", 42 + "type_info": "Text" 43 + }, 44 + { 45 + "ordinal": 5, 46 + "name": "provider_email", 47 + "type_info": "Text" 48 + }, 49 + { 50 + "ordinal": 6, 51 + "name": "created_at", 52 + "type_info": "Timestamptz" 53 + }, 54 + { 55 + "ordinal": 7, 56 + "name": "expires_at", 57 + "type_info": "Timestamptz" 58 + } 59 + ], 60 + "parameters": { 61 + "Left": [ 62 + "Text" 63 + ] 64 + }, 65 + "nullable": [ 66 + false, 67 + false, 68 + false, 69 + false, 70 + true, 71 + true, 72 + false, 73 + false 74 + ] 75 + }, 76 + "hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a" 77 + }
+15
.sqlx/query-575c1e5529874f8f523e6fe22ccf4ee3296806581b1765dfb91a84ffab347f15.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO oauth_authorization_request (id, client_id, parameters, expires_at)\n VALUES ($1, 'https://test.example.com', $2, NOW() + INTERVAL '1 hour')\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Jsonb" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "575c1e5529874f8f523e6fe22ccf4ee3296806581b1765dfb91a84ffab347f15" 15 + }
+33
.sqlx/query-596c3400a60c77c7645fd46fcea61fa7898b6832e58c0f647f382b23b81d350e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc", 20 + "apple" 21 + ] 22 + } 23 + } 24 + }, 25 + "Text", 26 + "Text", 27 + "Text" 28 + ] 29 + }, 30 + "nullable": [] 31 + }, 32 + "hash": "596c3400a60c77c7645fd46fcea61fa7898b6832e58c0f647f382b23b81d350e" 33 + }
+81
.sqlx/query-59e63c5cf92985714e9586d1ce012efef733d4afaa4ea09974daf8303805e5d2.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, did, provider as \"provider: SsoProviderType\", provider_user_id, provider_username, provider_email\n FROM external_identities\n WHERE provider = $1 AND provider_user_id = $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc", 29 + "apple" 30 + ] 31 + } 32 + } 33 + } 34 + }, 35 + { 36 + "ordinal": 3, 37 + "name": "provider_user_id", 38 + "type_info": "Text" 39 + }, 40 + { 41 + "ordinal": 4, 42 + "name": "provider_username", 43 + "type_info": "Text" 44 + }, 45 + { 46 + "ordinal": 5, 47 + "name": "provider_email", 48 + "type_info": "Text" 49 + } 50 + ], 51 + "parameters": { 52 + "Left": [ 53 + { 54 + "Custom": { 55 + "name": "sso_provider_type", 56 + "kind": { 57 + "Enum": [ 58 + "github", 59 + "discord", 60 + "google", 61 + "gitlab", 62 + "oidc", 63 + "apple" 64 + ] 65 + } 66 + } 67 + }, 68 + "Text" 69 + ] 70 + }, 71 + "nullable": [ 72 + false, 73 + false, 74 + false, 75 + false, 76 + true, 77 + true 78 + ] 79 + }, 80 + "hash": "59e63c5cf92985714e9586d1ce012efef733d4afaa4ea09974daf8303805e5d2" 81 + }
+28
.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT k.key_bytes, k.encryption_version\n FROM user_keys k\n JOIN users u ON k.user_id = u.id\n WHERE u.did = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "key_bytes", 9 + "type_info": "Bytea" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "encryption_version", 14 + "type_info": "Int4" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + true 25 + ] 26 + }, 27 + "hash": "5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0" 28 + }
+22
.sqlx/query-5af4a386c1632903ad7102551a5bd148bcf541baab6a84c8649666a695f9c4d1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM sso_auth_state\n WHERE state = $1 AND expires_at > NOW()\n RETURNING state\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "state", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "5af4a386c1632903ad7102551a5bd148bcf541baab6a84c8649666a695f9c4d1" 22 + }
+14
.sqlx/query-5dc0d09cea2415c4053518b7cb5c41da4a8cae66c8c9cd151eee4ea29c0e1c45.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM sso_auth_state\n WHERE expires_at < $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Timestamptz" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "5dc0d09cea2415c4053518b7cb5c41da4a8cae66c8c9cd151eee4ea29c0e1c45" 14 + }
+43
.sqlx/query-5e4c0dd92ac3c4b5e2eae5d129f2649cf3a8f068105f44a8dca9625427affc06.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT provider_user_id, provider_email_verified\n FROM external_identities\n WHERE did = $1 AND provider = $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "provider_user_id", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "provider_email_verified", 14 + "type_info": "Bool" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text", 20 + { 21 + "Custom": { 22 + "name": "sso_provider_type", 23 + "kind": { 24 + "Enum": [ 25 + "github", 26 + "discord", 27 + "google", 28 + "gitlab", 29 + "oidc", 30 + "apple" 31 + ] 32 + } 33 + } 34 + } 35 + ] 36 + }, 37 + "nullable": [ 38 + false, 39 + false 40 + ] 41 + }, 42 + "hash": "5e4c0dd92ac3c4b5e2eae5d129f2649cf3a8f068105f44a8dca9625427affc06" 43 + }
+33
.sqlx/query-5e9c6ec72c2c0ea1c8dff551d01baddd1dd953c828a5656db2ee39dea996f890.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc", 20 + "apple" 21 + ] 22 + } 23 + } 24 + }, 25 + "Text", 26 + "Text", 27 + "Text" 28 + ] 29 + }, 30 + "nullable": [] 31 + }, 32 + "hash": "5e9c6ec72c2c0ea1c8dff551d01baddd1dd953c828a5656db2ee39dea996f890" 33 + }
+34
.sqlx/query-630c1fabbf37946cbf2f3f77faa2e973875cd8e9176792d79a4bec91d703bbf2.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc", 20 + "apple" 21 + ] 22 + } 23 + } 24 + }, 25 + "Text", 26 + "Text", 27 + "Text", 28 + "Bool" 29 + ] 30 + }, 31 + "nullable": [] 32 + }, 33 + "hash": "630c1fabbf37946cbf2f3f77faa2e973875cd8e9176792d79a4bec91d703bbf2" 34 + }
+28
.sqlx/query-63f6f2a89650794fe90e10ce7fc785a6b9f7d37c12b31a6ff13f7c5214eef19e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT did, email_verified FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "email_verified", 14 + "type_info": "Bool" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "63f6f2a89650794fe90e10ce7fc785a6b9f7d37c12b31a6ff13f7c5214eef19e" 28 + }
+66
.sqlx/query-6c7ace2a64848adc757af6c93b9162e1d95788b372370a7ad0d7540338bb73ee.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT state, request_uri, provider as \"provider: SsoProviderType\", action, nonce, code_verifier\n FROM sso_auth_state\n WHERE state = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "state", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "request_uri", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc", 29 + "apple" 30 + ] 31 + } 32 + } 33 + } 34 + }, 35 + { 36 + "ordinal": 3, 37 + "name": "action", 38 + "type_info": "Text" 39 + }, 40 + { 41 + "ordinal": 4, 42 + "name": "nonce", 43 + "type_info": "Text" 44 + }, 45 + { 46 + "ordinal": 5, 47 + "name": "code_verifier", 48 + "type_info": "Text" 49 + } 50 + ], 51 + "parameters": { 52 + "Left": [ 53 + "Text" 54 + ] 55 + }, 56 + "nullable": [ 57 + false, 58 + false, 59 + false, 60 + false, 61 + true, 62 + true 63 + ] 64 + }, 65 + "hash": "6c7ace2a64848adc757af6c93b9162e1d95788b372370a7ad0d7540338bb73ee" 66 + }
+22
.sqlx/query-6fbcff0206599484bfb6cef165b6f729d27e7a342f7718ee4ac07f0ca94412ba.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT state FROM sso_auth_state WHERE state = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "state", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "6fbcff0206599484bfb6cef165b6f729d27e7a342f7718ee4ac07f0ca94412ba" 22 + }
+33
.sqlx/query-712459c27fc037f45389e2766cf1057e86e93ef756a784ed12beb453b03c5da1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc", 20 + "apple" 21 + ] 22 + } 23 + } 24 + }, 25 + "Text", 26 + "Text", 27 + "Bool" 28 + ] 29 + }, 30 + "nullable": [] 31 + }, 32 + "hash": "712459c27fc037f45389e2766cf1057e86e93ef756a784ed12beb453b03c5da1" 33 + }
+22
.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "subject", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Uuid" 15 + ] 16 + }, 17 + "nullable": [ 18 + true 19 + ] 20 + }, 21 + "hash": "785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f" 22 + }
+22
.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT t.token\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631" 22 + }
+28
.sqlx/query-7d24e744a4e63570b1410e50b45b745ce8915ab3715b3eff7efc2d84f27735d0.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT provider_username, last_login_at FROM external_identities WHERE id = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "provider_username", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "last_login_at", 14 + "type_info": "Timestamptz" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid" 20 + ] 21 + }, 22 + "nullable": [ 23 + true, 24 + true 25 + ] 26 + }, 27 + "hash": "7d24e744a4e63570b1410e50b45b745ce8915ab3715b3eff7efc2d84f27735d0" 28 + }
+28
.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT t.token, t.expires_at FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "expires_at", 14 + "type_info": "Timestamptz" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81" 28 + }
+34
.sqlx/query-85ffc37a77af832d7795f5f37efe304fced4bf56b4f2287fe9aeb3fc97e1b191.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc", 20 + "apple" 21 + ] 22 + } 23 + } 24 + }, 25 + "Text", 26 + "Text", 27 + "Text", 28 + "Bool" 29 + ] 30 + }, 31 + "nullable": [] 32 + }, 33 + "hash": "85ffc37a77af832d7795f5f37efe304fced4bf56b4f2287fe9aeb3fc97e1b191" 34 + }
+40
.sqlx/query-8d4753d81bdd340b97c816e160ba532f1838f2441079c11d471f2eddf24f5375.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + { 16 + "Custom": { 17 + "name": "sso_provider_type", 18 + "kind": { 19 + "Enum": [ 20 + "github", 21 + "discord", 22 + "google", 23 + "gitlab", 24 + "oidc", 25 + "apple" 26 + ] 27 + } 28 + } 29 + }, 30 + "Text", 31 + "Text", 32 + "Text" 33 + ] 34 + }, 35 + "nullable": [ 36 + false 37 + ] 38 + }, 39 + "hash": "8d4753d81bdd340b97c816e160ba532f1838f2441079c11d471f2eddf24f5375" 40 + }
+84
.sqlx/query-8f070e3bdc3b1bb8cfce9a9b1dd67dd022cc515720fb742cf4bf363895d71cd8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, provider_email_verified,\n created_at, expires_at\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "request_uri", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc", 29 + "apple" 30 + ] 31 + } 32 + } 33 + } 34 + }, 35 + { 36 + "ordinal": 3, 37 + "name": "provider_user_id", 38 + "type_info": "Text" 39 + }, 40 + { 41 + "ordinal": 4, 42 + "name": "provider_username", 43 + "type_info": "Text" 44 + }, 45 + { 46 + "ordinal": 5, 47 + "name": "provider_email", 48 + "type_info": "Text" 49 + }, 50 + { 51 + "ordinal": 6, 52 + "name": "provider_email_verified", 53 + "type_info": "Bool" 54 + }, 55 + { 56 + "ordinal": 7, 57 + "name": "created_at", 58 + "type_info": "Timestamptz" 59 + }, 60 + { 61 + "ordinal": 8, 62 + "name": "expires_at", 63 + "type_info": "Timestamptz" 64 + } 65 + ], 66 + "parameters": { 67 + "Left": [ 68 + "Text" 69 + ] 70 + }, 71 + "nullable": [ 72 + false, 73 + false, 74 + false, 75 + false, 76 + true, 77 + true, 78 + false, 79 + false, 80 + false 81 + ] 82 + }, 83 + "hash": "8f070e3bdc3b1bb8cfce9a9b1dd67dd022cc515720fb742cf4bf363895d71cd8" 84 + }
+84
.sqlx/query-9468c5af2fb0e06e600e6c67e236bd4e368b06ce4af15fed16b8a0bfc5328c36.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM sso_auth_state\n WHERE state = $1 AND expires_at > NOW()\n RETURNING state, request_uri, provider as \"provider: SsoProviderType\", action,\n nonce, code_verifier, did, created_at, expires_at\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "state", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "request_uri", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc", 29 + "apple" 30 + ] 31 + } 32 + } 33 + } 34 + }, 35 + { 36 + "ordinal": 3, 37 + "name": "action", 38 + "type_info": "Text" 39 + }, 40 + { 41 + "ordinal": 4, 42 + "name": "nonce", 43 + "type_info": "Text" 44 + }, 45 + { 46 + "ordinal": 5, 47 + "name": "code_verifier", 48 + "type_info": "Text" 49 + }, 50 + { 51 + "ordinal": 6, 52 + "name": "did", 53 + "type_info": "Text" 54 + }, 55 + { 56 + "ordinal": 7, 57 + "name": "created_at", 58 + "type_info": "Timestamptz" 59 + }, 60 + { 61 + "ordinal": 8, 62 + "name": "expires_at", 63 + "type_info": "Timestamptz" 64 + } 65 + ], 66 + "parameters": { 67 + "Left": [ 68 + "Text" 69 + ] 70 + }, 71 + "nullable": [ 72 + false, 73 + false, 74 + false, 75 + false, 76 + true, 77 + true, 78 + true, 79 + false, 80 + false 81 + ] 82 + }, 83 + "hash": "9468c5af2fb0e06e600e6c67e236bd4e368b06ce4af15fed16b8a0bfc5328c36" 84 + }
+14
.sqlx/query-946e30fee0e45a99f3fe1ec3671c561c9dc537a848bc94c4740d5a83bf8d2861.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM sso_pending_registration\n WHERE expires_at < $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Timestamptz" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "946e30fee0e45a99f3fe1ec3671c561c9dc537a848bc94c4740d5a83bf8d2861" 14 + }
+22
.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "body", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5" 22 + }
+28
.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "public_key_did_key", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + true, 24 + false 25 + ] 26 + }, 27 + "hash": "9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002" 28 + }
+22
.sqlx/query-9dba64081d4f95b5490c9a9bf30a7175db3429f39df4f25e212f38f33882fc65.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id FROM external_identities WHERE did = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "9dba64081d4f95b5490c9a9bf30a7175db3429f39df4f25e212f38f33882fc65" 22 + }
+66
.sqlx/query-9fd56986c1c843d386d1e5884acef8573eb55a3e9f5cb0122fcf8b93d6d667a5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\", provider_user_id,\n provider_username, provider_email\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "request_uri", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc", 29 + "apple" 30 + ] 31 + } 32 + } 33 + } 34 + }, 35 + { 36 + "ordinal": 3, 37 + "name": "provider_user_id", 38 + "type_info": "Text" 39 + }, 40 + { 41 + "ordinal": 4, 42 + "name": "provider_username", 43 + "type_info": "Text" 44 + }, 45 + { 46 + "ordinal": 5, 47 + "name": "provider_email", 48 + "type_info": "Text" 49 + } 50 + ], 51 + "parameters": { 52 + "Left": [ 53 + "Text" 54 + ] 55 + }, 56 + "nullable": [ 57 + false, 58 + false, 59 + false, 60 + false, 61 + true, 62 + true 63 + ] 64 + }, 65 + "hash": "9fd56986c1c843d386d1e5884acef8573eb55a3e9f5cb0122fcf8b93d6d667a5" 66 + }
+34
.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "private_key_bytes", 9 + "type_info": "Bytea" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "expires_at", 14 + "type_info": "Timestamptz" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "used_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + true 31 + ] 32 + }, 33 + "hash": "a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5" 34 + }
+15
.sqlx/query-a3d549a32e76c24e265c73a98dd739067623f275de0740bd576ee288f4444496.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE external_identities\n SET provider_username = $2, last_login_at = NOW()\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "a3d549a32e76c24e265c73a98dd739067623f275de0740bd576ee288f4444496" 15 + }
+31
.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + { 10 + "Custom": { 11 + "name": "sso_provider_type", 12 + "kind": { 13 + "Enum": [ 14 + "github", 15 + "discord", 16 + "google", 17 + "gitlab", 18 + "oidc" 19 + ] 20 + } 21 + } 22 + }, 23 + "Text", 24 + "Text", 25 + "Text" 26 + ] 27 + }, 28 + "nullable": [] 29 + }, 30 + "hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6" 31 + }
+22
.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT token FROM account_deletion_requests WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5" 22 + }
+99
.sqlx/query-a87afce2ff68221df2e3e1051293217446fa0ed25144755f0da6f4825478506c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, did, provider as \"provider: SsoProviderType\", provider_user_id,\n provider_username, provider_email, created_at, updated_at, last_login_at\n FROM external_identities\n WHERE provider = $1 AND provider_user_id = $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc", 29 + "apple" 30 + ] 31 + } 32 + } 33 + } 34 + }, 35 + { 36 + "ordinal": 3, 37 + "name": "provider_user_id", 38 + "type_info": "Text" 39 + }, 40 + { 41 + "ordinal": 4, 42 + "name": "provider_username", 43 + "type_info": "Text" 44 + }, 45 + { 46 + "ordinal": 5, 47 + "name": "provider_email", 48 + "type_info": "Text" 49 + }, 50 + { 51 + "ordinal": 6, 52 + "name": "created_at", 53 + "type_info": "Timestamptz" 54 + }, 55 + { 56 + "ordinal": 7, 57 + "name": "updated_at", 58 + "type_info": "Timestamptz" 59 + }, 60 + { 61 + "ordinal": 8, 62 + "name": "last_login_at", 63 + "type_info": "Timestamptz" 64 + } 65 + ], 66 + "parameters": { 67 + "Left": [ 68 + { 69 + "Custom": { 70 + "name": "sso_provider_type", 71 + "kind": { 72 + "Enum": [ 73 + "github", 74 + "discord", 75 + "google", 76 + "gitlab", 77 + "oidc", 78 + "apple" 79 + ] 80 + } 81 + } 82 + }, 83 + "Text" 84 + ] 85 + }, 86 + "nullable": [ 87 + false, 88 + false, 89 + false, 90 + false, 91 + true, 92 + true, 93 + false, 94 + false, 95 + true 96 + ] 97 + }, 98 + "hash": "a87afce2ff68221df2e3e1051293217446fa0ed25144755f0da6f4825478506c" 99 + }
+28
.sqlx/query-aee3e8e1d8924d41bec7d866e274f8bb2ddef833eb03326103c2d0a17ee56154.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM sso_auth_state\n WHERE state = $1 AND expires_at > NOW()\n RETURNING state, request_uri\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "state", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "request_uri", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "aee3e8e1d8924d41bec7d866e274f8bb2ddef833eb03326103c2d0a17ee56154" 28 + }
+31
.sqlx/query-ba9684872fad5201b8504c2606c29364a2df9631fe98817e7bfacd3f3f51f6cb.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, expires_at)\n VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour')\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc", 20 + "apple" 21 + ] 22 + } 23 + } 24 + }, 25 + "Text" 26 + ] 27 + }, 28 + "nullable": [] 29 + }, 30 + "hash": "ba9684872fad5201b8504c2606c29364a2df9631fe98817e7bfacd3f3f51f6cb" 31 + }
+12
.sqlx/query-bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM sso_auth_state WHERE expires_at < NOW()", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [] 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b" 12 + }
+84
.sqlx/query-bf7e32cc58dfe85e08d52595f0c3b979f0f7c04f4401b5840f96ff0e47144075.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, did, provider as \"provider: SsoProviderType\", provider_user_id,\n provider_username, provider_email, created_at, updated_at, last_login_at\n FROM external_identities\n WHERE did = $1\n ORDER BY created_at ASC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc", 29 + "apple" 30 + ] 31 + } 32 + } 33 + } 34 + }, 35 + { 36 + "ordinal": 3, 37 + "name": "provider_user_id", 38 + "type_info": "Text" 39 + }, 40 + { 41 + "ordinal": 4, 42 + "name": "provider_username", 43 + "type_info": "Text" 44 + }, 45 + { 46 + "ordinal": 5, 47 + "name": "provider_email", 48 + "type_info": "Text" 49 + }, 50 + { 51 + "ordinal": 6, 52 + "name": "created_at", 53 + "type_info": "Timestamptz" 54 + }, 55 + { 56 + "ordinal": 7, 57 + "name": "updated_at", 58 + "type_info": "Timestamptz" 59 + }, 60 + { 61 + "ordinal": 8, 62 + "name": "last_login_at", 63 + "type_info": "Timestamptz" 64 + } 65 + ], 66 + "parameters": { 67 + "Left": [ 68 + "Text" 69 + ] 70 + }, 71 + "nullable": [ 72 + false, 73 + false, 74 + false, 75 + false, 76 + true, 77 + true, 78 + false, 79 + false, 80 + true 81 + ] 82 + }, 83 + "hash": "bf7e32cc58dfe85e08d52595f0c3b979f0f7c04f4401b5840f96ff0e47144075" 84 + }
+22
.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT password_reset_code FROM users WHERE email = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "password_reset_code", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + true 19 + ] 20 + }, 21 + "hash": "cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee" 22 + }
+22
.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) as \"count!\" FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count!", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3" 22 + }
+34
.sqlx/query-cdba2cc5219e52ee1c23d52c1e099b49b87e45dcfc6edb7a3e73067ed61b312b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier, did)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc", 20 + "apple" 21 + ] 22 + } 23 + } 24 + }, 25 + "Text", 26 + "Text", 27 + "Text", 28 + "Text" 29 + ] 30 + }, 31 + "nullable": [] 32 + }, 33 + "hash": "cdba2cc5219e52ee1c23d52c1e099b49b87e45dcfc6edb7a3e73067ed61b312b" 34 + }
+31
.sqlx/query-d0d4fb4b44cda3442b20037b4d5efaa032e1d004c775e2b6077c5050d7d62041.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_auth_state (state, request_uri, provider, action, expires_at)\n VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour')\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc", 20 + "apple" 21 + ] 22 + } 23 + } 24 + }, 25 + "Text" 26 + ] 27 + }, 28 + "nullable": [] 29 + }, 30 + "hash": "d0d4fb4b44cda3442b20037b4d5efaa032e1d004c775e2b6077c5050d7d62041" 31 + }
+14
.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4" 14 + }
+40
.sqlx/query-dd7d80d4d118a5fc95b574e2ca9ffaccf974e52fb6ac368f716409c55f9d3ab0.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + { 16 + "Custom": { 17 + "name": "sso_provider_type", 18 + "kind": { 19 + "Enum": [ 20 + "github", 21 + "discord", 22 + "google", 23 + "gitlab", 24 + "oidc", 25 + "apple" 26 + ] 27 + } 28 + } 29 + }, 30 + "Text", 31 + "Text", 32 + "Text" 33 + ] 34 + }, 35 + "nullable": [ 36 + false 37 + ] 38 + }, 39 + "hash": "dd7d80d4d118a5fc95b574e2ca9ffaccf974e52fb6ac368f716409c55f9d3ab0" 40 + }
+32
.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + { 11 + "Custom": { 12 + "name": "sso_provider_type", 13 + "kind": { 14 + "Enum": [ 15 + "github", 16 + "discord", 17 + "google", 18 + "gitlab", 19 + "oidc" 20 + ] 21 + } 22 + } 23 + }, 24 + "Text", 25 + "Text", 26 + "Text" 27 + ] 28 + }, 29 + "nullable": [] 30 + }, 31 + "hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a" 32 + }
+22
.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Uuid" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3" 22 + }
+22
.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "used_at", 9 + "type_info": "Timestamptz" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + true 19 + ] 20 + }, 21 + "hash": "e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9" 22 + }
+30
.sqlx/query-eb54d2ce02cab7c2e7f9926bd469b19e5f0513f47173b2738fc01a57082d7abb.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id)\n VALUES ($1, $2, $3)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + { 10 + "Custom": { 11 + "name": "sso_provider_type", 12 + "kind": { 13 + "Enum": [ 14 + "github", 15 + "discord", 16 + "google", 17 + "gitlab", 18 + "oidc", 19 + "apple" 20 + ] 21 + } 22 + } 23 + }, 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [] 28 + }, 29 + "hash": "eb54d2ce02cab7c2e7f9926bd469b19e5f0513f47173b2738fc01a57082d7abb" 30 + }
+12
.sqlx/query-eb82195792193f432e9abfe5e6ea4d4c45ccb9bd15b025602c64967bd4c85fd3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM handle_reservations WHERE expires_at <= NOW()", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [] 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "eb82195792193f432e9abfe5e6ea4d4c45ccb9bd15b025602c64967bd4c85fd3" 12 + }
+15
.sqlx/query-ec22a8cc89e480c403a239eac44288e144d83364129491de6156760616666d3d.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM external_identities WHERE id = $1 AND did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "ec22a8cc89e480c403a239eac44288e144d83364129491de6156760616666d3d" 15 + }
+22
.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT email FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "email", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + true 19 + ] 20 + }, 21 + "hash": "f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7" 22 + }
+14
.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET is_admin = TRUE WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814" 14 + }
+14
.sqlx/query-f4d0d7fbb138a2c3c285d829ffd3a760a5036640291666daf6f51d32ab4f3d2d.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM handle_reservations WHERE handle = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "f4d0d7fbb138a2c3c285d829ffd3a760a5036640291666daf6f51d32ab4f3d2d" 14 + }
+28
.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "password_reset_code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "password_reset_code_expires_at", 14 + "type_info": "Timestamptz" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + true, 24 + true 25 + ] 26 + }, 27 + "hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382" 28 + }
+15
.sqlx/query-ff903cc1839ee69b3c217bc713f9c734fc4a794cefa9f76286facda88bf22f18.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM external_identities\n WHERE id = $1 AND did = $2\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "ff903cc1839ee69b3c217bc713f9c734fc4a794cefa9f76286facda88bf22f18" 15 + }
+84
.sqlx/query-ff93791f03c093deff1fdf4a86989548178bac3cbe6ffa73c22cafab61d05ba4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, provider_email_verified,\n created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "request_uri", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "provider: SsoProviderType", 19 + "type_info": { 20 + "Custom": { 21 + "name": "sso_provider_type", 22 + "kind": { 23 + "Enum": [ 24 + "github", 25 + "discord", 26 + "google", 27 + "gitlab", 28 + "oidc", 29 + "apple" 30 + ] 31 + } 32 + } 33 + } 34 + }, 35 + { 36 + "ordinal": 3, 37 + "name": "provider_user_id", 38 + "type_info": "Text" 39 + }, 40 + { 41 + "ordinal": 4, 42 + "name": "provider_username", 43 + "type_info": "Text" 44 + }, 45 + { 46 + "ordinal": 5, 47 + "name": "provider_email", 48 + "type_info": "Text" 49 + }, 50 + { 51 + "ordinal": 6, 52 + "name": "provider_email_verified", 53 + "type_info": "Bool" 54 + }, 55 + { 56 + "ordinal": 7, 57 + "name": "created_at", 58 + "type_info": "Timestamptz" 59 + }, 60 + { 61 + "ordinal": 8, 62 + "name": "expires_at", 63 + "type_info": "Timestamptz" 64 + } 65 + ], 66 + "parameters": { 67 + "Left": [ 68 + "Text" 69 + ] 70 + }, 71 + "nullable": [ 72 + false, 73 + false, 74 + false, 75 + false, 76 + true, 77 + true, 78 + false, 79 + false, 80 + false 81 + ] 82 + }, 83 + "hash": "ff93791f03c093deff1fdf4a86989548178bac3cbe6ffa73c22cafab61d05ba4" 84 + }
+47
Cargo.lock
··· 3193 3193 ] 3194 3194 3195 3195 [[package]] 3196 + name = "jsonwebtoken" 3197 + version = "10.2.0" 3198 + source = "registry+https://github.com/rust-lang/crates.io-index" 3199 + checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" 3200 + dependencies = [ 3201 + "base64 0.22.1", 3202 + "ed25519-dalek", 3203 + "getrandom 0.2.16", 3204 + "hmac", 3205 + "js-sys", 3206 + "p256 0.13.2", 3207 + "p384", 3208 + "pem", 3209 + "rand 0.8.5", 3210 + "rsa", 3211 + "serde", 3212 + "serde_json", 3213 + "sha2", 3214 + "signature 2.2.0", 3215 + "simple_asn1", 3216 + ] 3217 + 3218 + [[package]] 3196 3219 name = "k256" 3197 3220 version = "0.13.4" 3198 3221 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3884 3907 checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" 3885 3908 3886 3909 [[package]] 3910 + name = "pem" 3911 + version = "3.0.6" 3912 + source = "registry+https://github.com/rust-lang/crates.io-index" 3913 + checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" 3914 + dependencies = [ 3915 + "base64 0.22.1", 3916 + "serde_core", 3917 + ] 3918 + 3919 + [[package]] 3887 3920 name = "pem-rfc7468" 3888 3921 version = "0.7.0" 3889 3922 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5030 5063 checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 5031 5064 5032 5065 [[package]] 5066 + name = "simple_asn1" 5067 + version = "0.6.3" 5068 + source = "registry+https://github.com/rust-lang/crates.io-index" 5069 + checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" 5070 + dependencies = [ 5071 + "num-bigint", 5072 + "num-traits", 5073 + "thiserror 2.0.17", 5074 + "time", 5075 + ] 5076 + 5077 + [[package]] 5033 5078 name = "sketches-ddsketch" 5034 5079 version = "0.3.0" 5035 5080 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6040 6085 dependencies = [ 6041 6086 "aes-gcm", 6042 6087 "anyhow", 6088 + "async-trait", 6043 6089 "aws-config", 6044 6090 "aws-sdk-s3", 6045 6091 "axum", ··· 6067 6113 "iroh-car", 6068 6114 "jacquard-common", 6069 6115 "jacquard-repo", 6116 + "jsonwebtoken", 6070 6117 "k256", 6071 6118 "metrics", 6072 6119 "metrics-exporter-prometheus",
+10 -6
crates/tranquil-db-traits/src/lib.rs
··· 7 7 mod oauth; 8 8 mod repo; 9 9 mod session; 10 + mod sso; 10 11 mod user; 11 12 12 13 pub use backlink::{Backlink, BacklinkRepository}; ··· 40 41 AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem, 41 42 SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, 42 43 }; 44 + pub use sso::{ 45 + ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, 46 + }; 43 47 pub use user::{ 44 48 AccountSearchResult, CompletePasskeySetupInput, CreateAccountError, 45 49 CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput, 46 - CreatePasswordAccountResult, DidWebOverrides, MigrationReactivationError, 47 - MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, 48 - ReactivatedAccountInfo, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, 49 - ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, TotpRecord, User2faStatus, 50 - UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, 51 - UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, 50 + CreatePasswordAccountResult, CreateSsoAccountInput, DidWebOverrides, 51 + MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, 52 + PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput, 53 + RecoverPasskeyAccountResult, ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, 54 + TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, 55 + UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, 52 56 UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle, 53 57 UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, 54 58 UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo,
+176
crates/tranquil-db-traits/src/sso.rs
··· 1 + use async_trait::async_trait; 2 + use chrono::{DateTime, Utc}; 3 + use serde::{Deserialize, Serialize}; 4 + use tranquil_types::Did; 5 + use uuid::Uuid; 6 + 7 + use crate::DbError; 8 + 9 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 10 + #[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")] 11 + pub enum SsoProviderType { 12 + Github, 13 + Discord, 14 + Google, 15 + Gitlab, 16 + Oidc, 17 + Apple, 18 + } 19 + 20 + impl SsoProviderType { 21 + pub fn as_str(&self) -> &'static str { 22 + match self { 23 + Self::Github => "github", 24 + Self::Discord => "discord", 25 + Self::Google => "google", 26 + Self::Gitlab => "gitlab", 27 + Self::Oidc => "oidc", 28 + Self::Apple => "apple", 29 + } 30 + } 31 + 32 + pub fn parse(s: &str) -> Option<Self> { 33 + match s.to_lowercase().as_str() { 34 + "github" => Some(Self::Github), 35 + "discord" => Some(Self::Discord), 36 + "google" => Some(Self::Google), 37 + "gitlab" => Some(Self::Gitlab), 38 + "oidc" => Some(Self::Oidc), 39 + "apple" => Some(Self::Apple), 40 + _ => None, 41 + } 42 + } 43 + 44 + pub fn display_name(&self) -> &'static str { 45 + match self { 46 + Self::Github => "GitHub", 47 + Self::Discord => "Discord", 48 + Self::Google => "Google", 49 + Self::Gitlab => "GitLab", 50 + Self::Oidc => "SSO", 51 + Self::Apple => "Apple", 52 + } 53 + } 54 + 55 + pub fn icon_name(&self) -> &'static str { 56 + match self { 57 + Self::Github => "github", 58 + Self::Discord => "discord", 59 + Self::Google => "google", 60 + Self::Gitlab => "gitlab", 61 + Self::Oidc => "oidc", 62 + Self::Apple => "apple", 63 + } 64 + } 65 + } 66 + 67 + #[derive(Debug, Clone)] 68 + pub struct ExternalIdentity { 69 + pub id: Uuid, 70 + pub did: Did, 71 + pub provider: SsoProviderType, 72 + pub provider_user_id: String, 73 + pub provider_username: Option<String>, 74 + pub provider_email: Option<String>, 75 + pub created_at: DateTime<Utc>, 76 + pub updated_at: DateTime<Utc>, 77 + pub last_login_at: Option<DateTime<Utc>>, 78 + } 79 + 80 + #[derive(Debug, Clone)] 81 + pub struct SsoAuthState { 82 + pub state: String, 83 + pub request_uri: String, 84 + pub provider: SsoProviderType, 85 + pub action: String, 86 + pub nonce: Option<String>, 87 + pub code_verifier: Option<String>, 88 + pub did: Option<Did>, 89 + pub created_at: DateTime<Utc>, 90 + pub expires_at: DateTime<Utc>, 91 + } 92 + 93 + #[derive(Debug, Clone)] 94 + pub struct SsoPendingRegistration { 95 + pub token: String, 96 + pub request_uri: String, 97 + pub provider: SsoProviderType, 98 + pub provider_user_id: String, 99 + pub provider_username: Option<String>, 100 + pub provider_email: Option<String>, 101 + pub provider_email_verified: bool, 102 + pub created_at: DateTime<Utc>, 103 + pub expires_at: DateTime<Utc>, 104 + } 105 + 106 + #[async_trait] 107 + pub trait SsoRepository: Send + Sync { 108 + async fn create_external_identity( 109 + &self, 110 + did: &Did, 111 + provider: SsoProviderType, 112 + provider_user_id: &str, 113 + provider_username: Option<&str>, 114 + provider_email: Option<&str>, 115 + ) -> Result<Uuid, DbError>; 116 + 117 + async fn get_external_identity_by_provider( 118 + &self, 119 + provider: SsoProviderType, 120 + provider_user_id: &str, 121 + ) -> Result<Option<ExternalIdentity>, DbError>; 122 + 123 + async fn get_external_identities_by_did( 124 + &self, 125 + did: &Did, 126 + ) -> Result<Vec<ExternalIdentity>, DbError>; 127 + 128 + async fn update_external_identity_login( 129 + &self, 130 + id: Uuid, 131 + provider_username: Option<&str>, 132 + provider_email: Option<&str>, 133 + ) -> Result<(), DbError>; 134 + 135 + async fn delete_external_identity(&self, id: Uuid, did: &Did) -> Result<bool, DbError>; 136 + 137 + #[allow(clippy::too_many_arguments)] 138 + async fn create_sso_auth_state( 139 + &self, 140 + state: &str, 141 + request_uri: &str, 142 + provider: SsoProviderType, 143 + action: &str, 144 + nonce: Option<&str>, 145 + code_verifier: Option<&str>, 146 + did: Option<&Did>, 147 + ) -> Result<(), DbError>; 148 + 149 + async fn consume_sso_auth_state(&self, state: &str) -> Result<Option<SsoAuthState>, DbError>; 150 + 151 + async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError>; 152 + 153 + #[allow(clippy::too_many_arguments)] 154 + async fn create_pending_registration( 155 + &self, 156 + token: &str, 157 + request_uri: &str, 158 + provider: SsoProviderType, 159 + provider_user_id: &str, 160 + provider_username: Option<&str>, 161 + provider_email: Option<&str>, 162 + provider_email_verified: bool, 163 + ) -> Result<(), DbError>; 164 + 165 + async fn get_pending_registration( 166 + &self, 167 + token: &str, 168 + ) -> Result<Option<SsoPendingRegistration>, DbError>; 169 + 170 + async fn consume_pending_registration( 171 + &self, 172 + token: &str, 173 + ) -> Result<Option<SsoPendingRegistration>, DbError>; 174 + 175 + async fn cleanup_expired_pending_registrations(&self) -> Result<u64, DbError>; 176 + }
+37 -1
crates/tranquil-db-traits/src/user.rs
··· 3 3 use tranquil_types::{Did, Handle}; 4 4 use uuid::Uuid; 5 5 6 - use crate::{CommsChannel, DbError}; 6 + use crate::{CommsChannel, DbError, SsoProviderType}; 7 7 8 8 #[derive(Debug, Clone)] 9 9 pub struct UserRow { ··· 480 480 input: &CreatePasskeyAccountInput, 481 481 ) -> Result<CreatePasswordAccountResult, CreateAccountError>; 482 482 483 + async fn create_sso_account( 484 + &self, 485 + input: &CreateSsoAccountInput, 486 + ) -> Result<CreatePasswordAccountResult, CreateAccountError>; 487 + 483 488 async fn reactivate_migration_account( 484 489 &self, 485 490 input: &MigrationReactivationInput, ··· 489 494 &self, 490 495 handle: &Handle, 491 496 ) -> Result<bool, DbError>; 497 + 498 + async fn reserve_handle(&self, handle: &Handle, reserved_by: &str) -> Result<bool, DbError>; 499 + 500 + async fn release_handle_reservation(&self, handle: &Handle) -> Result<(), DbError>; 501 + 502 + async fn cleanup_expired_handle_reservations(&self) -> Result<u64, DbError>; 492 503 493 504 async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError>; 494 505 ··· 842 853 HandleTaken, 843 854 EmailTaken, 844 855 DidExists, 856 + InvalidToken, 845 857 Database(String), 846 858 } 847 859 ··· 880 892 pub genesis_block_cids: Vec<Vec<u8>>, 881 893 pub invite_code: Option<String>, 882 894 pub birthdate_pref: Option<serde_json::Value>, 895 + } 896 + 897 + #[derive(Debug, Clone)] 898 + pub struct CreateSsoAccountInput { 899 + pub handle: Handle, 900 + pub email: Option<String>, 901 + pub did: Did, 902 + pub preferred_comms_channel: CommsChannel, 903 + pub discord_id: Option<String>, 904 + pub telegram_username: Option<String>, 905 + pub signal_number: Option<String>, 906 + pub encrypted_key_bytes: Vec<u8>, 907 + pub encryption_version: i32, 908 + pub commit_cid: String, 909 + pub repo_rev: String, 910 + pub genesis_block_cids: Vec<Vec<u8>>, 911 + pub invite_code: Option<String>, 912 + pub birthdate_pref: Option<serde_json::Value>, 913 + pub sso_provider: SsoProviderType, 914 + pub sso_provider_user_id: String, 915 + pub sso_provider_username: Option<String>, 916 + pub sso_provider_email: Option<String>, 917 + pub sso_provider_email_verified: bool, 918 + pub pending_registration_token: String, 883 919 } 884 920 885 921 #[derive(Debug, Clone)]
+6 -1
crates/tranquil-db/src/postgres/mod.rs
··· 7 7 mod oauth; 8 8 mod repo; 9 9 mod session; 10 + mod sso; 10 11 mod user; 11 12 12 13 use sqlx::PgPool; ··· 21 22 pub use oauth::PostgresOAuthRepository; 22 23 pub use repo::PostgresRepoRepository; 23 24 pub use session::PostgresSessionRepository; 25 + pub use sso::PostgresSsoRepository; 24 26 use tranquil_db_traits::{ 25 27 BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository, 26 - OAuthRepository, RepoEventNotifier, RepoRepository, SessionRepository, UserRepository, 28 + OAuthRepository, RepoEventNotifier, RepoRepository, SessionRepository, SsoRepository, 29 + UserRepository, 27 30 }; 28 31 pub use user::PostgresUserRepository; 29 32 ··· 38 41 pub infra: Arc<dyn InfraRepository>, 39 42 pub backup: Arc<dyn BackupRepository>, 40 43 pub backlink: Arc<dyn BacklinkRepository>, 44 + pub sso: Arc<dyn SsoRepository>, 41 45 pub event_notifier: Arc<dyn RepoEventNotifier>, 42 46 } 43 47 ··· 54 58 infra: Arc::new(PostgresInfraRepository::new(pool.clone())), 55 59 backup: Arc::new(PostgresBackupRepository::new(pool.clone())), 56 60 backlink: Arc::new(PostgresBacklinkRepository::new(pool.clone())), 61 + sso: Arc::new(PostgresSsoRepository::new(pool.clone())), 57 62 event_notifier: Arc::new(PostgresRepoEventNotifier::new(pool)), 58 63 } 59 64 }
+337
crates/tranquil-db/src/postgres/sso.rs
··· 1 + use async_trait::async_trait; 2 + use chrono::Utc; 3 + use sqlx::PgPool; 4 + use tranquil_db_traits::{ 5 + DbError, ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, 6 + }; 7 + use tranquil_types::Did; 8 + use uuid::Uuid; 9 + 10 + use super::user::map_sqlx_error; 11 + 12 + pub struct PostgresSsoRepository { 13 + pool: PgPool, 14 + } 15 + 16 + impl PostgresSsoRepository { 17 + pub fn new(pool: PgPool) -> Self { 18 + Self { pool } 19 + } 20 + } 21 + 22 + #[async_trait] 23 + impl SsoRepository for PostgresSsoRepository { 24 + async fn create_external_identity( 25 + &self, 26 + did: &Did, 27 + provider: SsoProviderType, 28 + provider_user_id: &str, 29 + provider_username: Option<&str>, 30 + provider_email: Option<&str>, 31 + ) -> Result<Uuid, DbError> { 32 + let id = sqlx::query_scalar!( 33 + r#" 34 + INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email) 35 + VALUES ($1, $2, $3, $4, $5) 36 + RETURNING id 37 + "#, 38 + did.as_str(), 39 + provider as SsoProviderType, 40 + provider_user_id, 41 + provider_username, 42 + provider_email, 43 + ) 44 + .fetch_one(&self.pool) 45 + .await 46 + .map_err(map_sqlx_error)?; 47 + 48 + Ok(id) 49 + } 50 + 51 + async fn get_external_identity_by_provider( 52 + &self, 53 + provider: SsoProviderType, 54 + provider_user_id: &str, 55 + ) -> Result<Option<ExternalIdentity>, DbError> { 56 + let row = sqlx::query!( 57 + r#" 58 + SELECT id, did, provider as "provider: SsoProviderType", provider_user_id, 59 + provider_username, provider_email, created_at, updated_at, last_login_at 60 + FROM external_identities 61 + WHERE provider = $1 AND provider_user_id = $2 62 + "#, 63 + provider as SsoProviderType, 64 + provider_user_id, 65 + ) 66 + .fetch_optional(&self.pool) 67 + .await 68 + .map_err(map_sqlx_error)?; 69 + 70 + Ok(row.map(|r| ExternalIdentity { 71 + id: r.id, 72 + did: Did::new_unchecked(&r.did), 73 + provider: r.provider, 74 + provider_user_id: r.provider_user_id, 75 + provider_username: r.provider_username, 76 + provider_email: r.provider_email, 77 + created_at: r.created_at, 78 + updated_at: r.updated_at, 79 + last_login_at: r.last_login_at, 80 + })) 81 + } 82 + 83 + async fn get_external_identities_by_did( 84 + &self, 85 + did: &Did, 86 + ) -> Result<Vec<ExternalIdentity>, DbError> { 87 + let rows = sqlx::query!( 88 + r#" 89 + SELECT id, did, provider as "provider: SsoProviderType", provider_user_id, 90 + provider_username, provider_email, created_at, updated_at, last_login_at 91 + FROM external_identities 92 + WHERE did = $1 93 + ORDER BY created_at ASC 94 + "#, 95 + did.as_str(), 96 + ) 97 + .fetch_all(&self.pool) 98 + .await 99 + .map_err(map_sqlx_error)?; 100 + 101 + Ok(rows 102 + .into_iter() 103 + .map(|r| ExternalIdentity { 104 + id: r.id, 105 + did: Did::new_unchecked(&r.did), 106 + provider: r.provider, 107 + provider_user_id: r.provider_user_id, 108 + provider_username: r.provider_username, 109 + provider_email: r.provider_email, 110 + created_at: r.created_at, 111 + updated_at: r.updated_at, 112 + last_login_at: r.last_login_at, 113 + }) 114 + .collect()) 115 + } 116 + 117 + async fn update_external_identity_login( 118 + &self, 119 + id: Uuid, 120 + provider_username: Option<&str>, 121 + provider_email: Option<&str>, 122 + ) -> Result<(), DbError> { 123 + sqlx::query!( 124 + r#" 125 + UPDATE external_identities 126 + SET provider_username = COALESCE($2, provider_username), 127 + provider_email = COALESCE($3, provider_email), 128 + last_login_at = NOW(), 129 + updated_at = NOW() 130 + WHERE id = $1 131 + "#, 132 + id, 133 + provider_username, 134 + provider_email, 135 + ) 136 + .execute(&self.pool) 137 + .await 138 + .map_err(map_sqlx_error)?; 139 + 140 + Ok(()) 141 + } 142 + 143 + async fn delete_external_identity(&self, id: Uuid, did: &Did) -> Result<bool, DbError> { 144 + let result = sqlx::query!( 145 + r#" 146 + DELETE FROM external_identities 147 + WHERE id = $1 AND did = $2 148 + "#, 149 + id, 150 + did.as_str(), 151 + ) 152 + .execute(&self.pool) 153 + .await 154 + .map_err(map_sqlx_error)?; 155 + 156 + Ok(result.rows_affected() > 0) 157 + } 158 + 159 + async fn create_sso_auth_state( 160 + &self, 161 + state: &str, 162 + request_uri: &str, 163 + provider: SsoProviderType, 164 + action: &str, 165 + nonce: Option<&str>, 166 + code_verifier: Option<&str>, 167 + did: Option<&Did>, 168 + ) -> Result<(), DbError> { 169 + sqlx::query!( 170 + r#" 171 + INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier, did) 172 + VALUES ($1, $2, $3, $4, $5, $6, $7) 173 + "#, 174 + state, 175 + request_uri, 176 + provider as SsoProviderType, 177 + action, 178 + nonce, 179 + code_verifier, 180 + did.map(|d| d.as_str()), 181 + ) 182 + .execute(&self.pool) 183 + .await 184 + .map_err(map_sqlx_error)?; 185 + 186 + Ok(()) 187 + } 188 + 189 + async fn consume_sso_auth_state(&self, state: &str) -> Result<Option<SsoAuthState>, DbError> { 190 + let row = sqlx::query!( 191 + r#" 192 + DELETE FROM sso_auth_state 193 + WHERE state = $1 AND expires_at > NOW() 194 + RETURNING state, request_uri, provider as "provider: SsoProviderType", action, 195 + nonce, code_verifier, did, created_at, expires_at 196 + "#, 197 + state, 198 + ) 199 + .fetch_optional(&self.pool) 200 + .await 201 + .map_err(map_sqlx_error)?; 202 + 203 + Ok(row.map(|r| SsoAuthState { 204 + state: r.state, 205 + request_uri: r.request_uri, 206 + provider: r.provider, 207 + action: r.action, 208 + nonce: r.nonce, 209 + code_verifier: r.code_verifier, 210 + did: r.did.map(|d| Did::new_unchecked(&d)), 211 + created_at: r.created_at, 212 + expires_at: r.expires_at, 213 + })) 214 + } 215 + 216 + async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError> { 217 + let result = sqlx::query!( 218 + r#" 219 + DELETE FROM sso_auth_state 220 + WHERE expires_at < $1 221 + "#, 222 + Utc::now(), 223 + ) 224 + .execute(&self.pool) 225 + .await 226 + .map_err(map_sqlx_error)?; 227 + 228 + Ok(result.rows_affected()) 229 + } 230 + 231 + async fn create_pending_registration( 232 + &self, 233 + token: &str, 234 + request_uri: &str, 235 + provider: SsoProviderType, 236 + provider_user_id: &str, 237 + provider_username: Option<&str>, 238 + provider_email: Option<&str>, 239 + provider_email_verified: bool, 240 + ) -> Result<(), DbError> { 241 + sqlx::query!( 242 + r#" 243 + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified) 244 + VALUES ($1, $2, $3, $4, $5, $6, $7) 245 + "#, 246 + token, 247 + request_uri, 248 + provider as SsoProviderType, 249 + provider_user_id, 250 + provider_username, 251 + provider_email, 252 + provider_email_verified, 253 + ) 254 + .execute(&self.pool) 255 + .await 256 + .map_err(map_sqlx_error)?; 257 + 258 + Ok(()) 259 + } 260 + 261 + async fn get_pending_registration( 262 + &self, 263 + token: &str, 264 + ) -> Result<Option<SsoPendingRegistration>, DbError> { 265 + let row = sqlx::query!( 266 + r#" 267 + SELECT token, request_uri, provider as "provider: SsoProviderType", 268 + provider_user_id, provider_username, provider_email, provider_email_verified, 269 + created_at, expires_at 270 + FROM sso_pending_registration 271 + WHERE token = $1 AND expires_at > NOW() 272 + "#, 273 + token, 274 + ) 275 + .fetch_optional(&self.pool) 276 + .await 277 + .map_err(map_sqlx_error)?; 278 + 279 + Ok(row.map(|r| SsoPendingRegistration { 280 + token: r.token, 281 + request_uri: r.request_uri, 282 + provider: r.provider, 283 + provider_user_id: r.provider_user_id, 284 + provider_username: r.provider_username, 285 + provider_email: r.provider_email, 286 + provider_email_verified: r.provider_email_verified, 287 + created_at: r.created_at, 288 + expires_at: r.expires_at, 289 + })) 290 + } 291 + 292 + async fn consume_pending_registration( 293 + &self, 294 + token: &str, 295 + ) -> Result<Option<SsoPendingRegistration>, DbError> { 296 + let row = sqlx::query!( 297 + r#" 298 + DELETE FROM sso_pending_registration 299 + WHERE token = $1 AND expires_at > NOW() 300 + RETURNING token, request_uri, provider as "provider: SsoProviderType", 301 + provider_user_id, provider_username, provider_email, provider_email_verified, 302 + created_at, expires_at 303 + "#, 304 + token, 305 + ) 306 + .fetch_optional(&self.pool) 307 + .await 308 + .map_err(map_sqlx_error)?; 309 + 310 + Ok(row.map(|r| SsoPendingRegistration { 311 + token: r.token, 312 + request_uri: r.request_uri, 313 + provider: r.provider, 314 + provider_user_id: r.provider_user_id, 315 + provider_username: r.provider_username, 316 + provider_email: r.provider_email, 317 + provider_email_verified: r.provider_email_verified, 318 + created_at: r.created_at, 319 + expires_at: r.expires_at, 320 + })) 321 + } 322 + 323 + async fn cleanup_expired_pending_registrations(&self) -> Result<u64, DbError> { 324 + let result = sqlx::query!( 325 + r#" 326 + DELETE FROM sso_pending_registration 327 + WHERE expires_at < $1 328 + "#, 329 + Utc::now(), 330 + ) 331 + .execute(&self.pool) 332 + .await 333 + .map_err(map_sqlx_error)?; 334 + 335 + Ok(result.rows_affected()) 336 + } 337 + }
+230 -9
crates/tranquil-db/src/postgres/user.rs
··· 6 6 7 7 use tranquil_db_traits::{ 8 8 AccountSearchResult, CommsChannel, DbError, DidWebOverrides, NotificationPrefs, 9 - OAuthTokenWithUser, PasswordResetResult, StoredBackupCode, StoredPasskey, TotpRecord, 10 - User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, 11 - UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, 9 + OAuthTokenWithUser, PasswordResetResult, SsoProviderType, StoredBackupCode, StoredPasskey, 10 + TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, 11 + UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, 12 12 UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle, 13 13 UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, 14 14 UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, ··· 2671 2671 }) 2672 2672 } 2673 2673 2674 + async fn create_sso_account( 2675 + &self, 2676 + input: &tranquil_db_traits::CreateSsoAccountInput, 2677 + ) -> Result< 2678 + tranquil_db_traits::CreatePasswordAccountResult, 2679 + tranquil_db_traits::CreateAccountError, 2680 + > { 2681 + let mut tx = self.pool.begin().await.map_err(|e: sqlx::Error| { 2682 + tranquil_db_traits::CreateAccountError::Database(e.to_string()) 2683 + })?; 2684 + 2685 + let token_consumed: Option<(String,)> = sqlx::query_as( 2686 + r#" 2687 + DELETE FROM sso_pending_registration 2688 + WHERE token = $1 AND expires_at > NOW() 2689 + RETURNING token 2690 + "#, 2691 + ) 2692 + .bind(&input.pending_registration_token) 2693 + .fetch_optional(&mut *tx) 2694 + .await 2695 + .map_err(|e: sqlx::Error| { 2696 + tranquil_db_traits::CreateAccountError::Database(e.to_string()) 2697 + })?; 2698 + 2699 + if token_consumed.is_none() { 2700 + return Err(tranquil_db_traits::CreateAccountError::InvalidToken); 2701 + } 2702 + 2703 + let is_first_user: bool = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users") 2704 + .fetch_one(&mut *tx) 2705 + .await 2706 + .map(|c| c.unwrap_or(0) == 0) 2707 + .unwrap_or(false); 2708 + 2709 + let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( 2710 + r#"INSERT INTO users ( 2711 + handle, email, did, password_hash, password_required, 2712 + preferred_comms_channel, discord_id, telegram_username, signal_number, 2713 + is_admin 2714 + ) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8) RETURNING id"#, 2715 + ) 2716 + .bind(input.handle.as_str()) 2717 + .bind(&input.email) 2718 + .bind(input.did.as_str()) 2719 + .bind(input.preferred_comms_channel) 2720 + .bind(&input.discord_id) 2721 + .bind(&input.telegram_username) 2722 + .bind(&input.signal_number) 2723 + .bind(is_first_user) 2724 + .fetch_one(&mut *tx) 2725 + .await; 2726 + 2727 + let user_id = match user_insert { 2728 + Ok((id,)) => id, 2729 + Err(e) => { 2730 + if let Some(db_err) = e.as_database_error() 2731 + && db_err.code().as_deref() == Some("23505") 2732 + { 2733 + let constraint = db_err.constraint().unwrap_or(""); 2734 + if constraint.contains("handle") { 2735 + return Err(tranquil_db_traits::CreateAccountError::HandleTaken); 2736 + } else if constraint.contains("email") { 2737 + return Err(tranquil_db_traits::CreateAccountError::EmailTaken); 2738 + } 2739 + } 2740 + return Err(tranquil_db_traits::CreateAccountError::Database( 2741 + e.to_string(), 2742 + )); 2743 + } 2744 + }; 2745 + 2746 + sqlx::query!( 2747 + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", 2748 + user_id, 2749 + &input.encrypted_key_bytes[..], 2750 + input.encryption_version 2751 + ) 2752 + .execute(&mut *tx) 2753 + .await 2754 + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; 2755 + 2756 + sqlx::query!( 2757 + "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)", 2758 + user_id, 2759 + input.commit_cid, 2760 + input.repo_rev 2761 + ) 2762 + .execute(&mut *tx) 2763 + .await 2764 + .map_err(|e: sqlx::Error| { 2765 + tranquil_db_traits::CreateAccountError::Database(e.to_string()) 2766 + })?; 2767 + 2768 + sqlx::query( 2769 + r#" 2770 + INSERT INTO user_blocks (user_id, block_cid, repo_rev) 2771 + SELECT $1, block_cid, $3 FROM UNNEST($2::bytea[]) AS t(block_cid) 2772 + ON CONFLICT (user_id, block_cid) DO NOTHING 2773 + "#, 2774 + ) 2775 + .bind(user_id) 2776 + .bind(&input.genesis_block_cids) 2777 + .bind(&input.repo_rev) 2778 + .execute(&mut *tx) 2779 + .await 2780 + .map_err(|e: sqlx::Error| { 2781 + tranquil_db_traits::CreateAccountError::Database(e.to_string()) 2782 + })?; 2783 + 2784 + if let Some(code) = &input.invite_code { 2785 + let _ = sqlx::query!( 2786 + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 2787 + code 2788 + ) 2789 + .execute(&mut *tx) 2790 + .await; 2791 + 2792 + let _ = sqlx::query!( 2793 + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 2794 + code, 2795 + user_id 2796 + ) 2797 + .execute(&mut *tx) 2798 + .await; 2799 + } 2800 + 2801 + if let Some(birthdate_pref) = &input.birthdate_pref { 2802 + let _ = sqlx::query!( 2803 + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) 2804 + ON CONFLICT (user_id, name) DO NOTHING", 2805 + user_id, 2806 + "app.bsky.actor.defs#personalDetailsPref", 2807 + birthdate_pref 2808 + ) 2809 + .execute(&mut *tx) 2810 + .await; 2811 + } 2812 + 2813 + sqlx::query!( 2814 + r#" 2815 + INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email, provider_email_verified) 2816 + VALUES ($1, $2, $3, $4, $5, $6) 2817 + "#, 2818 + input.did.as_str(), 2819 + input.sso_provider as SsoProviderType, 2820 + &input.sso_provider_user_id, 2821 + input.sso_provider_username.as_deref(), 2822 + input.sso_provider_email.as_deref(), 2823 + input.sso_provider_email_verified, 2824 + ) 2825 + .execute(&mut *tx) 2826 + .await 2827 + .map_err(|e: sqlx::Error| { 2828 + tranquil_db_traits::CreateAccountError::Database(e.to_string()) 2829 + })?; 2830 + 2831 + tx.commit().await.map_err(|e: sqlx::Error| { 2832 + tranquil_db_traits::CreateAccountError::Database(e.to_string()) 2833 + })?; 2834 + 2835 + Ok(tranquil_db_traits::CreatePasswordAccountResult { 2836 + user_id, 2837 + is_admin: is_first_user, 2838 + }) 2839 + } 2840 + 2674 2841 async fn reactivate_migration_account( 2675 2842 &self, 2676 2843 input: &tranquil_db_traits::MigrationReactivationInput, ··· 2744 2911 &self, 2745 2912 handle: &Handle, 2746 2913 ) -> Result<bool, DbError> { 2747 - let exists: Option<(i32,)> = 2748 - sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL") 2749 - .bind(handle.as_str()) 2750 - .fetch_optional(&self.pool) 2751 - .await 2752 - .map_err(map_sqlx_error)?; 2914 + let exists: Option<(i32,)> = sqlx::query_as( 2915 + r#" 2916 + SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL 2917 + UNION ALL 2918 + SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW() 2919 + LIMIT 1 2920 + "#, 2921 + ) 2922 + .bind(handle.as_str()) 2923 + .fetch_optional(&self.pool) 2924 + .await 2925 + .map_err(map_sqlx_error)?; 2753 2926 2754 2927 Ok(exists.is_none()) 2928 + } 2929 + 2930 + async fn reserve_handle(&self, handle: &Handle, reserved_by: &str) -> Result<bool, DbError> { 2931 + sqlx::query!("DELETE FROM handle_reservations WHERE expires_at <= NOW()") 2932 + .execute(&self.pool) 2933 + .await 2934 + .map_err(map_sqlx_error)?; 2935 + 2936 + let result = sqlx::query!( 2937 + r#" 2938 + INSERT INTO handle_reservations (handle, reserved_by) 2939 + SELECT $1, $2 2940 + WHERE NOT EXISTS ( 2941 + SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL 2942 + ) 2943 + AND NOT EXISTS ( 2944 + SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW() 2945 + ) 2946 + "#, 2947 + handle.as_str(), 2948 + reserved_by, 2949 + ) 2950 + .execute(&self.pool) 2951 + .await 2952 + .map_err(map_sqlx_error)?; 2953 + 2954 + Ok(result.rows_affected() > 0) 2955 + } 2956 + 2957 + async fn release_handle_reservation(&self, handle: &Handle) -> Result<(), DbError> { 2958 + sqlx::query!( 2959 + "DELETE FROM handle_reservations WHERE handle = $1", 2960 + handle.as_str() 2961 + ) 2962 + .execute(&self.pool) 2963 + .await 2964 + .map_err(map_sqlx_error)?; 2965 + 2966 + Ok(()) 2967 + } 2968 + 2969 + async fn cleanup_expired_handle_reservations(&self) -> Result<u64, DbError> { 2970 + let result = sqlx::query!("DELETE FROM handle_reservations WHERE expires_at <= NOW()") 2971 + .execute(&self.pool) 2972 + .await 2973 + .map_err(map_sqlx_error)?; 2974 + 2975 + Ok(result.rows_affected()) 2755 2976 } 2756 2977 2757 2978 async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError> {
+2
crates/tranquil-pds/Cargo.toml
··· 18 18 tranquil-db-traits = { workspace = true } 19 19 20 20 aes-gcm = { workspace = true } 21 + async-trait = { workspace = true } 21 22 backon = { workspace = true } 22 23 anyhow = { workspace = true } 23 24 aws-config = { workspace = true } ··· 44 45 iroh-car = { workspace = true } 45 46 jacquard-common = { workspace = true } 46 47 jacquard-repo = { workspace = true } 48 + jsonwebtoken = { workspace = true } 47 49 k256 = { workspace = true } 48 50 metrics = { workspace = true } 49 51 metrics-exporter-prometheus = { workspace = true }
+12
crates/tranquil-pds/build.rs
··· 1 + use std::process::Command; 2 + 3 + fn main() { 4 + let timestamp = Command::new("date") 5 + .arg("+%Y-%m-%d %H:%M:%S UTC") 6 + .output() 7 + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) 8 + .unwrap_or_else(|_| "unknown".to_string()); 9 + 10 + println!("cargo:rustc-env=BUILD_TIMESTAMP={}", timestamp); 11 + println!("cargo:rerun-if-changed=build.rs"); 12 + }
+42 -2
crates/tranquil-pds/src/api/error.rs
··· 107 107 error: Option<String>, 108 108 message: Option<String>, 109 109 }, 110 + SsoProviderNotFound, 111 + SsoProviderNotEnabled, 112 + SsoInvalidAction, 113 + SsoNotAuthenticated, 114 + SsoSessionExpired, 115 + SsoAlreadyLinked, 116 + SsoLinkNotFound, 110 117 } 111 118 112 119 impl ApiError { ··· 197 204 | Self::InvalidVerificationChannel 198 205 | Self::SelfHostedDidWebDisabled 199 206 | Self::AccountAlreadyExists 200 - | Self::TokenRequired => StatusCode::BAD_REQUEST, 201 - Self::PasskeyNotFound => StatusCode::NOT_FOUND, 207 + | Self::TokenRequired 208 + | Self::SsoProviderNotFound 209 + | Self::SsoProviderNotEnabled 210 + | Self::SsoInvalidAction 211 + | Self::SsoNotAuthenticated 212 + | Self::SsoSessionExpired 213 + | Self::SsoAlreadyLinked => StatusCode::BAD_REQUEST, 214 + Self::PasskeyNotFound | Self::SsoLinkNotFound => StatusCode::NOT_FOUND, 202 215 } 203 216 } 204 217 fn error_name(&self) -> Cow<'static, str> { ··· 293 306 Self::AccountAlreadyExists => Cow::Borrowed("AccountAlreadyExists"), 294 307 Self::HandleNotFound => Cow::Borrowed("HandleNotFound"), 295 308 Self::SubjectNotFound => Cow::Borrowed("SubjectNotFound"), 309 + Self::SsoProviderNotFound => Cow::Borrowed("SsoProviderNotFound"), 310 + Self::SsoProviderNotEnabled => Cow::Borrowed("SsoProviderNotEnabled"), 311 + Self::SsoInvalidAction => Cow::Borrowed("SsoInvalidAction"), 312 + Self::SsoNotAuthenticated => Cow::Borrowed("SsoNotAuthenticated"), 313 + Self::SsoSessionExpired => Cow::Borrowed("SsoSessionExpired"), 314 + Self::SsoAlreadyLinked => Cow::Borrowed("SsoAlreadyLinked"), 315 + Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"), 296 316 } 297 317 } 298 318 fn message(&self) -> Option<String> { ··· 392 412 Self::AccountAlreadyExists => Some("Account already exists".to_string()), 393 413 Self::HandleNotFound => Some("Unable to resolve handle".to_string()), 394 414 Self::SubjectNotFound => Some("Subject not found".to_string()), 415 + Self::SsoProviderNotFound => Some("Unknown SSO provider".to_string()), 416 + Self::SsoProviderNotEnabled => Some("SSO provider is not enabled".to_string()), 417 + Self::SsoInvalidAction => { 418 + Some("Action must be login, link, or register".to_string()) 419 + } 420 + Self::SsoNotAuthenticated => { 421 + Some("Must be authenticated to link SSO account".to_string()) 422 + } 423 + Self::SsoSessionExpired => Some("SSO session expired or invalid".to_string()), 424 + Self::SsoAlreadyLinked => { 425 + Some("This SSO account is already linked to a different user".to_string()) 426 + } 427 + Self::SsoLinkNotFound => Some("Linked account not found".to_string()), 395 428 Self::IdentifierMismatch => { 396 429 Some("The identifier does not match the verification token".to_string()) 397 430 } ··· 462 495 463 496 impl From<sqlx::Error> for ApiError { 464 497 fn from(e: sqlx::Error) -> Self { 498 + tracing::error!("Database error: {:?}", e); 499 + Self::DatabaseError 500 + } 501 + } 502 + 503 + impl From<tranquil_db_traits::DbError> for ApiError { 504 + fn from(e: tranquil_db_traits::DbError) -> Self { 465 505 tracing::error!("Database error: {:?}", e); 466 506 Self::DatabaseError 467 507 }
+4 -1
crates/tranquil-pds/src/api/server/account_status.rs
··· 428 428 let _ = state.cache.delete(&format!("plc:doc:{}", did)).await; 429 429 let _ = state.cache.delete(&format!("plc:data:{}", did)).await; 430 430 if state.did_resolver.refresh_did(did.as_str()).await.is_none() { 431 - warn!("[MIGRATION] activateAccount: Failed to refresh DID cache for {}", did); 431 + warn!( 432 + "[MIGRATION] activateAccount: Failed to refresh DID cache for {}", 433 + did 434 + ); 432 435 } 433 436 info!( 434 437 "[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}",
+232 -23
crates/tranquil-pds/src/api/server/email.rs
··· 7 7 extract::State, 8 8 response::{IntoResponse, Response}, 9 9 }; 10 - use serde::Deserialize; 10 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 11 + use serde::{Deserialize, Serialize}; 11 12 use serde_json::json; 13 + use sha2::{Digest, Sha256}; 14 + use std::time::Duration; 15 + use subtle::ConstantTimeEq; 12 16 use tracing::{error, info, warn}; 13 17 18 + const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60); 19 + 20 + fn email_update_cache_key(did: &str) -> String { 21 + format!("email_update:{}", did) 22 + } 23 + 24 + fn hash_token(token: &str) -> String { 25 + let mut hasher = Sha256::new(); 26 + hasher.update(token.as_bytes()); 27 + URL_SAFE_NO_PAD.encode(hasher.finalize()) 28 + } 29 + 30 + #[derive(Serialize, Deserialize)] 31 + struct PendingEmailUpdate { 32 + new_email: String, 33 + token_hash: String, 34 + authorized: bool, 35 + } 36 + 37 + #[derive(Deserialize)] 38 + #[serde(rename_all = "camelCase")] 39 + pub struct RequestEmailUpdateInput { 40 + #[serde(default)] 41 + pub new_email: Option<String>, 42 + } 43 + 14 44 pub async fn request_email_update( 15 45 State(state): State<AppState>, 16 46 headers: axum::http::HeaderMap, 17 47 auth: BearerAuth, 48 + input: Option<Json<RequestEmailUpdateInput>>, 18 49 ) -> Response { 19 50 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 20 51 if !state ··· 60 91 ); 61 92 let formatted_code = crate::auth::verification_token::format_token_for_display(&code); 62 93 94 + if let Some(Json(ref inp)) = input 95 + && let Some(ref new_email) = inp.new_email { 96 + let new_email = new_email.trim().to_lowercase(); 97 + if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) { 98 + let pending = PendingEmailUpdate { 99 + new_email, 100 + token_hash: hash_token(&code), 101 + authorized: false, 102 + }; 103 + if let Ok(json) = serde_json::to_string(&pending) { 104 + let cache_key = email_update_cache_key(&auth.0.did); 105 + if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { 106 + warn!("Failed to cache pending email update: {:?}", e); 107 + } 108 + } 109 + } 110 + } 111 + 63 112 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 64 113 if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token( 65 114 state.user_repo.as_ref(), 66 115 state.infra_repo.as_ref(), 67 116 user.id, 117 + &code, 68 118 &formatted_code, 69 119 &hostname, 70 120 ) ··· 223 273 } 224 274 225 275 if email_verified { 226 - let Some(ref t) = input.token else { 227 - return ApiError::TokenRequired.into_response(); 228 - }; 229 - let confirmation_token = crate::auth::verification_token::normalize_token_input(t.trim()); 276 + let mut authorized_via_link = false; 230 277 231 - let current_email_lower = current_email 232 - .as_ref() 233 - .map(|e| e.to_lowercase()) 234 - .unwrap_or_default(); 278 + let cache_key = email_update_cache_key(did); 279 + if let Some(pending_json) = state.cache.get(&cache_key).await 280 + && let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json) 281 + && pending.authorized && pending.new_email == new_email { 282 + authorized_via_link = true; 283 + let _ = state.cache.delete(&cache_key).await; 284 + info!(did = %did, "Email update completed via link authorization"); 285 + } 235 286 236 - let verified = crate::auth::verification_token::verify_channel_update_token( 237 - &confirmation_token, 238 - "email_update", 239 - &current_email_lower, 240 - ); 287 + if !authorized_via_link { 288 + let Some(ref t) = input.token else { 289 + return ApiError::TokenRequired.into_response(); 290 + }; 291 + let confirmation_token = 292 + crate::auth::verification_token::normalize_token_input(t.trim()); 241 293 242 - match verified { 243 - Ok(token_data) => { 244 - if token_data.did != did.as_str() { 294 + let current_email_lower = current_email 295 + .as_ref() 296 + .map(|e| e.to_lowercase()) 297 + .unwrap_or_default(); 298 + 299 + let verified = crate::auth::verification_token::verify_channel_update_token( 300 + &confirmation_token, 301 + "email_update", 302 + &current_email_lower, 303 + ); 304 + 305 + match verified { 306 + Ok(token_data) => { 307 + if token_data.did != did.as_str() { 308 + return ApiError::InvalidToken(None).into_response(); 309 + } 310 + } 311 + Err(crate::auth::verification_token::VerifyError::Expired) => { 312 + return ApiError::ExpiredToken(None).into_response(); 313 + } 314 + Err(_) => { 245 315 return ApiError::InvalidToken(None).into_response(); 246 316 } 247 - } 248 - Err(crate::auth::verification_token::VerifyError::Expired) => { 249 - return ApiError::ExpiredToken(None).into_response(); 250 - } 251 - Err(_) => { 252 - return ApiError::InvalidToken(None).into_response(); 253 317 } 254 318 } 255 319 } ··· 332 396 } 333 397 } 334 398 } 399 + 400 + #[derive(Deserialize)] 401 + pub struct AuthorizeEmailUpdateQuery { 402 + pub token: String, 403 + } 404 + 405 + pub async fn authorize_email_update( 406 + State(state): State<AppState>, 407 + headers: axum::http::HeaderMap, 408 + axum::extract::Query(query): axum::extract::Query<AuthorizeEmailUpdateQuery>, 409 + ) -> Response { 410 + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 411 + if !state 412 + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) 413 + .await 414 + { 415 + return ApiError::RateLimitExceeded(None).into_response(); 416 + } 417 + 418 + let verified = crate::auth::verification_token::verify_token_signature(&query.token); 419 + 420 + let token_data = match verified { 421 + Ok(data) => data, 422 + Err(crate::auth::verification_token::VerifyError::Expired) => { 423 + warn!("authorize_email_update: token expired"); 424 + return ApiError::ExpiredToken(None).into_response(); 425 + } 426 + Err(e) => { 427 + warn!("authorize_email_update: token verification failed: {:?}", e); 428 + return ApiError::InvalidToken(None).into_response(); 429 + } 430 + }; 431 + 432 + if token_data.purpose != crate::auth::verification_token::VerificationPurpose::ChannelUpdate { 433 + warn!( 434 + "authorize_email_update: wrong purpose: {:?}", 435 + token_data.purpose 436 + ); 437 + return ApiError::InvalidToken(None).into_response(); 438 + } 439 + if token_data.channel != "email_update" { 440 + warn!( 441 + "authorize_email_update: wrong channel: {}", 442 + token_data.channel 443 + ); 444 + return ApiError::InvalidToken(None).into_response(); 445 + } 446 + 447 + let did = token_data.did; 448 + info!("authorize_email_update: token valid for did={}", did); 449 + 450 + let cache_key = email_update_cache_key(&did); 451 + let pending_json = match state.cache.get(&cache_key).await { 452 + Some(json) => json, 453 + None => { 454 + warn!( 455 + "authorize_email_update: no pending email update in cache for did={}", 456 + did 457 + ); 458 + return ApiError::InvalidRequest("No pending email update found".into()) 459 + .into_response(); 460 + } 461 + }; 462 + 463 + let mut pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) { 464 + Ok(p) => p, 465 + Err(_) => { 466 + return ApiError::InternalError(None).into_response(); 467 + } 468 + }; 469 + 470 + let token_hash = hash_token(&query.token); 471 + if pending 472 + .token_hash 473 + .as_bytes() 474 + .ct_eq(token_hash.as_bytes()) 475 + .unwrap_u8() 476 + != 1 477 + { 478 + warn!("authorize_email_update: token hash mismatch"); 479 + return ApiError::InvalidToken(None).into_response(); 480 + } 481 + 482 + pending.authorized = true; 483 + if let Ok(json) = serde_json::to_string(&pending) 484 + && let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { 485 + warn!("Failed to update pending email authorization: {:?}", e); 486 + return ApiError::InternalError(None).into_response(); 487 + } 488 + 489 + info!(did = %did, "Email update authorized via link click"); 490 + 491 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 492 + let redirect_url = format!( 493 + "https://{}/app/verify?type=email-authorize-success", 494 + hostname 495 + ); 496 + 497 + axum::response::Redirect::to(&redirect_url).into_response() 498 + } 499 + 500 + pub async fn check_email_update_status( 501 + State(state): State<AppState>, 502 + headers: axum::http::HeaderMap, 503 + auth: BearerAuth, 504 + ) -> Response { 505 + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 506 + if !state 507 + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) 508 + .await 509 + { 510 + return ApiError::RateLimitExceeded(None).into_response(); 511 + } 512 + 513 + if let Err(e) = crate::auth::scope_check::check_account_scope( 514 + auth.0.is_oauth, 515 + auth.0.scope.as_deref(), 516 + crate::oauth::scopes::AccountAttr::Email, 517 + crate::oauth::scopes::AccountAction::Read, 518 + ) { 519 + return e; 520 + } 521 + 522 + let cache_key = email_update_cache_key(&auth.0.did); 523 + let pending_json = match state.cache.get(&cache_key).await { 524 + Some(json) => json, 525 + None => { 526 + return Json(json!({ "pending": false, "authorized": false })).into_response(); 527 + } 528 + }; 529 + 530 + let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) { 531 + Ok(p) => p, 532 + Err(_) => { 533 + return Json(json!({ "pending": false, "authorized": false })).into_response(); 534 + } 535 + }; 536 + 537 + Json(json!({ 538 + "pending": true, 539 + "authorized": pending.authorized, 540 + "newEmail": pending.new_email, 541 + })) 542 + .into_response() 543 + }
+4 -1
crates/tranquil-pds/src/api/server/mod.rs
··· 22 22 request_account_delete, 23 23 }; 24 24 pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; 25 - pub use email::{check_email_verified, confirm_email, request_email_update, update_email}; 25 + pub use email::{ 26 + authorize_email_update, check_email_update_status, check_email_verified, confirm_email, 27 + request_email_update, update_email, 28 + }; 26 29 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 27 30 pub use logo::get_logo; 28 31 pub use meta::{describe_server, health, robots_txt};
+27 -6
crates/tranquil-pds/src/api/server/password.rs
··· 366 366 auth: BearerAuth, 367 367 Json(input): Json<SetPasswordInput>, 368 368 ) -> Response { 369 - if crate::api::server::reauth::check_reauth_required_cached( 370 - &*state.session_repo, 371 - &state.cache, 372 - &auth.0.did, 373 - ) 374 - .await 369 + let has_password = state 370 + .user_repo 371 + .has_password_by_did(&auth.0.did) 372 + .await 373 + .ok() 374 + .flatten() 375 + .unwrap_or(false); 376 + let has_passkeys = state 377 + .user_repo 378 + .has_passkeys(&auth.0.did) 379 + .await 380 + .unwrap_or(false); 381 + let has_totp = state 382 + .user_repo 383 + .has_totp_enabled(&auth.0.did) 384 + .await 385 + .unwrap_or(false); 386 + 387 + let has_any_reauth_method = has_password || has_passkeys || has_totp; 388 + 389 + if has_any_reauth_method 390 + && crate::api::server::reauth::check_reauth_required_cached( 391 + &*state.session_repo, 392 + &state.cache, 393 + &auth.0.did, 394 + ) 395 + .await 375 396 { 376 397 return crate::api::server::reauth::reauth_required_response( 377 398 &*state.user_repo,
+6 -5
crates/tranquil-pds/src/comms/service.rs
··· 366 366 user_repo: &dyn UserRepository, 367 367 infra_repo: &dyn InfraRepository, 368 368 user_id: Uuid, 369 - code: &str, 369 + raw_token: &str, 370 + display_code: &str, 370 371 hostname: &str, 371 372 ) -> Result<Uuid, DbError> { 372 373 let prefs = user_repo ··· 375 376 .ok_or(DbError::NotFound)?; 376 377 let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); 377 378 let current_email = prefs.email.unwrap_or_default(); 378 - let verify_page = format!("https://{}/app/verify?type=email-update", hostname); 379 + let verify_page = format!("https://{}/app/settings", hostname); 379 380 let verify_link = format!( 380 - "https://{}/app/verify?type=email-update&token={}", 381 + "https://{}/xrpc/_account.authorizeEmailUpdate?token={}", 381 382 hostname, 382 - urlencoding::encode(code) 383 + urlencoding::encode(raw_token) 383 384 ); 384 385 let body = format_message( 385 386 strings.email_update_body, 386 387 &[ 387 388 ("handle", &prefs.handle), 388 - ("code", code), 389 + ("code", display_code), 389 390 ("verify_page", &verify_page), 390 391 ("verify_link", &verify_link), 391 392 ],
+30 -1
crates/tranquil-pds/src/lib.rs
··· 16 16 pub mod rate_limit; 17 17 pub mod repo; 18 18 pub mod scheduled; 19 + pub mod sso; 19 20 pub mod state; 20 21 pub mod storage; 21 22 pub mod sync; ··· 288 289 post(api::server::update_email), 289 290 ) 290 291 .route( 292 + "/_account.authorizeEmailUpdate", 293 + get(api::server::authorize_email_update), 294 + ) 295 + .route( 296 + "/_account.checkEmailUpdateStatus", 297 + get(api::server::check_email_update_status), 298 + ) 299 + .route( 291 300 "/com.atproto.server.reserveSigningKey", 292 301 post(api::server::reserve_signing_key), 293 302 ) ··· 569 578 ) 570 579 .route("/token", post(oauth::endpoints::token_endpoint)) 571 580 .route("/revoke", post(oauth::endpoints::revoke_token)) 572 - .route("/introspect", post(oauth::endpoints::introspect_token)); 581 + .route("/introspect", post(oauth::endpoints::introspect_token)) 582 + .route("/sso/providers", get(sso::endpoints::get_sso_providers)) 583 + .route("/sso/initiate", post(sso::endpoints::sso_initiate)) 584 + .route( 585 + "/sso/callback", 586 + get(sso::endpoints::sso_callback).post(sso::endpoints::sso_callback_post), 587 + ) 588 + .route("/sso/linked", get(sso::endpoints::get_linked_accounts)) 589 + .route("/sso/unlink", post(sso::endpoints::unlink_account)) 590 + .route( 591 + "/sso/pending-registration", 592 + get(sso::endpoints::get_pending_registration), 593 + ) 594 + .route( 595 + "/sso/complete-registration", 596 + post(sso::endpoints::complete_registration), 597 + ) 598 + .route( 599 + "/sso/check-handle-available", 600 + get(sso::endpoints::check_handle_available), 601 + ); 573 602 574 603 let well_known_router = Router::new() 575 604 .route("/did.json", get(api::identity::well_known_did))
+9 -1
crates/tranquil-pds/src/main.rs
··· 4 4 use tokio::sync::watch; 5 5 use tracing::{error, info, warn}; 6 6 use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; 7 + 8 + const BUILD_VERSION: &str = concat!( 9 + env!("CARGO_PKG_VERSION"), 10 + " (built ", 11 + env!("BUILD_TIMESTAMP"), 12 + ")" 13 + ); 7 14 use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; 8 15 use tranquil_pds::scheduled::{ 9 16 backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, ··· 106 113 state.user_repo.clone(), 107 114 state.blob_repo.clone(), 108 115 state.blob_store.clone(), 116 + state.sso_repo.clone(), 109 117 shutdown_rx, 110 118 )); 111 119 ··· 121 129 .parse() 122 130 .map_err(|e| format!("Invalid SERVER_HOST or SERVER_PORT: {}", e))?; 123 131 124 - info!("listening on {}", addr); 132 + info!("tranquil-pds {} listening on {}", BUILD_VERSION, addr); 125 133 126 134 let listener = tokio::net::TcpListener::bind(addr) 127 135 .await
+1 -3
crates/tranquil-pds/src/oauth/endpoints/delegation.rs
··· 459 459 headers: HeaderMap, 460 460 Json(form): Json<DelegationTokenAuthSubmit>, 461 461 ) -> Response { 462 - let auth_header = headers 463 - .get("authorization") 464 - .and_then(|v| v.to_str().ok()); 462 + let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok()); 465 463 466 464 let extracted = match extract_auth_token_from_header(auth_header) { 467 465 Some(e) => e,
+1 -1
crates/tranquil-pds/src/oauth/endpoints/metadata.rs
··· 176 176 "refresh_token".to_string(), 177 177 ], 178 178 response_types: vec!["code".to_string()], 179 - scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:* identity:*" 179 + scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:*?action=manage identity:*" 180 180 .to_string(), 181 181 token_endpoint_auth_method: "none".to_string(), 182 182 application_type: "web".to_string(),
+19
crates/tranquil-pds/src/rate_limit.rs
··· 33 33 pub handle_update: Arc<KeyedRateLimiter>, 34 34 pub handle_update_daily: Arc<KeyedRateLimiter>, 35 35 pub verification_check: Arc<KeyedRateLimiter>, 36 + pub sso_initiate: Arc<KeyedRateLimiter>, 37 + pub sso_callback: Arc<KeyedRateLimiter>, 38 + pub sso_unlink: Arc<KeyedRateLimiter>, 36 39 } 37 40 38 41 impl Default for RateLimiters { ··· 95 98 verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute( 96 99 NonZeroU32::new(60).unwrap(), 97 100 ))), 101 + sso_initiate: Arc::new(RateLimiter::keyed(Quota::per_minute( 102 + NonZeroU32::new(10).unwrap(), 103 + ))), 104 + sso_callback: Arc::new(RateLimiter::keyed(Quota::per_minute( 105 + NonZeroU32::new(30).unwrap(), 106 + ))), 107 + sso_unlink: Arc::new(RateLimiter::keyed(Quota::per_minute( 108 + NonZeroU32::new(10).unwrap(), 109 + ))), 98 110 } 99 111 } 100 112 ··· 136 148 pub fn with_email_update_limit(mut self, per_hour: u32) -> Self { 137 149 self.email_update = Arc::new(RateLimiter::keyed(Quota::per_hour( 138 150 NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()), 151 + ))); 152 + self 153 + } 154 + 155 + pub fn with_sso_initiate_limit(mut self, per_minute: u32) -> Self { 156 + self.sso_initiate = Arc::new(RateLimiter::keyed(Quota::per_minute( 157 + NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()), 139 158 ))); 140 159 self 141 160 }
+33 -1
crates/tranquil-pds/src/scheduled.rs
··· 9 9 use tokio::time::interval; 10 10 use tracing::{debug, error, info, warn}; 11 11 use tranquil_db_traits::{ 12 - BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, UserRepository, 12 + BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SsoRepository, 13 + UserRepository, 13 14 }; 14 15 use tranquil_types::{AtUri, CidLink, Did}; 15 16 ··· 390 391 user_repo: Arc<dyn UserRepository>, 391 392 blob_repo: Arc<dyn BlobRepository>, 392 393 blob_store: Arc<dyn BlobStorage>, 394 + sso_repo: Arc<dyn SsoRepository>, 393 395 mut shutdown_rx: watch::Receiver<bool>, 394 396 ) { 395 397 let check_interval = Duration::from_secs( ··· 422 424 blob_store.as_ref(), 423 425 ).await { 424 426 error!("Error processing scheduled deletions: {}", e); 427 + } 428 + 429 + match sso_repo.cleanup_expired_sso_auth_states().await { 430 + Ok(count) if count > 0 => { 431 + info!(count = count, "Cleaned up expired SSO auth states"); 432 + } 433 + Ok(_) => {} 434 + Err(e) => { 435 + error!("Error cleaning up SSO auth states: {:?}", e); 436 + } 437 + } 438 + 439 + match sso_repo.cleanup_expired_pending_registrations().await { 440 + Ok(count) if count > 0 => { 441 + info!(count = count, "Cleaned up expired SSO pending registrations"); 442 + } 443 + Ok(_) => {} 444 + Err(e) => { 445 + error!("Error cleaning up SSO pending registrations: {:?}", e); 446 + } 447 + } 448 + 449 + match user_repo.cleanup_expired_handle_reservations().await { 450 + Ok(count) if count > 0 => { 451 + info!(count = count, "Cleaned up expired handle reservations"); 452 + } 453 + Ok(_) => {} 454 + Err(e) => { 455 + error!("Error cleaning up handle reservations: {:?}", e); 456 + } 425 457 } 426 458 } 427 459 }
+211
crates/tranquil-pds/src/sso/config.rs
··· 1 + use std::sync::OnceLock; 2 + use tranquil_db_traits::SsoProviderType; 3 + 4 + static SSO_CONFIG: OnceLock<SsoConfig> = OnceLock::new(); 5 + static SSO_REDIRECT_URI: OnceLock<String> = OnceLock::new(); 6 + 7 + #[derive(Debug, Clone)] 8 + pub struct ProviderConfig { 9 + pub client_id: String, 10 + pub client_secret: String, 11 + pub issuer: Option<String>, 12 + pub display_name: Option<String>, 13 + } 14 + 15 + #[derive(Debug, Clone)] 16 + pub struct AppleProviderConfig { 17 + pub client_id: String, 18 + pub team_id: String, 19 + pub key_id: String, 20 + pub private_key_pem: String, 21 + } 22 + 23 + #[derive(Debug, Clone, Default)] 24 + pub struct SsoConfig { 25 + pub github: Option<ProviderConfig>, 26 + pub discord: Option<ProviderConfig>, 27 + pub google: Option<ProviderConfig>, 28 + pub gitlab: Option<ProviderConfig>, 29 + pub oidc: Option<ProviderConfig>, 30 + pub apple: Option<AppleProviderConfig>, 31 + } 32 + 33 + impl SsoConfig { 34 + pub fn init() -> &'static Self { 35 + SSO_CONFIG.get_or_init(|| { 36 + let github = Self::load_provider("GITHUB", false); 37 + let discord = Self::load_provider("DISCORD", false); 38 + let google = Self::load_provider("GOOGLE", false); 39 + let gitlab = Self::load_provider("GITLAB", true); 40 + let oidc = Self::load_provider("OIDC", true); 41 + let apple = Self::load_apple_provider(); 42 + 43 + let config = SsoConfig { 44 + github, 45 + discord, 46 + google, 47 + gitlab, 48 + oidc, 49 + apple, 50 + }; 51 + 52 + if config.is_any_enabled() { 53 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_default(); 54 + if hostname.is_empty() || hostname == "localhost" { 55 + panic!( 56 + "PDS_HOSTNAME must be set to a valid hostname when SSO is enabled. \ 57 + SSO redirect URIs require a proper hostname for security." 58 + ); 59 + } 60 + SSO_REDIRECT_URI 61 + .set(format!("https://{}/oauth/sso/callback", hostname)) 62 + .expect("SSO_REDIRECT_URI already set"); 63 + tracing::info!( 64 + hostname = %hostname, 65 + providers = ?config.enabled_providers().iter().map(|p| p.as_str()).collect::<Vec<_>>(), 66 + "SSO initialized" 67 + ); 68 + } 69 + 70 + config 71 + }) 72 + } 73 + 74 + pub fn get_redirect_uri() -> &'static str { 75 + SSO_REDIRECT_URI 76 + .get() 77 + .map(|s| s.as_str()) 78 + .expect("SSO redirect URI not initialized - call SsoConfig::init() first") 79 + } 80 + 81 + fn load_provider(name: &str, needs_issuer: bool) -> Option<ProviderConfig> { 82 + let enabled = std::env::var(format!("SSO_{}_ENABLED", name)) 83 + .map(|v| v == "true" || v == "1") 84 + .unwrap_or(false); 85 + 86 + if !enabled { 87 + return None; 88 + } 89 + 90 + let client_id = std::env::var(format!("SSO_{}_CLIENT_ID", name)).ok()?; 91 + let client_secret = std::env::var(format!("SSO_{}_CLIENT_SECRET", name)).ok()?; 92 + 93 + if client_id.is_empty() || client_secret.is_empty() { 94 + tracing::warn!( 95 + "SSO_{} enabled but missing client_id or client_secret", 96 + name 97 + ); 98 + return None; 99 + } 100 + 101 + let issuer = if needs_issuer { 102 + let issuer_val = std::env::var(format!("SSO_{}_ISSUER", name)).ok(); 103 + if issuer_val.is_none() || issuer_val.as_ref().map(|s| s.is_empty()).unwrap_or(true) { 104 + tracing::warn!("SSO_{} requires ISSUER but none provided", name); 105 + return None; 106 + } 107 + issuer_val 108 + } else { 109 + None 110 + }; 111 + 112 + let display_name = std::env::var(format!("SSO_{}_NAME", name)).ok(); 113 + 114 + Some(ProviderConfig { 115 + client_id, 116 + client_secret, 117 + issuer, 118 + display_name, 119 + }) 120 + } 121 + 122 + fn load_apple_provider() -> Option<AppleProviderConfig> { 123 + let enabled = std::env::var("SSO_APPLE_ENABLED") 124 + .map(|v| v == "true" || v == "1") 125 + .unwrap_or(false); 126 + 127 + if !enabled { 128 + return None; 129 + } 130 + 131 + let client_id = std::env::var("SSO_APPLE_CLIENT_ID").ok()?; 132 + let team_id = std::env::var("SSO_APPLE_TEAM_ID").ok()?; 133 + let key_id = std::env::var("SSO_APPLE_KEY_ID").ok()?; 134 + let private_key_pem = std::env::var("SSO_APPLE_PRIVATE_KEY").ok()?; 135 + 136 + if client_id.is_empty() { 137 + tracing::warn!("SSO_APPLE enabled but missing CLIENT_ID"); 138 + return None; 139 + } 140 + if team_id.is_empty() || team_id.len() != 10 { 141 + tracing::warn!("SSO_APPLE enabled but TEAM_ID is invalid (must be 10 characters)"); 142 + return None; 143 + } 144 + if key_id.is_empty() { 145 + tracing::warn!("SSO_APPLE enabled but missing KEY_ID"); 146 + return None; 147 + } 148 + if private_key_pem.is_empty() || !private_key_pem.contains("PRIVATE KEY") { 149 + tracing::warn!("SSO_APPLE enabled but PRIVATE_KEY is invalid"); 150 + return None; 151 + } 152 + 153 + Some(AppleProviderConfig { 154 + client_id, 155 + team_id, 156 + key_id, 157 + private_key_pem, 158 + }) 159 + } 160 + 161 + pub fn get() -> &'static Self { 162 + SSO_CONFIG.get_or_init(SsoConfig::default) 163 + } 164 + 165 + pub fn get_provider_config(&self, provider: SsoProviderType) -> Option<&ProviderConfig> { 166 + match provider { 167 + SsoProviderType::Github => self.github.as_ref(), 168 + SsoProviderType::Discord => self.discord.as_ref(), 169 + SsoProviderType::Google => self.google.as_ref(), 170 + SsoProviderType::Gitlab => self.gitlab.as_ref(), 171 + SsoProviderType::Oidc => self.oidc.as_ref(), 172 + SsoProviderType::Apple => None, 173 + } 174 + } 175 + 176 + pub fn get_apple_config(&self) -> Option<&AppleProviderConfig> { 177 + self.apple.as_ref() 178 + } 179 + 180 + pub fn enabled_providers(&self) -> Vec<SsoProviderType> { 181 + let mut providers = Vec::new(); 182 + if self.github.is_some() { 183 + providers.push(SsoProviderType::Github); 184 + } 185 + if self.discord.is_some() { 186 + providers.push(SsoProviderType::Discord); 187 + } 188 + if self.google.is_some() { 189 + providers.push(SsoProviderType::Google); 190 + } 191 + if self.gitlab.is_some() { 192 + providers.push(SsoProviderType::Gitlab); 193 + } 194 + if self.oidc.is_some() { 195 + providers.push(SsoProviderType::Oidc); 196 + } 197 + if self.apple.is_some() { 198 + providers.push(SsoProviderType::Apple); 199 + } 200 + providers 201 + } 202 + 203 + pub fn is_any_enabled(&self) -> bool { 204 + self.github.is_some() 205 + || self.discord.is_some() 206 + || self.google.is_some() 207 + || self.gitlab.is_some() 208 + || self.oidc.is_some() 209 + || self.apple.is_some() 210 + } 211 + }
+1306
crates/tranquil-pds/src/sso/endpoints.rs
··· 1 + use axum::{ 2 + Form, Json, 3 + extract::{Query, State}, 4 + http::HeaderMap, 5 + response::{IntoResponse, Redirect, Response}, 6 + }; 7 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 8 + use serde::{Deserialize, Serialize}; 9 + use tranquil_db_traits::SsoProviderType; 10 + use tranquil_types::RequestId; 11 + 12 + use super::config::SsoConfig; 13 + use crate::api::error::ApiError; 14 + use crate::auth::extractor::extract_bearer_token_from_header; 15 + use crate::auth::validate_bearer_token_cached; 16 + use crate::rate_limit::extract_client_ip; 17 + use crate::state::{AppState, RateLimitKind}; 18 + 19 + fn generate_state() -> String { 20 + use rand::RngCore; 21 + let mut bytes = [0u8; 32]; 22 + rand::thread_rng().fill_bytes(&mut bytes); 23 + URL_SAFE_NO_PAD.encode(bytes) 24 + } 25 + 26 + fn generate_nonce() -> String { 27 + use rand::RngCore; 28 + let mut bytes = [0u8; 16]; 29 + rand::thread_rng().fill_bytes(&mut bytes); 30 + URL_SAFE_NO_PAD.encode(bytes) 31 + } 32 + 33 + #[derive(Debug, Serialize)] 34 + pub struct SsoProviderInfo { 35 + pub provider: String, 36 + pub name: String, 37 + pub icon: String, 38 + } 39 + 40 + #[derive(Debug, Serialize)] 41 + pub struct SsoProvidersResponse { 42 + pub providers: Vec<SsoProviderInfo>, 43 + } 44 + 45 + pub async fn get_sso_providers(State(state): State<AppState>) -> Json<SsoProvidersResponse> { 46 + let providers = state 47 + .sso_manager 48 + .enabled_providers() 49 + .iter() 50 + .map(|(t, name, icon)| SsoProviderInfo { 51 + provider: t.as_str().to_string(), 52 + name: name.to_string(), 53 + icon: icon.to_string(), 54 + }) 55 + .collect(); 56 + 57 + Json(SsoProvidersResponse { providers }) 58 + } 59 + 60 + #[derive(Debug, Deserialize)] 61 + pub struct SsoInitiateRequest { 62 + pub provider: String, 63 + pub request_uri: Option<String>, 64 + pub action: Option<String>, 65 + } 66 + 67 + #[derive(Debug, Serialize)] 68 + pub struct SsoInitiateResponse { 69 + pub redirect_url: String, 70 + } 71 + 72 + pub async fn sso_initiate( 73 + State(state): State<AppState>, 74 + headers: HeaderMap, 75 + Json(input): Json<SsoInitiateRequest>, 76 + ) -> Result<Json<SsoInitiateResponse>, ApiError> { 77 + let client_ip = extract_client_ip(&headers, None); 78 + if !state 79 + .check_rate_limit(RateLimitKind::SsoInitiate, &client_ip) 80 + .await 81 + { 82 + tracing::warn!(ip = %client_ip, "SSO initiate rate limit exceeded"); 83 + return Err(ApiError::RateLimitExceeded(None)); 84 + } 85 + 86 + if input.provider.len() > 20 { 87 + return Err(ApiError::SsoProviderNotFound); 88 + } 89 + if let Some(ref uri) = input.request_uri 90 + && uri.len() > 500 { 91 + return Err(ApiError::InvalidRequest("Request URI too long".into())); 92 + } 93 + if let Some(ref action) = input.action 94 + && action.len() > 20 { 95 + return Err(ApiError::SsoInvalidAction); 96 + } 97 + 98 + let provider_type = 99 + SsoProviderType::parse(&input.provider).ok_or(ApiError::SsoProviderNotFound)?; 100 + 101 + let provider = state 102 + .sso_manager 103 + .get_provider(provider_type) 104 + .ok_or(ApiError::SsoProviderNotEnabled)?; 105 + 106 + let action = input.action.as_deref().unwrap_or("login"); 107 + if !["login", "link", "register"].contains(&action) { 108 + return Err(ApiError::SsoInvalidAction); 109 + } 110 + 111 + let is_standalone = action == "register" && input.request_uri.is_none(); 112 + let request_uri = input 113 + .request_uri 114 + .clone() 115 + .unwrap_or_else(|| "standalone".to_string()); 116 + 117 + let auth_did = match action { 118 + "link" => { 119 + let auth_header = headers 120 + .get(axum::http::header::AUTHORIZATION) 121 + .and_then(|v| v.to_str().ok()); 122 + let token = extract_bearer_token_from_header(auth_header) 123 + .ok_or(ApiError::SsoNotAuthenticated)?; 124 + let auth_user = validate_bearer_token_cached( 125 + state.user_repo.as_ref(), 126 + state.cache.as_ref(), 127 + &token, 128 + ) 129 + .await 130 + .map_err(|_| ApiError::SsoNotAuthenticated)?; 131 + Some(auth_user.did) 132 + } 133 + "register" if is_standalone => None, 134 + _ => { 135 + let request_id = RequestId::new(request_uri.clone()); 136 + let _request_data = state 137 + .oauth_repo 138 + .get_authorization_request(&request_id) 139 + .await? 140 + .ok_or(ApiError::InvalidRequest( 141 + "Authorization request not found or expired".into(), 142 + ))?; 143 + None 144 + } 145 + }; 146 + 147 + let sso_state = generate_state(); 148 + let nonce = generate_nonce(); 149 + let redirect_uri = SsoConfig::get_redirect_uri(); 150 + 151 + let auth_result = provider 152 + .build_auth_url(&sso_state, redirect_uri, Some(&nonce)) 153 + .await 154 + .map_err(|e| { 155 + tracing::error!("Failed to build auth URL: {:?}", e); 156 + ApiError::InternalError(Some("Failed to build authorization URL".into())) 157 + })?; 158 + 159 + state 160 + .sso_repo 161 + .create_sso_auth_state( 162 + &sso_state, 163 + &request_uri, 164 + provider_type, 165 + action, 166 + Some(&nonce), 167 + auth_result.code_verifier.as_deref(), 168 + auth_did.as_ref(), 169 + ) 170 + .await?; 171 + 172 + tracing::debug!( 173 + provider = %provider_type.as_str(), 174 + action = %action, 175 + "SSO flow initiated" 176 + ); 177 + 178 + Ok(Json(SsoInitiateResponse { 179 + redirect_url: auth_result.url, 180 + })) 181 + } 182 + 183 + #[derive(Debug, Deserialize)] 184 + pub struct SsoCallbackQuery { 185 + pub code: Option<String>, 186 + pub state: Option<String>, 187 + pub error: Option<String>, 188 + pub error_description: Option<String>, 189 + } 190 + 191 + #[derive(Debug, Deserialize)] 192 + pub struct SsoCallbackForm { 193 + pub code: Option<String>, 194 + pub state: Option<String>, 195 + pub error: Option<String>, 196 + pub error_description: Option<String>, 197 + #[serde(default)] 198 + pub user: Option<String>, 199 + } 200 + 201 + fn redirect_to_error(message: &str) -> Response { 202 + let encoded = urlencoding::encode(message); 203 + Redirect::to(&format!("/app/oauth/error?error={}", encoded)).into_response() 204 + } 205 + 206 + fn redirect_to_login_with_error(request_uri: &str, message: &str) -> Response { 207 + let uri_encoded = urlencoding::encode(request_uri); 208 + let msg_encoded = urlencoding::encode(message); 209 + Redirect::to(&format!( 210 + "/app/oauth/login?request_uri={}&error={}", 211 + uri_encoded, msg_encoded 212 + )) 213 + .into_response() 214 + } 215 + 216 + pub async fn sso_callback( 217 + State(state): State<AppState>, 218 + headers: HeaderMap, 219 + Query(query): Query<SsoCallbackQuery>, 220 + ) -> Response { 221 + tracing::debug!( 222 + has_code = query.code.is_some(), 223 + has_state = query.state.is_some(), 224 + has_error = query.error.is_some(), 225 + "SSO callback received" 226 + ); 227 + 228 + let client_ip = extract_client_ip(&headers, None); 229 + if !state 230 + .check_rate_limit(RateLimitKind::SsoCallback, &client_ip) 231 + .await 232 + { 233 + tracing::warn!(ip = %client_ip, "SSO callback rate limit exceeded"); 234 + return redirect_to_error("Too many requests. Please try again later."); 235 + } 236 + 237 + if let Some(ref error) = query.error { 238 + tracing::warn!( 239 + error = %error, 240 + error_description = ?query.error_description, 241 + "SSO provider returned error" 242 + ); 243 + if error.len() > 100 { 244 + return redirect_to_error("Invalid error response"); 245 + } 246 + let desc = query 247 + .error_description 248 + .as_ref() 249 + .map(|d| if d.len() > 500 { "Error" } else { d.as_str() }) 250 + .unwrap_or_default(); 251 + return redirect_to_error(&format!("{}: {}", error, desc)); 252 + } 253 + 254 + let (code, sso_state) = match (&query.code, &query.state) { 255 + (Some(c), Some(s)) if c.len() <= 2000 && s.len() <= 100 => (c.clone(), s.clone()), 256 + (Some(_), Some(_)) => return redirect_to_error("Invalid callback parameters"), 257 + _ => return redirect_to_error("Missing code or state parameter"), 258 + }; 259 + 260 + let auth_state = match state.sso_repo.consume_sso_auth_state(&sso_state).await { 261 + Ok(Some(s)) => s, 262 + Ok(None) => return redirect_to_error("SSO session expired or invalid"), 263 + Err(e) => { 264 + tracing::error!("SSO state lookup failed: {:?}", e); 265 + return redirect_to_error("Database error"); 266 + } 267 + }; 268 + 269 + tracing::debug!( 270 + provider = %auth_state.provider.as_str(), 271 + action = %auth_state.action, 272 + request_uri = %auth_state.request_uri, 273 + "SSO auth state retrieved" 274 + ); 275 + 276 + let is_standalone = auth_state.request_uri == "standalone"; 277 + 278 + let provider = match state.sso_manager.get_provider(auth_state.provider) { 279 + Some(p) => p, 280 + None => return redirect_to_error("Provider no longer available"), 281 + }; 282 + 283 + let redirect_uri = SsoConfig::get_redirect_uri(); 284 + 285 + let token_resp = match provider 286 + .exchange_code(&code, redirect_uri, auth_state.code_verifier.as_deref()) 287 + .await 288 + { 289 + Ok(t) => t, 290 + Err(e) => { 291 + tracing::error!("SSO token exchange failed: {:?}", e); 292 + if is_standalone { 293 + return redirect_to_error( 294 + "Failed to exchange authorization code. Please try again.", 295 + ); 296 + } 297 + return redirect_to_login_with_error( 298 + &auth_state.request_uri, 299 + "Failed to exchange authorization code", 300 + ); 301 + } 302 + }; 303 + 304 + let user_info = match provider 305 + .get_user_info( 306 + &token_resp.access_token, 307 + token_resp.id_token.as_deref(), 308 + auth_state.nonce.as_deref(), 309 + ) 310 + .await 311 + { 312 + Ok(u) => u, 313 + Err(e) => { 314 + tracing::error!("SSO user info fetch failed: {:?}", e); 315 + if is_standalone { 316 + return redirect_to_error( 317 + "Failed to get user information from provider. Please try again.", 318 + ); 319 + } 320 + return redirect_to_login_with_error( 321 + &auth_state.request_uri, 322 + "Failed to get user information from provider", 323 + ); 324 + } 325 + }; 326 + 327 + match auth_state.action.as_str() { 328 + "login" => { 329 + handle_sso_login( 330 + &state, 331 + &auth_state.request_uri, 332 + auth_state.provider, 333 + &user_info, 334 + ) 335 + .await 336 + } 337 + "link" => { 338 + let did = match auth_state.did { 339 + Some(d) => d, 340 + None => return redirect_to_error("Not authenticated"), 341 + }; 342 + handle_sso_link(&state, did, auth_state.provider, &user_info).await 343 + } 344 + "register" => { 345 + handle_sso_register( 346 + &state, 347 + &auth_state.request_uri, 348 + auth_state.provider, 349 + &user_info, 350 + ) 351 + .await 352 + } 353 + _ => redirect_to_error("Unknown SSO action"), 354 + } 355 + } 356 + 357 + pub async fn sso_callback_post( 358 + State(state): State<AppState>, 359 + headers: HeaderMap, 360 + Form(form): Form<SsoCallbackForm>, 361 + ) -> Response { 362 + tracing::debug!( 363 + has_code = form.code.is_some(), 364 + has_state = form.state.is_some(), 365 + has_error = form.error.is_some(), 366 + has_user = form.user.is_some(), 367 + "SSO callback (POST/form_post) received" 368 + ); 369 + 370 + let query = SsoCallbackQuery { 371 + code: form.code, 372 + state: form.state, 373 + error: form.error, 374 + error_description: form.error_description, 375 + }; 376 + 377 + sso_callback(State(state), headers, Query(query)).await 378 + } 379 + 380 + fn generate_registration_token() -> String { 381 + use rand::RngCore; 382 + let mut bytes = [0u8; 32]; 383 + rand::thread_rng().fill_bytes(&mut bytes); 384 + URL_SAFE_NO_PAD.encode(bytes) 385 + } 386 + 387 + async fn handle_sso_login( 388 + state: &AppState, 389 + request_uri: &str, 390 + provider: SsoProviderType, 391 + user_info: &crate::sso::providers::SsoUserInfo, 392 + ) -> Response { 393 + let identity = match state 394 + .sso_repo 395 + .get_external_identity_by_provider(provider, &user_info.provider_user_id) 396 + .await 397 + { 398 + Ok(Some(id)) => id, 399 + Ok(None) => { 400 + let token = generate_registration_token(); 401 + if let Err(e) = state 402 + .sso_repo 403 + .create_pending_registration( 404 + &token, 405 + request_uri, 406 + provider, 407 + &user_info.provider_user_id, 408 + user_info.username.as_deref(), 409 + user_info.email.as_deref(), 410 + user_info.email_verified.unwrap_or(false), 411 + ) 412 + .await 413 + { 414 + tracing::error!("Failed to create pending registration: {:?}", e); 415 + return redirect_to_error("Database error"); 416 + } 417 + return Redirect::to(&format!( 418 + "/app/oauth/sso-register?token={}", 419 + urlencoding::encode(&token), 420 + )) 421 + .into_response(); 422 + } 423 + Err(e) => { 424 + tracing::error!("SSO identity lookup failed: {:?}", e); 425 + return redirect_to_error("Database error"); 426 + } 427 + }; 428 + 429 + if let Err(e) = state 430 + .sso_repo 431 + .update_external_identity_login( 432 + identity.id, 433 + user_info.username.as_deref(), 434 + user_info.email.as_deref(), 435 + ) 436 + .await 437 + { 438 + tracing::warn!("Failed to update external identity last login: {:?}", e); 439 + } 440 + 441 + let request_id = RequestId::new(request_uri.to_string()); 442 + if let Err(e) = state 443 + .oauth_repo 444 + .set_authorization_did(&request_id, &identity.did, None) 445 + .await 446 + { 447 + tracing::error!("Failed to set authorization DID: {:?}", e); 448 + return redirect_to_error("Failed to authenticate"); 449 + } 450 + 451 + tracing::info!( 452 + did = %identity.did, 453 + provider = %provider.as_str(), 454 + provider_user_id = %user_info.provider_user_id, 455 + "SSO login successful" 456 + ); 457 + 458 + let has_totp = match state.user_repo.get_totp_record(&identity.did).await { 459 + Ok(Some(record)) => record.verified, 460 + _ => false, 461 + }; 462 + 463 + if has_totp { 464 + return Redirect::to(&format!( 465 + "/app/oauth/totp?request_uri={}", 466 + urlencoding::encode(request_uri) 467 + )) 468 + .into_response(); 469 + } 470 + 471 + Redirect::to(&format!( 472 + "/app/oauth/consent?request_uri={}", 473 + urlencoding::encode(request_uri) 474 + )) 475 + .into_response() 476 + } 477 + 478 + async fn handle_sso_link( 479 + state: &AppState, 480 + did: tranquil_types::Did, 481 + provider: SsoProviderType, 482 + user_info: &crate::sso::providers::SsoUserInfo, 483 + ) -> Response { 484 + let existing = state 485 + .sso_repo 486 + .get_external_identity_by_provider(provider, &user_info.provider_user_id) 487 + .await; 488 + 489 + match existing { 490 + Ok(Some(existing_id)) => { 491 + if existing_id.did != did { 492 + tracing::warn!( 493 + provider = %provider.as_str(), 494 + provider_user_id = %user_info.provider_user_id, 495 + existing_did = %existing_id.did, 496 + requested_did = %did, 497 + "SSO account already linked to different user" 498 + ); 499 + return Redirect::to(&format!( 500 + "/app/security?error={}", 501 + urlencoding::encode("This SSO account is already linked to a different user") 502 + )) 503 + .into_response(); 504 + } 505 + tracing::info!( 506 + did = %did, 507 + provider = %provider.as_str(), 508 + "SSO account already linked to this user" 509 + ); 510 + return Redirect::to("/app/security?sso_linked=true").into_response(); 511 + } 512 + Ok(None) => {} 513 + Err(e) => { 514 + tracing::error!("Failed to check existing identity: {:?}", e); 515 + return Redirect::to(&format!( 516 + "/app/security?error={}", 517 + urlencoding::encode("Database error") 518 + )) 519 + .into_response(); 520 + } 521 + } 522 + 523 + if let Err(e) = state 524 + .sso_repo 525 + .create_external_identity( 526 + &did, 527 + provider, 528 + &user_info.provider_user_id, 529 + user_info.username.as_deref(), 530 + user_info.email.as_deref(), 531 + ) 532 + .await 533 + { 534 + tracing::error!("Failed to create external identity: {:?}", e); 535 + return Redirect::to(&format!( 536 + "/app/security?error={}", 537 + urlencoding::encode("Failed to link account") 538 + )) 539 + .into_response(); 540 + } 541 + 542 + tracing::info!( 543 + did = %did, 544 + provider = %provider.as_str(), 545 + provider_user_id = %user_info.provider_user_id, 546 + "Successfully linked SSO account" 547 + ); 548 + Redirect::to("/app/security?sso_linked=true").into_response() 549 + } 550 + 551 + async fn handle_sso_register( 552 + state: &AppState, 553 + request_uri: &str, 554 + provider: SsoProviderType, 555 + user_info: &crate::sso::providers::SsoUserInfo, 556 + ) -> Response { 557 + match state 558 + .sso_repo 559 + .get_external_identity_by_provider(provider, &user_info.provider_user_id) 560 + .await 561 + { 562 + Ok(Some(_)) => { 563 + return redirect_to_error( 564 + "This account is already linked to an existing user. Please sign in instead.", 565 + ); 566 + } 567 + Ok(None) => {} 568 + Err(e) => { 569 + tracing::error!("SSO identity lookup failed: {:?}", e); 570 + return redirect_to_error("Database error"); 571 + } 572 + } 573 + 574 + let token = generate_registration_token(); 575 + if let Err(e) = state 576 + .sso_repo 577 + .create_pending_registration( 578 + &token, 579 + request_uri, 580 + provider, 581 + &user_info.provider_user_id, 582 + user_info.username.as_deref(), 583 + user_info.email.as_deref(), 584 + user_info.email_verified.unwrap_or(false), 585 + ) 586 + .await 587 + { 588 + tracing::error!("Failed to create pending registration: {:?}", e); 589 + return redirect_to_error("Database error"); 590 + } 591 + Redirect::to(&format!( 592 + "/app/oauth/sso-register?token={}", 593 + urlencoding::encode(&token), 594 + )) 595 + .into_response() 596 + } 597 + 598 + #[derive(Debug, Serialize)] 599 + pub struct LinkedAccountInfo { 600 + pub id: String, 601 + pub provider: String, 602 + pub provider_name: String, 603 + pub provider_username: Option<String>, 604 + pub provider_email: Option<String>, 605 + pub created_at: String, 606 + pub last_login_at: Option<String>, 607 + } 608 + 609 + #[derive(Debug, Serialize)] 610 + pub struct LinkedAccountsResponse { 611 + pub accounts: Vec<LinkedAccountInfo>, 612 + } 613 + 614 + pub async fn get_linked_accounts( 615 + State(state): State<AppState>, 616 + crate::auth::extractor::BearerAuth(auth): crate::auth::extractor::BearerAuth, 617 + ) -> Result<Json<LinkedAccountsResponse>, ApiError> { 618 + let identities = state 619 + .sso_repo 620 + .get_external_identities_by_did(&auth.did) 621 + .await?; 622 + 623 + let accounts = identities 624 + .into_iter() 625 + .map(|id| LinkedAccountInfo { 626 + id: id.id.to_string(), 627 + provider: id.provider.as_str().to_string(), 628 + provider_name: id.provider.display_name().to_string(), 629 + provider_username: id.provider_username, 630 + provider_email: id.provider_email, 631 + created_at: id.created_at.to_rfc3339(), 632 + last_login_at: id.last_login_at.map(|t| t.to_rfc3339()), 633 + }) 634 + .collect(); 635 + 636 + Ok(Json(LinkedAccountsResponse { accounts })) 637 + } 638 + 639 + #[derive(Debug, Deserialize)] 640 + pub struct UnlinkAccountRequest { 641 + pub id: String, 642 + } 643 + 644 + #[derive(Debug, Serialize)] 645 + pub struct UnlinkAccountResponse { 646 + pub success: bool, 647 + } 648 + 649 + pub async fn unlink_account( 650 + State(state): State<AppState>, 651 + crate::auth::extractor::BearerAuth(auth): crate::auth::extractor::BearerAuth, 652 + Json(input): Json<UnlinkAccountRequest>, 653 + ) -> Result<Json<UnlinkAccountResponse>, ApiError> { 654 + if !state 655 + .check_rate_limit(RateLimitKind::SsoUnlink, auth.did.as_str()) 656 + .await 657 + { 658 + tracing::warn!(did = %auth.did, "SSO unlink rate limit exceeded"); 659 + return Err(ApiError::RateLimitExceeded(None)); 660 + } 661 + 662 + let id = uuid::Uuid::parse_str(&input.id).map_err(|_| ApiError::InvalidId)?; 663 + 664 + let has_password = state 665 + .user_repo 666 + .has_password_by_did(&auth.did) 667 + .await? 668 + .unwrap_or(false); 669 + 670 + let passkeys = state.user_repo.get_passkeys_for_user(&auth.did).await?; 671 + let has_passkeys = !passkeys.is_empty(); 672 + 673 + if !has_password && !has_passkeys { 674 + let identities = state 675 + .sso_repo 676 + .get_external_identities_by_did(&auth.did) 677 + .await?; 678 + 679 + if identities.len() <= 1 { 680 + return Err(ApiError::InvalidRequest( 681 + "Cannot unlink your only login method. Add a password or passkey first." 682 + .to_string(), 683 + )); 684 + } 685 + } 686 + 687 + let deleted = state 688 + .sso_repo 689 + .delete_external_identity(id, &auth.did) 690 + .await?; 691 + 692 + if !deleted { 693 + return Err(ApiError::SsoLinkNotFound); 694 + } 695 + 696 + tracing::info!(did = %auth.did, identity_id = %id, "SSO account unlinked"); 697 + 698 + Ok(Json(UnlinkAccountResponse { success: true })) 699 + } 700 + 701 + #[derive(Debug, Deserialize)] 702 + pub struct PendingRegistrationQuery { 703 + pub token: String, 704 + } 705 + 706 + #[derive(Debug, Serialize)] 707 + pub struct PendingRegistrationResponse { 708 + pub request_uri: String, 709 + pub provider: String, 710 + pub provider_user_id: String, 711 + pub provider_username: Option<String>, 712 + pub provider_email: Option<String>, 713 + pub provider_email_verified: bool, 714 + } 715 + 716 + pub async fn get_pending_registration( 717 + State(state): State<AppState>, 718 + headers: HeaderMap, 719 + Query(query): Query<PendingRegistrationQuery>, 720 + ) -> Result<Json<PendingRegistrationResponse>, ApiError> { 721 + let client_ip = extract_client_ip(&headers, None); 722 + if !state 723 + .check_rate_limit(RateLimitKind::SsoCallback, &client_ip) 724 + .await 725 + { 726 + tracing::warn!(ip = %client_ip, "SSO pending registration rate limit exceeded"); 727 + return Err(ApiError::RateLimitExceeded(None)); 728 + } 729 + 730 + if query.token.len() > 100 { 731 + return Err(ApiError::InvalidRequest("Invalid token".into())); 732 + } 733 + 734 + let pending = state 735 + .sso_repo 736 + .get_pending_registration(&query.token) 737 + .await? 738 + .ok_or(ApiError::SsoSessionExpired)?; 739 + 740 + Ok(Json(PendingRegistrationResponse { 741 + request_uri: pending.request_uri, 742 + provider: pending.provider.as_str().to_string(), 743 + provider_user_id: pending.provider_user_id, 744 + provider_username: pending.provider_username, 745 + provider_email: pending.provider_email, 746 + provider_email_verified: pending.provider_email_verified, 747 + })) 748 + } 749 + 750 + #[derive(Debug, Deserialize)] 751 + pub struct CheckHandleQuery { 752 + pub handle: String, 753 + } 754 + 755 + #[derive(Debug, Serialize)] 756 + pub struct CheckHandleResponse { 757 + pub available: bool, 758 + pub reason: Option<String>, 759 + } 760 + 761 + pub async fn check_handle_available( 762 + State(state): State<AppState>, 763 + Query(query): Query<CheckHandleQuery>, 764 + ) -> Result<Json<CheckHandleResponse>, ApiError> { 765 + if query.handle.len() > 100 { 766 + return Ok(Json(CheckHandleResponse { 767 + available: false, 768 + reason: Some("Handle too long".into()), 769 + })); 770 + } 771 + 772 + let validated = match crate::api::validation::validate_short_handle(&query.handle) { 773 + Ok(h) => h, 774 + Err(e) => { 775 + return Ok(Json(CheckHandleResponse { 776 + available: false, 777 + reason: Some(e.to_string()), 778 + })); 779 + } 780 + }; 781 + 782 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 783 + let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); 784 + let full_handle = format!("{}.{}", validated, hostname_for_handles); 785 + let handle_typed = crate::types::Handle::new_unchecked(&full_handle); 786 + 787 + let db_available = state 788 + .user_repo 789 + .check_handle_available_for_new_account(&handle_typed) 790 + .await 791 + .unwrap_or(false); 792 + 793 + if !db_available { 794 + return Ok(Json(CheckHandleResponse { 795 + available: false, 796 + reason: Some("Handle is already taken".into()), 797 + })); 798 + } 799 + 800 + Ok(Json(CheckHandleResponse { 801 + available: true, 802 + reason: None, 803 + })) 804 + } 805 + 806 + #[derive(Debug, Deserialize)] 807 + pub struct CompleteRegistrationInput { 808 + pub token: String, 809 + pub handle: String, 810 + pub email: Option<String>, 811 + pub invite_code: Option<String>, 812 + pub verification_channel: Option<String>, 813 + pub discord_id: Option<String>, 814 + pub telegram_username: Option<String>, 815 + pub signal_number: Option<String>, 816 + } 817 + 818 + #[derive(Debug, Serialize)] 819 + #[serde(rename_all = "camelCase")] 820 + pub struct CompleteRegistrationResponse { 821 + pub did: String, 822 + pub handle: String, 823 + pub redirect_url: String, 824 + #[serde(skip_serializing_if = "Option::is_none")] 825 + pub access_jwt: Option<String>, 826 + #[serde(skip_serializing_if = "Option::is_none")] 827 + pub refresh_jwt: Option<String>, 828 + } 829 + 830 + pub async fn complete_registration( 831 + State(state): State<AppState>, 832 + headers: HeaderMap, 833 + Json(input): Json<CompleteRegistrationInput>, 834 + ) -> Result<Json<CompleteRegistrationResponse>, ApiError> { 835 + use jacquard_common::types::{integer::LimitedU32, string::Tid}; 836 + use jacquard_repo::{mst::Mst, storage::BlockStore}; 837 + use k256::ecdsa::SigningKey; 838 + use rand::rngs::OsRng; 839 + use serde_json::json; 840 + use std::sync::Arc; 841 + 842 + let client_ip = extract_client_ip(&headers, None); 843 + if !state 844 + .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) 845 + .await 846 + { 847 + tracing::warn!(ip = %client_ip, "SSO registration rate limit exceeded"); 848 + return Err(ApiError::RateLimitExceeded(None)); 849 + } 850 + 851 + if input.token.len() > 100 { 852 + return Err(ApiError::InvalidRequest("Invalid token".into())); 853 + } 854 + 855 + if input.handle.len() > 100 { 856 + return Err(ApiError::InvalidHandle(None)); 857 + } 858 + 859 + let pending_preview = state 860 + .sso_repo 861 + .get_pending_registration(&input.token) 862 + .await? 863 + .ok_or(ApiError::SsoSessionExpired)?; 864 + 865 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 866 + let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); 867 + 868 + let handle = match crate::api::validation::validate_short_handle(&input.handle) { 869 + Ok(h) => format!("{}.{}", h, hostname_for_handles), 870 + Err(_) => return Err(ApiError::InvalidHandle(None)), 871 + }; 872 + 873 + let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); 874 + let verification_recipient = match verification_channel { 875 + "email" => { 876 + let email = input 877 + .email 878 + .clone() 879 + .or_else(|| pending_preview.provider_email.clone()) 880 + .map(|e| e.trim().to_string()) 881 + .filter(|e| !e.is_empty()); 882 + match email { 883 + Some(e) if !e.is_empty() => e, 884 + _ => return Err(ApiError::MissingEmail), 885 + } 886 + } 887 + "discord" => match &input.discord_id { 888 + Some(id) if !id.trim().is_empty() => id.trim().to_string(), 889 + _ => return Err(ApiError::MissingDiscordId), 890 + }, 891 + "telegram" => match &input.telegram_username { 892 + Some(username) if !username.trim().is_empty() => username.trim().to_string(), 893 + _ => return Err(ApiError::MissingTelegramUsername), 894 + }, 895 + "signal" => match &input.signal_number { 896 + Some(number) if !number.trim().is_empty() => number.trim().to_string(), 897 + _ => return Err(ApiError::MissingSignalNumber), 898 + }, 899 + _ => return Err(ApiError::InvalidVerificationChannel), 900 + }; 901 + 902 + let email = input 903 + .email 904 + .clone() 905 + .or_else(|| pending_preview.provider_email.clone()) 906 + .map(|e| e.trim().to_string()) 907 + .filter(|e| !e.is_empty()); 908 + 909 + let email = match &email { 910 + Some(e) => { 911 + if e.len() > 254 { 912 + return Err(ApiError::InvalidEmail); 913 + } 914 + if !crate::api::validation::is_valid_email(e) { 915 + return Err(ApiError::InvalidEmail); 916 + } 917 + let email_exists = state 918 + .user_repo 919 + .check_email_exists(e, uuid::Uuid::nil()) 920 + .await 921 + .unwrap_or(true); 922 + if email_exists { 923 + return Err(ApiError::EmailTaken); 924 + } 925 + Some(e.clone()) 926 + } 927 + None => None, 928 + }; 929 + 930 + if let Some(ref code) = input.invite_code { 931 + let valid = state 932 + .infra_repo 933 + .is_invite_code_valid(code) 934 + .await 935 + .unwrap_or(false); 936 + if !valid { 937 + return Err(ApiError::InvalidInviteCode); 938 + } 939 + } else { 940 + let invite_required = std::env::var("INVITE_CODE_REQUIRED") 941 + .map(|v| v == "true" || v == "1") 942 + .unwrap_or(false); 943 + if invite_required { 944 + return Err(ApiError::InviteCodeRequired); 945 + } 946 + } 947 + 948 + let handle_typed = crate::types::Handle::new_unchecked(&handle); 949 + let reserved = state 950 + .user_repo 951 + .reserve_handle(&handle_typed, &client_ip) 952 + .await 953 + .unwrap_or(false); 954 + 955 + if !reserved { 956 + return Err(ApiError::HandleNotAvailable(None)); 957 + } 958 + 959 + let secret_key = k256::SecretKey::random(&mut OsRng); 960 + let secret_key_bytes = secret_key.to_bytes().to_vec(); 961 + let signing_key = match SigningKey::from_slice(&secret_key_bytes) { 962 + Ok(k) => k, 963 + Err(e) => { 964 + tracing::error!("Error creating signing key: {:?}", e); 965 + return Err(ApiError::InternalError(None)); 966 + } 967 + }; 968 + 969 + let pds_endpoint = format!("https://{}", hostname); 970 + let rotation_key = std::env::var("PLC_ROTATION_KEY") 971 + .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); 972 + 973 + let genesis_result = match crate::plc::create_genesis_operation( 974 + &signing_key, 975 + &rotation_key, 976 + &handle, 977 + &pds_endpoint, 978 + ) { 979 + Ok(r) => r, 980 + Err(e) => { 981 + tracing::error!("Error creating PLC genesis operation: {:?}", e); 982 + return Err(ApiError::InternalError(Some( 983 + "Failed to create PLC operation".into(), 984 + ))); 985 + } 986 + }; 987 + 988 + let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); 989 + if let Err(e) = plc_client 990 + .send_operation(&genesis_result.did, &genesis_result.signed_operation) 991 + .await 992 + { 993 + tracing::error!("Failed to submit PLC genesis operation: {:?}", e); 994 + return Err(ApiError::UpstreamErrorMsg(format!( 995 + "Failed to register DID with PLC directory: {}", 996 + e 997 + ))); 998 + } 999 + 1000 + let did = genesis_result.did; 1001 + tracing::info!(did = %did, handle = %handle, provider = %pending_preview.provider.as_str(), "Created DID for SSO account"); 1002 + 1003 + let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 1004 + Ok(bytes) => bytes, 1005 + Err(e) => { 1006 + tracing::error!("Error encrypting signing key: {:?}", e); 1007 + return Err(ApiError::InternalError(None)); 1008 + } 1009 + }; 1010 + 1011 + let mst = Mst::new(Arc::new(state.block_store.clone())); 1012 + let mst_root = match mst.persist().await { 1013 + Ok(c) => c, 1014 + Err(e) => { 1015 + tracing::error!("Error persisting MST: {:?}", e); 1016 + return Err(ApiError::InternalError(None)); 1017 + } 1018 + }; 1019 + 1020 + let rev = Tid::now(LimitedU32::MIN); 1021 + let did_typed = crate::types::Did::new_unchecked(&did); 1022 + let (commit_bytes, _sig) = match crate::api::repo::record::utils::create_signed_commit( 1023 + &did_typed, 1024 + mst_root, 1025 + rev.as_ref(), 1026 + None, 1027 + &signing_key, 1028 + ) { 1029 + Ok(result) => result, 1030 + Err(e) => { 1031 + tracing::error!("Error creating genesis commit: {:?}", e); 1032 + return Err(ApiError::InternalError(None)); 1033 + } 1034 + }; 1035 + 1036 + let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { 1037 + Ok(c) => c, 1038 + Err(e) => { 1039 + tracing::error!("Error saving genesis commit: {:?}", e); 1040 + return Err(ApiError::InternalError(None)); 1041 + } 1042 + }; 1043 + 1044 + let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; 1045 + 1046 + let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| { 1047 + json!({ 1048 + "$type": "app.bsky.actor.defs#personalDetailsPref", 1049 + "birthDate": "1998-05-06T00:00:00.000Z" 1050 + }) 1051 + }); 1052 + 1053 + let preferred_comms_channel = match verification_channel { 1054 + "email" => tranquil_db_traits::CommsChannel::Email, 1055 + "discord" => tranquil_db_traits::CommsChannel::Discord, 1056 + "telegram" => tranquil_db_traits::CommsChannel::Telegram, 1057 + "signal" => tranquil_db_traits::CommsChannel::Signal, 1058 + _ => tranquil_db_traits::CommsChannel::Email, 1059 + }; 1060 + 1061 + let create_input = tranquil_db_traits::CreateSsoAccountInput { 1062 + handle: handle_typed.clone(), 1063 + email: email.clone(), 1064 + did: did_typed.clone(), 1065 + preferred_comms_channel, 1066 + discord_id: input 1067 + .discord_id 1068 + .clone() 1069 + .map(|s| s.trim().to_string()) 1070 + .filter(|s| !s.is_empty()), 1071 + telegram_username: input 1072 + .telegram_username 1073 + .clone() 1074 + .map(|s| s.trim().to_string()) 1075 + .filter(|s| !s.is_empty()), 1076 + signal_number: input 1077 + .signal_number 1078 + .clone() 1079 + .map(|s| s.trim().to_string()) 1080 + .filter(|s| !s.is_empty()), 1081 + encrypted_key_bytes: encrypted_key_bytes.clone(), 1082 + encryption_version: crate::config::ENCRYPTION_VERSION, 1083 + commit_cid: commit_cid.to_string(), 1084 + repo_rev: rev.as_ref().to_string(), 1085 + genesis_block_cids, 1086 + invite_code: input.invite_code.clone(), 1087 + birthdate_pref, 1088 + sso_provider: pending_preview.provider, 1089 + sso_provider_user_id: pending_preview.provider_user_id.clone(), 1090 + sso_provider_username: pending_preview.provider_username.clone(), 1091 + sso_provider_email: pending_preview.provider_email.clone(), 1092 + sso_provider_email_verified: pending_preview.provider_email_verified, 1093 + pending_registration_token: input.token.clone(), 1094 + }; 1095 + 1096 + let _create_result = match state.user_repo.create_sso_account(&create_input).await { 1097 + Ok(r) => r, 1098 + Err(tranquil_db_traits::CreateAccountError::HandleTaken) => { 1099 + return Err(ApiError::HandleNotAvailable(None)); 1100 + } 1101 + Err(tranquil_db_traits::CreateAccountError::EmailTaken) => { 1102 + return Err(ApiError::EmailTaken); 1103 + } 1104 + Err(tranquil_db_traits::CreateAccountError::InvalidToken) => { 1105 + return Err(ApiError::SsoSessionExpired); 1106 + } 1107 + Err(e) => { 1108 + tracing::error!("Error creating SSO account: {:?}", e); 1109 + return Err(ApiError::InternalError(None)); 1110 + } 1111 + }; 1112 + 1113 + let _ = state 1114 + .user_repo 1115 + .release_handle_reservation(&handle_typed) 1116 + .await; 1117 + 1118 + if let Err(e) = 1119 + crate::api::repo::record::sequence_identity_event(&state, &did_typed, Some(&handle_typed)) 1120 + .await 1121 + { 1122 + tracing::warn!("Failed to sequence identity event for {}: {}", did, e); 1123 + } 1124 + if let Err(e) = 1125 + crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await 1126 + { 1127 + tracing::warn!("Failed to sequence account event for {}: {}", did, e); 1128 + } 1129 + 1130 + let profile_record = json!({ 1131 + "$type": "app.bsky.actor.profile", 1132 + "displayName": handle_typed.as_str() 1133 + }); 1134 + let profile_collection = crate::types::Nsid::new_unchecked("app.bsky.actor.profile"); 1135 + let profile_rkey = crate::types::Rkey::new_unchecked("self"); 1136 + if let Err(e) = crate::api::repo::record::create_record_internal( 1137 + &state, 1138 + &did_typed, 1139 + &profile_collection, 1140 + &profile_rkey, 1141 + &profile_record, 1142 + ) 1143 + .await 1144 + { 1145 + tracing::warn!("Failed to create default profile for {}: {}", did, e); 1146 + } 1147 + 1148 + let is_standalone = pending_preview.request_uri == "standalone"; 1149 + 1150 + if !is_standalone { 1151 + let request_id = RequestId::new(pending_preview.request_uri.clone()); 1152 + if let Err(e) = state 1153 + .oauth_repo 1154 + .set_authorization_did(&request_id, &did_typed, None) 1155 + .await 1156 + { 1157 + tracing::error!("Failed to set authorization DID: {:?}", e); 1158 + return Err(ApiError::InternalError(None)); 1159 + } 1160 + } 1161 + 1162 + tracing::info!( 1163 + did = %did, 1164 + handle = %handle, 1165 + provider = %pending_preview.provider.as_str(), 1166 + provider_user_id = %pending_preview.provider_user_id, 1167 + standalone = %is_standalone, 1168 + "SSO registration completed successfully" 1169 + ); 1170 + 1171 + let user_id = state 1172 + .user_repo 1173 + .get_id_by_did(&did_typed) 1174 + .await 1175 + .unwrap_or(None); 1176 + 1177 + let channel_auto_verified = verification_channel == "email" 1178 + && pending_preview.provider_email_verified 1179 + && pending_preview.provider_email.as_ref() == email.as_ref(); 1180 + 1181 + if channel_auto_verified { 1182 + let _ = state 1183 + .user_repo 1184 + .set_channel_verified(&did_typed, tranquil_db_traits::CommsChannel::Email) 1185 + .await; 1186 + tracing::info!(did = %did, "Auto-verified email from SSO provider"); 1187 + 1188 + if is_standalone { 1189 + let key_bytes = match crate::config::decrypt_key( 1190 + &encrypted_key_bytes, 1191 + Some(crate::config::ENCRYPTION_VERSION), 1192 + ) { 1193 + Ok(k) => k, 1194 + Err(e) => { 1195 + tracing::error!("Failed to decrypt user key: {:?}", e); 1196 + return Err(ApiError::InternalError(None)); 1197 + } 1198 + }; 1199 + 1200 + let access_meta = match crate::auth::create_access_token_with_metadata(&did, &key_bytes) 1201 + { 1202 + Ok(m) => m, 1203 + Err(e) => { 1204 + tracing::error!("Failed to create access token: {:?}", e); 1205 + return Err(ApiError::InternalError(None)); 1206 + } 1207 + }; 1208 + let refresh_meta = 1209 + match crate::auth::create_refresh_token_with_metadata(&did, &key_bytes) { 1210 + Ok(m) => m, 1211 + Err(e) => { 1212 + tracing::error!("Failed to create refresh token: {:?}", e); 1213 + return Err(ApiError::InternalError(None)); 1214 + } 1215 + }; 1216 + 1217 + let session_data = tranquil_db_traits::SessionTokenCreate { 1218 + did: did_typed.clone(), 1219 + access_jti: access_meta.jti.clone(), 1220 + refresh_jti: refresh_meta.jti.clone(), 1221 + access_expires_at: access_meta.expires_at, 1222 + refresh_expires_at: refresh_meta.expires_at, 1223 + legacy_login: false, 1224 + mfa_verified: false, 1225 + scope: None, 1226 + controller_did: None, 1227 + app_password_name: None, 1228 + }; 1229 + if let Err(e) = state.session_repo.create_session(&session_data).await { 1230 + tracing::error!("Failed to insert session: {:?}", e); 1231 + return Err(ApiError::InternalError(None)); 1232 + } 1233 + 1234 + let hostname = 1235 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1236 + if let Err(e) = crate::comms::comms_repo::enqueue_welcome( 1237 + state.user_repo.as_ref(), 1238 + state.infra_repo.as_ref(), 1239 + user_id.unwrap_or(uuid::Uuid::nil()), 1240 + &hostname, 1241 + ) 1242 + .await 1243 + { 1244 + tracing::warn!("Failed to enqueue welcome notification: {:?}", e); 1245 + } 1246 + 1247 + return Ok(Json(CompleteRegistrationResponse { 1248 + did, 1249 + handle, 1250 + redirect_url: "/app/dashboard".to_string(), 1251 + access_jwt: Some(access_meta.token), 1252 + refresh_jwt: Some(refresh_meta.token), 1253 + })); 1254 + } 1255 + 1256 + return Ok(Json(CompleteRegistrationResponse { 1257 + did, 1258 + handle, 1259 + redirect_url: format!( 1260 + "/app/oauth/consent?request_uri={}", 1261 + urlencoding::encode(&pending_preview.request_uri) 1262 + ), 1263 + access_jwt: None, 1264 + refresh_jwt: None, 1265 + })); 1266 + } 1267 + 1268 + if let Some(uid) = user_id { 1269 + let verification_token = crate::auth::verification_token::generate_signup_token( 1270 + &did, 1271 + verification_channel, 1272 + &verification_recipient, 1273 + ); 1274 + let formatted_token = 1275 + crate::auth::verification_token::format_token_for_display(&verification_token); 1276 + if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( 1277 + state.infra_repo.as_ref(), 1278 + uid, 1279 + verification_channel, 1280 + &verification_recipient, 1281 + &formatted_token, 1282 + &hostname, 1283 + ) 1284 + .await 1285 + { 1286 + tracing::warn!("Failed to enqueue signup verification: {:?}", e); 1287 + } 1288 + } 1289 + 1290 + let redirect_url = if is_standalone { 1291 + format!("/app/verify?did={}", urlencoding::encode(&did)) 1292 + } else { 1293 + format!( 1294 + "/app/oauth/verify?request_uri={}", 1295 + urlencoding::encode(&pending_preview.request_uri) 1296 + ) 1297 + }; 1298 + 1299 + Ok(Json(CompleteRegistrationResponse { 1300 + did, 1301 + handle, 1302 + redirect_url, 1303 + access_jwt: None, 1304 + refresh_jwt: None, 1305 + })) 1306 + }
+6
crates/tranquil-pds/src/sso/mod.rs
··· 1 + pub mod config; 2 + pub mod endpoints; 3 + pub mod providers; 4 + 5 + pub use config::SsoConfig; 6 + pub use providers::{AuthUrlResult, SsoError, SsoManager, SsoProvider, SsoUserInfo};
+1126
crates/tranquil-pds/src/sso/providers.rs
··· 1 + use async_trait::async_trait; 2 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 3 + use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, jwk::JwkSet}; 4 + use reqwest::Client; 5 + use serde::{Deserialize, Serialize}; 6 + use std::collections::HashMap; 7 + use std::sync::Arc; 8 + use std::time::{Duration, SystemTime, UNIX_EPOCH}; 9 + use thiserror::Error; 10 + use tokio::sync::{OnceCell, RwLock}; 11 + use tranquil_db_traits::SsoProviderType; 12 + 13 + use super::config::{AppleProviderConfig, ProviderConfig, SsoConfig}; 14 + 15 + const SSO_HTTP_TIMEOUT: Duration = Duration::from_secs(15); 16 + 17 + fn create_http_client() -> Client { 18 + Client::builder() 19 + .timeout(SSO_HTTP_TIMEOUT) 20 + .connect_timeout(Duration::from_secs(5)) 21 + .build() 22 + .expect("Failed to create HTTP client") 23 + } 24 + 25 + #[derive(Debug, Error)] 26 + pub enum SsoError { 27 + #[error("HTTP request failed: {0}")] 28 + Http(#[from] reqwest::Error), 29 + 30 + #[error("Provider error: {0}")] 31 + Provider(String), 32 + 33 + #[error("Invalid response: {0}")] 34 + InvalidResponse(String), 35 + 36 + #[error("OIDC discovery failed: {0}")] 37 + Discovery(String), 38 + 39 + #[error("JWT validation failed: {0}")] 40 + JwtValidation(String), 41 + } 42 + 43 + #[derive(Debug, Clone, Serialize, Deserialize)] 44 + pub struct SsoTokenResponse { 45 + pub access_token: String, 46 + pub token_type: Option<String>, 47 + pub id_token: Option<String>, 48 + } 49 + 50 + #[derive(Debug, Clone)] 51 + pub struct SsoUserInfo { 52 + pub provider_user_id: String, 53 + pub username: Option<String>, 54 + pub email: Option<String>, 55 + pub email_verified: Option<bool>, 56 + } 57 + 58 + pub struct AuthUrlResult { 59 + pub url: String, 60 + pub code_verifier: Option<String>, 61 + } 62 + 63 + #[async_trait] 64 + pub trait SsoProvider: Send + Sync { 65 + fn provider_type(&self) -> SsoProviderType; 66 + fn display_name(&self) -> &str; 67 + fn icon_name(&self) -> &str; 68 + 69 + async fn build_auth_url( 70 + &self, 71 + state: &str, 72 + redirect_uri: &str, 73 + nonce: Option<&str>, 74 + ) -> Result<AuthUrlResult, SsoError>; 75 + 76 + async fn exchange_code( 77 + &self, 78 + code: &str, 79 + redirect_uri: &str, 80 + code_verifier: Option<&str>, 81 + ) -> Result<SsoTokenResponse, SsoError>; 82 + 83 + async fn get_user_info( 84 + &self, 85 + access_token: &str, 86 + id_token: Option<&str>, 87 + expected_nonce: Option<&str>, 88 + ) -> Result<SsoUserInfo, SsoError>; 89 + } 90 + 91 + pub struct GitHubProvider { 92 + client_id: String, 93 + client_secret: String, 94 + http_client: Client, 95 + } 96 + 97 + impl GitHubProvider { 98 + pub fn new(config: &ProviderConfig) -> Self { 99 + Self { 100 + client_id: config.client_id.clone(), 101 + client_secret: config.client_secret.clone(), 102 + http_client: create_http_client(), 103 + } 104 + } 105 + } 106 + 107 + #[derive(Debug, Deserialize)] 108 + struct GitHubTokenResponse { 109 + access_token: String, 110 + token_type: Option<String>, 111 + } 112 + 113 + #[derive(Debug, Deserialize)] 114 + struct GitHubUser { 115 + id: i64, 116 + login: String, 117 + } 118 + 119 + #[derive(Debug, Deserialize)] 120 + struct GitHubEmail { 121 + email: String, 122 + primary: bool, 123 + verified: bool, 124 + } 125 + 126 + #[async_trait] 127 + impl SsoProvider for GitHubProvider { 128 + fn provider_type(&self) -> SsoProviderType { 129 + SsoProviderType::Github 130 + } 131 + 132 + fn display_name(&self) -> &str { 133 + "GitHub" 134 + } 135 + 136 + fn icon_name(&self) -> &str { 137 + "github" 138 + } 139 + 140 + async fn build_auth_url( 141 + &self, 142 + state: &str, 143 + redirect_uri: &str, 144 + _nonce: Option<&str>, 145 + ) -> Result<AuthUrlResult, SsoError> { 146 + let url = format!( 147 + "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}&scope=read:user%20user:email", 148 + urlencoding::encode(&self.client_id), 149 + urlencoding::encode(redirect_uri), 150 + urlencoding::encode(state), 151 + ); 152 + Ok(AuthUrlResult { 153 + url, 154 + code_verifier: None, 155 + }) 156 + } 157 + 158 + async fn exchange_code( 159 + &self, 160 + code: &str, 161 + _redirect_uri: &str, 162 + _code_verifier: Option<&str>, 163 + ) -> Result<SsoTokenResponse, SsoError> { 164 + let resp = self 165 + .http_client 166 + .post("https://github.com/login/oauth/access_token") 167 + .header("Accept", "application/json") 168 + .form(&[ 169 + ("client_id", &self.client_id), 170 + ("client_secret", &self.client_secret), 171 + ("code", &code.to_string()), 172 + ]) 173 + .send() 174 + .await?; 175 + 176 + if !resp.status().is_success() { 177 + let text = resp.text().await.unwrap_or_default(); 178 + return Err(SsoError::Provider(format!("GitHub token error: {}", text))); 179 + } 180 + 181 + let data: GitHubTokenResponse = resp.json().await?; 182 + Ok(SsoTokenResponse { 183 + access_token: data.access_token, 184 + token_type: data.token_type, 185 + id_token: None, 186 + }) 187 + } 188 + 189 + async fn get_user_info( 190 + &self, 191 + access_token: &str, 192 + _id_token: Option<&str>, 193 + _expected_nonce: Option<&str>, 194 + ) -> Result<SsoUserInfo, SsoError> { 195 + let user: GitHubUser = self 196 + .http_client 197 + .get("https://api.github.com/user") 198 + .header("Authorization", format!("Bearer {}", access_token)) 199 + .header("User-Agent", "tranquil-pds") 200 + .send() 201 + .await? 202 + .json() 203 + .await?; 204 + 205 + let emails_result: Result<Vec<GitHubEmail>, _> = self 206 + .http_client 207 + .get("https://api.github.com/user/emails") 208 + .header("Authorization", format!("Bearer {}", access_token)) 209 + .header("User-Agent", "tranquil-pds") 210 + .send() 211 + .await? 212 + .json() 213 + .await; 214 + 215 + let emails = match emails_result { 216 + Ok(e) => e, 217 + Err(e) => { 218 + tracing::warn!( 219 + github_user_id = %user.id, 220 + error = %e, 221 + "Failed to fetch GitHub user emails, continuing without email" 222 + ); 223 + Vec::new() 224 + } 225 + }; 226 + 227 + let primary_email = emails 228 + .iter() 229 + .find(|e| e.primary && e.verified) 230 + .or_else(|| emails.iter().find(|e| e.verified)) 231 + .map(|e| e.email.clone()); 232 + 233 + Ok(SsoUserInfo { 234 + provider_user_id: user.id.to_string(), 235 + username: Some(user.login), 236 + email: primary_email, 237 + email_verified: Some(true), 238 + }) 239 + } 240 + } 241 + 242 + pub struct DiscordProvider { 243 + client_id: String, 244 + client_secret: String, 245 + http_client: Client, 246 + } 247 + 248 + impl DiscordProvider { 249 + pub fn new(config: &ProviderConfig) -> Self { 250 + Self { 251 + client_id: config.client_id.clone(), 252 + client_secret: config.client_secret.clone(), 253 + http_client: create_http_client(), 254 + } 255 + } 256 + } 257 + 258 + #[derive(Debug, Deserialize)] 259 + struct DiscordTokenResponse { 260 + access_token: String, 261 + token_type: String, 262 + } 263 + 264 + #[derive(Debug, Deserialize)] 265 + struct DiscordUser { 266 + id: String, 267 + username: String, 268 + email: Option<String>, 269 + verified: Option<bool>, 270 + } 271 + 272 + #[async_trait] 273 + impl SsoProvider for DiscordProvider { 274 + fn provider_type(&self) -> SsoProviderType { 275 + SsoProviderType::Discord 276 + } 277 + 278 + fn display_name(&self) -> &str { 279 + "Discord" 280 + } 281 + 282 + fn icon_name(&self) -> &str { 283 + "discord" 284 + } 285 + 286 + async fn build_auth_url( 287 + &self, 288 + state: &str, 289 + redirect_uri: &str, 290 + _nonce: Option<&str>, 291 + ) -> Result<AuthUrlResult, SsoError> { 292 + let url = format!( 293 + "https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}&state={}&response_type=code&scope=identify%20email", 294 + urlencoding::encode(&self.client_id), 295 + urlencoding::encode(redirect_uri), 296 + urlencoding::encode(state), 297 + ); 298 + Ok(AuthUrlResult { 299 + url, 300 + code_verifier: None, 301 + }) 302 + } 303 + 304 + async fn exchange_code( 305 + &self, 306 + code: &str, 307 + redirect_uri: &str, 308 + _code_verifier: Option<&str>, 309 + ) -> Result<SsoTokenResponse, SsoError> { 310 + let resp = self 311 + .http_client 312 + .post("https://discord.com/api/oauth2/token") 313 + .form(&[ 314 + ("client_id", &self.client_id), 315 + ("client_secret", &self.client_secret), 316 + ("code", &code.to_string()), 317 + ("grant_type", &"authorization_code".to_string()), 318 + ("redirect_uri", &redirect_uri.to_string()), 319 + ]) 320 + .send() 321 + .await?; 322 + 323 + if !resp.status().is_success() { 324 + let text = resp.text().await.unwrap_or_default(); 325 + return Err(SsoError::Provider(format!("Discord token error: {}", text))); 326 + } 327 + 328 + let data: DiscordTokenResponse = resp.json().await?; 329 + Ok(SsoTokenResponse { 330 + access_token: data.access_token, 331 + token_type: Some(data.token_type), 332 + id_token: None, 333 + }) 334 + } 335 + 336 + async fn get_user_info( 337 + &self, 338 + access_token: &str, 339 + _id_token: Option<&str>, 340 + _expected_nonce: Option<&str>, 341 + ) -> Result<SsoUserInfo, SsoError> { 342 + let user: DiscordUser = self 343 + .http_client 344 + .get("https://discord.com/api/users/@me") 345 + .header("Authorization", format!("Bearer {}", access_token)) 346 + .send() 347 + .await? 348 + .json() 349 + .await?; 350 + 351 + Ok(SsoUserInfo { 352 + provider_user_id: user.id, 353 + username: Some(user.username), 354 + email: user.email, 355 + email_verified: user.verified, 356 + }) 357 + } 358 + } 359 + 360 + #[derive(Debug, Clone, Deserialize)] 361 + pub struct OidcDiscoveryConfig { 362 + pub issuer: String, 363 + pub authorization_endpoint: String, 364 + pub token_endpoint: String, 365 + pub userinfo_endpoint: Option<String>, 366 + pub jwks_uri: Option<String>, 367 + } 368 + 369 + struct OidcDiscoveryCache { 370 + config: OidcDiscoveryConfig, 371 + jwks: Option<JwkSet>, 372 + } 373 + 374 + pub struct OidcProvider { 375 + provider_type: SsoProviderType, 376 + client_id: String, 377 + client_secret: String, 378 + issuer: String, 379 + display_name: String, 380 + http_client: Client, 381 + discovery_cache: OnceCell<OidcDiscoveryCache>, 382 + } 383 + 384 + impl OidcProvider { 385 + pub fn new( 386 + provider_type: SsoProviderType, 387 + config: &ProviderConfig, 388 + default_issuer: Option<&str>, 389 + default_name: &str, 390 + ) -> Option<Self> { 391 + let issuer = config 392 + .issuer 393 + .clone() 394 + .or_else(|| default_issuer.map(String::from))?; 395 + 396 + Some(Self { 397 + provider_type, 398 + client_id: config.client_id.clone(), 399 + client_secret: config.client_secret.clone(), 400 + issuer, 401 + display_name: config 402 + .display_name 403 + .clone() 404 + .unwrap_or_else(|| default_name.to_string()), 405 + http_client: create_http_client(), 406 + discovery_cache: OnceCell::new(), 407 + }) 408 + } 409 + 410 + async fn get_discovery(&self) -> Result<&OidcDiscoveryCache, SsoError> { 411 + self.discovery_cache 412 + .get_or_try_init(|| async { 413 + let discovery_url = format!( 414 + "{}/.well-known/openid-configuration", 415 + self.issuer.trim_end_matches('/') 416 + ); 417 + 418 + tracing::debug!( 419 + provider = %self.provider_type.as_str(), 420 + url = %discovery_url, 421 + "Fetching OIDC discovery document" 422 + ); 423 + 424 + let resp = self 425 + .http_client 426 + .get(&discovery_url) 427 + .send() 428 + .await 429 + .map_err(|e| SsoError::Discovery(e.to_string()))?; 430 + 431 + if !resp.status().is_success() { 432 + return Err(SsoError::Discovery(format!( 433 + "Discovery endpoint returned {}", 434 + resp.status() 435 + ))); 436 + } 437 + 438 + let config: OidcDiscoveryConfig = resp 439 + .json() 440 + .await 441 + .map_err(|e| SsoError::Discovery(e.to_string()))?; 442 + 443 + let jwks = match &config.jwks_uri { 444 + Some(jwks_uri) => { 445 + tracing::debug!( 446 + provider = %self.provider_type.as_str(), 447 + url = %jwks_uri, 448 + "Fetching JWKS" 449 + ); 450 + let jwks_resp = 451 + self.http_client.get(jwks_uri).send().await.map_err(|e| { 452 + SsoError::Discovery(format!("JWKS fetch failed: {}", e)) 453 + })?; 454 + 455 + if jwks_resp.status().is_success() { 456 + Some(jwks_resp.json::<JwkSet>().await.map_err(|e| { 457 + SsoError::Discovery(format!("JWKS parse failed: {}", e)) 458 + })?) 459 + } else { 460 + tracing::warn!( 461 + provider = %self.provider_type.as_str(), 462 + status = %jwks_resp.status(), 463 + "JWKS fetch returned non-success status" 464 + ); 465 + None 466 + } 467 + } 468 + None => None, 469 + }; 470 + 471 + Ok(OidcDiscoveryCache { config, jwks }) 472 + }) 473 + .await 474 + } 475 + 476 + fn generate_pkce() -> (String, String) { 477 + use rand::RngCore; 478 + let mut verifier_bytes = [0u8; 32]; 479 + rand::thread_rng().fill_bytes(&mut verifier_bytes); 480 + let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 481 + 482 + use sha2::{Digest, Sha256}; 483 + let challenge_bytes = Sha256::digest(verifier.as_bytes()); 484 + let challenge = URL_SAFE_NO_PAD.encode(challenge_bytes); 485 + 486 + (verifier, challenge) 487 + } 488 + 489 + fn validate_id_token( 490 + &self, 491 + id_token: &str, 492 + jwks: &JwkSet, 493 + expected_nonce: Option<&str>, 494 + ) -> Result<IdTokenClaims, SsoError> { 495 + let header = jsonwebtoken::decode_header(id_token) 496 + .map_err(|e| SsoError::JwtValidation(format!("Invalid JWT header: {}", e)))?; 497 + 498 + let kid = header 499 + .kid 500 + .as_ref() 501 + .ok_or_else(|| SsoError::JwtValidation("JWT missing kid header".to_string()))?; 502 + 503 + let jwk = jwks 504 + .find(kid) 505 + .ok_or_else(|| SsoError::JwtValidation(format!("No matching JWK for kid: {}", kid)))?; 506 + 507 + let decoding_key = DecodingKey::from_jwk(jwk) 508 + .map_err(|e| SsoError::JwtValidation(format!("Invalid JWK: {}", e)))?; 509 + 510 + let algorithm = match header.alg { 511 + jsonwebtoken::Algorithm::RS256 => Algorithm::RS256, 512 + jsonwebtoken::Algorithm::RS384 => Algorithm::RS384, 513 + jsonwebtoken::Algorithm::RS512 => Algorithm::RS512, 514 + jsonwebtoken::Algorithm::ES256 => Algorithm::ES256, 515 + jsonwebtoken::Algorithm::ES384 => Algorithm::ES384, 516 + alg => { 517 + return Err(SsoError::JwtValidation(format!( 518 + "Unsupported algorithm: {:?}", 519 + alg 520 + ))); 521 + } 522 + }; 523 + 524 + let mut validation = Validation::new(algorithm); 525 + validation.set_audience(&[&self.client_id]); 526 + validation.set_issuer(&[&self.issuer]); 527 + 528 + let token_data = 529 + jsonwebtoken::decode::<IdTokenClaims>(id_token, &decoding_key, &validation) 530 + .map_err(|e| SsoError::JwtValidation(format!("JWT validation failed: {}", e)))?; 531 + 532 + if let Some(expected) = expected_nonce { 533 + match &token_data.claims.nonce { 534 + Some(actual) if actual == expected => {} 535 + Some(actual) => { 536 + return Err(SsoError::JwtValidation(format!( 537 + "Nonce mismatch: expected {}, got {}", 538 + expected, actual 539 + ))); 540 + } 541 + None => { 542 + return Err(SsoError::JwtValidation( 543 + "Missing nonce in id_token".to_string(), 544 + )); 545 + } 546 + } 547 + } 548 + 549 + Ok(token_data.claims) 550 + } 551 + } 552 + 553 + #[derive(Debug, Deserialize)] 554 + struct IdTokenClaims { 555 + sub: String, 556 + #[serde(default)] 557 + email: Option<String>, 558 + #[serde(default)] 559 + email_verified: Option<bool>, 560 + #[serde(default)] 561 + preferred_username: Option<String>, 562 + #[serde(default)] 563 + name: Option<String>, 564 + #[serde(default)] 565 + nonce: Option<String>, 566 + } 567 + 568 + #[async_trait] 569 + impl SsoProvider for OidcProvider { 570 + fn provider_type(&self) -> SsoProviderType { 571 + self.provider_type 572 + } 573 + 574 + fn display_name(&self) -> &str { 575 + &self.display_name 576 + } 577 + 578 + fn icon_name(&self) -> &str { 579 + self.provider_type.icon_name() 580 + } 581 + 582 + async fn build_auth_url( 583 + &self, 584 + state: &str, 585 + redirect_uri: &str, 586 + nonce: Option<&str>, 587 + ) -> Result<AuthUrlResult, SsoError> { 588 + let (verifier, challenge) = Self::generate_pkce(); 589 + 590 + let auth_endpoint = match self.provider_type { 591 + SsoProviderType::Google => "https://accounts.google.com/o/oauth2/v2/auth".to_string(), 592 + SsoProviderType::Gitlab => { 593 + format!("{}/oauth/authorize", self.issuer.trim_end_matches('/')) 594 + } 595 + _ => { 596 + let discovery = self.get_discovery().await?; 597 + discovery.config.authorization_endpoint.clone() 598 + } 599 + }; 600 + 601 + let mut url = format!( 602 + "{}?client_id={}&redirect_uri={}&state={}&response_type=code&scope=openid%20email%20profile&code_challenge={}&code_challenge_method=S256", 603 + auth_endpoint, 604 + urlencoding::encode(&self.client_id), 605 + urlencoding::encode(redirect_uri), 606 + urlencoding::encode(state), 607 + urlencoding::encode(&challenge), 608 + ); 609 + 610 + if let Some(n) = nonce { 611 + url.push_str(&format!("&nonce={}", urlencoding::encode(n))); 612 + } 613 + 614 + Ok(AuthUrlResult { 615 + url, 616 + code_verifier: Some(verifier), 617 + }) 618 + } 619 + 620 + async fn exchange_code( 621 + &self, 622 + code: &str, 623 + redirect_uri: &str, 624 + code_verifier: Option<&str>, 625 + ) -> Result<SsoTokenResponse, SsoError> { 626 + let token_endpoint = match self.provider_type { 627 + SsoProviderType::Google => "https://oauth2.googleapis.com/token".to_string(), 628 + SsoProviderType::Gitlab => format!("{}/oauth/token", self.issuer.trim_end_matches('/')), 629 + _ => { 630 + let discovery = self.get_discovery().await?; 631 + discovery.config.token_endpoint.clone() 632 + } 633 + }; 634 + 635 + let mut params: HashMap<&str, &str> = HashMap::new(); 636 + params.insert("client_id", &self.client_id); 637 + params.insert("client_secret", &self.client_secret); 638 + params.insert("code", code); 639 + params.insert("redirect_uri", redirect_uri); 640 + params.insert("grant_type", "authorization_code"); 641 + 642 + if let Some(verifier) = code_verifier { 643 + params.insert("code_verifier", verifier); 644 + } 645 + 646 + let resp = self 647 + .http_client 648 + .post(&token_endpoint) 649 + .form(&params) 650 + .send() 651 + .await?; 652 + 653 + if !resp.status().is_success() { 654 + let text = resp.text().await.unwrap_or_default(); 655 + return Err(SsoError::Provider(format!("OIDC token error: {}", text))); 656 + } 657 + 658 + #[derive(Deserialize)] 659 + struct TokenResp { 660 + access_token: String, 661 + token_type: Option<String>, 662 + id_token: Option<String>, 663 + } 664 + 665 + let data: TokenResp = resp.json().await?; 666 + Ok(SsoTokenResponse { 667 + access_token: data.access_token, 668 + token_type: data.token_type, 669 + id_token: data.id_token, 670 + }) 671 + } 672 + 673 + async fn get_user_info( 674 + &self, 675 + access_token: &str, 676 + id_token: Option<&str>, 677 + expected_nonce: Option<&str>, 678 + ) -> Result<SsoUserInfo, SsoError> { 679 + if let Some(token) = id_token { 680 + let discovery = self.get_discovery().await?; 681 + if let Some(ref jwks) = discovery.jwks { 682 + match self.validate_id_token(token, jwks, expected_nonce) { 683 + Ok(claims) => { 684 + tracing::debug!( 685 + provider = %self.provider_type.as_str(), 686 + sub = %claims.sub, 687 + "Successfully validated id_token" 688 + ); 689 + return Ok(SsoUserInfo { 690 + provider_user_id: claims.sub, 691 + username: claims.preferred_username.or(claims.name), 692 + email: claims.email, 693 + email_verified: claims.email_verified, 694 + }); 695 + } 696 + Err(e) => { 697 + tracing::warn!( 698 + provider = %self.provider_type.as_str(), 699 + error = %e, 700 + "id_token validation failed, falling back to userinfo endpoint" 701 + ); 702 + } 703 + } 704 + } 705 + } 706 + 707 + let userinfo_endpoint = match self.provider_type { 708 + SsoProviderType::Google => { 709 + "https://openidconnect.googleapis.com/v1/userinfo".to_string() 710 + } 711 + SsoProviderType::Gitlab => { 712 + format!("{}/oauth/userinfo", self.issuer.trim_end_matches('/')) 713 + } 714 + _ => { 715 + let discovery = self.get_discovery().await?; 716 + discovery 717 + .config 718 + .userinfo_endpoint 719 + .clone() 720 + .ok_or_else(|| SsoError::Discovery("No userinfo endpoint".to_string()))? 721 + } 722 + }; 723 + 724 + let resp = self 725 + .http_client 726 + .get(&userinfo_endpoint) 727 + .header("Authorization", format!("Bearer {}", access_token)) 728 + .send() 729 + .await?; 730 + 731 + if !resp.status().is_success() { 732 + let text = resp.text().await.unwrap_or_default(); 733 + return Err(SsoError::Provider(format!("Userinfo error: {}", text))); 734 + } 735 + 736 + #[derive(Deserialize)] 737 + struct UserInfo { 738 + sub: String, 739 + preferred_username: Option<String>, 740 + name: Option<String>, 741 + email: Option<String>, 742 + email_verified: Option<bool>, 743 + } 744 + 745 + let info: UserInfo = resp.json().await?; 746 + Ok(SsoUserInfo { 747 + provider_user_id: info.sub, 748 + username: info.preferred_username.or(info.name), 749 + email: info.email, 750 + email_verified: info.email_verified, 751 + }) 752 + } 753 + } 754 + 755 + struct CachedClientSecret { 756 + secret: String, 757 + expires_at: u64, 758 + } 759 + 760 + pub struct AppleProvider { 761 + client_id: String, 762 + team_id: String, 763 + key_id: String, 764 + private_key_pem: String, 765 + http_client: Client, 766 + client_secret_cache: RwLock<Option<CachedClientSecret>>, 767 + jwks_cache: OnceCell<JwkSet>, 768 + } 769 + 770 + impl AppleProvider { 771 + pub fn new(config: &AppleProviderConfig) -> Result<Self, SsoError> { 772 + let key_pem = config.private_key_pem.replace("\\n", "\n"); 773 + 774 + jsonwebtoken::EncodingKey::from_ec_pem(key_pem.as_bytes()) 775 + .map_err(|e| SsoError::Provider(format!("Invalid Apple private key: {}", e)))?; 776 + 777 + Ok(Self { 778 + client_id: config.client_id.clone(), 779 + team_id: config.team_id.clone(), 780 + key_id: config.key_id.clone(), 781 + private_key_pem: key_pem, 782 + http_client: create_http_client(), 783 + client_secret_cache: RwLock::new(None), 784 + jwks_cache: OnceCell::new(), 785 + }) 786 + } 787 + 788 + fn generate_client_secret(&self) -> Result<(String, u64), SsoError> { 789 + let now = SystemTime::now() 790 + .duration_since(UNIX_EPOCH) 791 + .unwrap() 792 + .as_secs(); 793 + let exp = now + (150 * 24 * 60 * 60); 794 + 795 + #[derive(Serialize)] 796 + struct AppleClientSecretClaims { 797 + iss: String, 798 + iat: u64, 799 + exp: u64, 800 + aud: String, 801 + sub: String, 802 + } 803 + 804 + let claims = AppleClientSecretClaims { 805 + iss: self.team_id.clone(), 806 + iat: now, 807 + exp, 808 + aud: "https://appleid.apple.com".to_string(), 809 + sub: self.client_id.clone(), 810 + }; 811 + 812 + let mut header = Header::new(Algorithm::ES256); 813 + header.kid = Some(self.key_id.clone()); 814 + 815 + let encoding_key = 816 + EncodingKey::from_ec_pem(self.private_key_pem.as_bytes()).map_err(|e| { 817 + SsoError::Provider(format!("Invalid Apple private key for encoding: {}", e)) 818 + })?; 819 + 820 + let token = jsonwebtoken::encode(&header, &claims, &encoding_key).map_err(|e| { 821 + SsoError::Provider(format!("Failed to generate Apple client secret: {}", e)) 822 + })?; 823 + 824 + Ok((token, exp)) 825 + } 826 + 827 + async fn get_client_secret(&self) -> Result<String, SsoError> { 828 + let now = SystemTime::now() 829 + .duration_since(UNIX_EPOCH) 830 + .unwrap() 831 + .as_secs(); 832 + 833 + { 834 + let cache = self.client_secret_cache.read().await; 835 + if let Some(ref cached) = *cache 836 + && cached.expires_at > now + 3600 { 837 + return Ok(cached.secret.clone()); 838 + } 839 + } 840 + 841 + let (secret, expires_at) = self.generate_client_secret()?; 842 + 843 + { 844 + let mut cache = self.client_secret_cache.write().await; 845 + *cache = Some(CachedClientSecret { 846 + secret: secret.clone(), 847 + expires_at, 848 + }); 849 + } 850 + 851 + Ok(secret) 852 + } 853 + 854 + async fn get_jwks(&self) -> Result<&JwkSet, SsoError> { 855 + self.jwks_cache 856 + .get_or_try_init(|| async { 857 + tracing::debug!("Fetching Apple JWKS"); 858 + let resp = self 859 + .http_client 860 + .get("https://appleid.apple.com/auth/keys") 861 + .send() 862 + .await 863 + .map_err(|e| SsoError::Discovery(format!("Apple JWKS fetch failed: {}", e)))?; 864 + 865 + if !resp.status().is_success() { 866 + return Err(SsoError::Discovery(format!( 867 + "Apple JWKS returned {}", 868 + resp.status() 869 + ))); 870 + } 871 + 872 + resp.json::<JwkSet>() 873 + .await 874 + .map_err(|e| SsoError::Discovery(format!("Apple JWKS parse failed: {}", e))) 875 + }) 876 + .await 877 + } 878 + 879 + fn validate_id_token( 880 + &self, 881 + id_token: &str, 882 + jwks: &JwkSet, 883 + expected_nonce: Option<&str>, 884 + ) -> Result<AppleIdTokenClaims, SsoError> { 885 + let header = jsonwebtoken::decode_header(id_token) 886 + .map_err(|e| SsoError::JwtValidation(format!("Invalid JWT header: {}", e)))?; 887 + 888 + let kid = header 889 + .kid 890 + .as_ref() 891 + .ok_or_else(|| SsoError::JwtValidation("JWT missing kid header".to_string()))?; 892 + 893 + let jwk = jwks 894 + .find(kid) 895 + .ok_or_else(|| SsoError::JwtValidation(format!("No matching JWK for kid: {}", kid)))?; 896 + 897 + let decoding_key = DecodingKey::from_jwk(jwk) 898 + .map_err(|e| SsoError::JwtValidation(format!("Invalid JWK: {}", e)))?; 899 + 900 + let mut validation = Validation::new(Algorithm::RS256); 901 + validation.set_audience(&[&self.client_id]); 902 + validation.set_issuer(&["https://appleid.apple.com"]); 903 + 904 + let token_data = 905 + jsonwebtoken::decode::<AppleIdTokenClaims>(id_token, &decoding_key, &validation) 906 + .map_err(|e| SsoError::JwtValidation(format!("JWT validation failed: {}", e)))?; 907 + 908 + if let Some(expected) = expected_nonce { 909 + match &token_data.claims.nonce { 910 + Some(actual) if actual == expected => {} 911 + Some(actual) => { 912 + return Err(SsoError::JwtValidation(format!( 913 + "Nonce mismatch: expected {}, got {}", 914 + expected, actual 915 + ))); 916 + } 917 + None => { 918 + return Err(SsoError::JwtValidation( 919 + "Missing nonce in id_token".to_string(), 920 + )); 921 + } 922 + } 923 + } 924 + 925 + Ok(token_data.claims) 926 + } 927 + } 928 + 929 + #[derive(Debug, Deserialize)] 930 + struct AppleIdTokenClaims { 931 + sub: String, 932 + #[serde(default)] 933 + email: Option<String>, 934 + #[serde(default)] 935 + email_verified: Option<bool>, 936 + #[serde(default)] 937 + nonce: Option<String>, 938 + } 939 + 940 + #[async_trait] 941 + impl SsoProvider for AppleProvider { 942 + fn provider_type(&self) -> SsoProviderType { 943 + SsoProviderType::Apple 944 + } 945 + 946 + fn display_name(&self) -> &str { 947 + "Apple" 948 + } 949 + 950 + fn icon_name(&self) -> &str { 951 + "apple" 952 + } 953 + 954 + async fn build_auth_url( 955 + &self, 956 + state: &str, 957 + redirect_uri: &str, 958 + nonce: Option<&str>, 959 + ) -> Result<AuthUrlResult, SsoError> { 960 + let mut url = format!( 961 + "https://appleid.apple.com/auth/authorize?client_id={}&redirect_uri={}&state={}&response_type=code&scope=name%20email&response_mode=form_post", 962 + urlencoding::encode(&self.client_id), 963 + urlencoding::encode(redirect_uri), 964 + urlencoding::encode(state), 965 + ); 966 + 967 + if let Some(n) = nonce { 968 + url.push_str(&format!("&nonce={}", urlencoding::encode(n))); 969 + } 970 + 971 + Ok(AuthUrlResult { 972 + url, 973 + code_verifier: None, 974 + }) 975 + } 976 + 977 + async fn exchange_code( 978 + &self, 979 + code: &str, 980 + redirect_uri: &str, 981 + _code_verifier: Option<&str>, 982 + ) -> Result<SsoTokenResponse, SsoError> { 983 + let client_secret = self.get_client_secret().await?; 984 + 985 + let resp = self 986 + .http_client 987 + .post("https://appleid.apple.com/auth/token") 988 + .form(&[ 989 + ("client_id", &self.client_id), 990 + ("client_secret", &client_secret), 991 + ("code", &code.to_string()), 992 + ("grant_type", &"authorization_code".to_string()), 993 + ("redirect_uri", &redirect_uri.to_string()), 994 + ]) 995 + .send() 996 + .await?; 997 + 998 + if !resp.status().is_success() { 999 + let text = resp.text().await.unwrap_or_default(); 1000 + return Err(SsoError::Provider(format!("Apple token error: {}", text))); 1001 + } 1002 + 1003 + #[derive(Deserialize)] 1004 + struct AppleTokenResp { 1005 + access_token: String, 1006 + token_type: Option<String>, 1007 + id_token: Option<String>, 1008 + } 1009 + 1010 + let data: AppleTokenResp = resp.json().await?; 1011 + Ok(SsoTokenResponse { 1012 + access_token: data.access_token, 1013 + token_type: data.token_type, 1014 + id_token: data.id_token, 1015 + }) 1016 + } 1017 + 1018 + async fn get_user_info( 1019 + &self, 1020 + _access_token: &str, 1021 + id_token: Option<&str>, 1022 + expected_nonce: Option<&str>, 1023 + ) -> Result<SsoUserInfo, SsoError> { 1024 + let id_token = id_token.ok_or_else(|| { 1025 + SsoError::InvalidResponse("Apple did not return an id_token".to_string()) 1026 + })?; 1027 + 1028 + let jwks = self.get_jwks().await?; 1029 + let claims = self.validate_id_token(id_token, jwks, expected_nonce)?; 1030 + 1031 + tracing::debug!( 1032 + sub = %claims.sub, 1033 + email = ?claims.email, 1034 + "Successfully validated Apple id_token" 1035 + ); 1036 + 1037 + Ok(SsoUserInfo { 1038 + provider_user_id: claims.sub, 1039 + username: None, 1040 + email: claims.email, 1041 + email_verified: claims.email_verified, 1042 + }) 1043 + } 1044 + } 1045 + 1046 + #[derive(Clone)] 1047 + pub struct SsoManager { 1048 + providers: HashMap<SsoProviderType, Arc<dyn SsoProvider>>, 1049 + } 1050 + 1051 + impl SsoManager { 1052 + pub fn from_config(config: &SsoConfig) -> Self { 1053 + let mut providers: HashMap<SsoProviderType, Arc<dyn SsoProvider>> = HashMap::new(); 1054 + 1055 + if let Some(ref cfg) = config.github { 1056 + providers.insert(SsoProviderType::Github, Arc::new(GitHubProvider::new(cfg))); 1057 + } 1058 + 1059 + if let Some(ref cfg) = config.discord { 1060 + providers.insert( 1061 + SsoProviderType::Discord, 1062 + Arc::new(DiscordProvider::new(cfg)), 1063 + ); 1064 + } 1065 + 1066 + if let Some(ref cfg) = config.google 1067 + && let Some(provider) = OidcProvider::new( 1068 + SsoProviderType::Google, 1069 + cfg, 1070 + Some("https://accounts.google.com"), 1071 + "Google", 1072 + ) { 1073 + providers.insert(SsoProviderType::Google, Arc::new(provider)); 1074 + } 1075 + 1076 + if let Some(ref cfg) = config.gitlab 1077 + && let Some(provider) = OidcProvider::new(SsoProviderType::Gitlab, cfg, None, "GitLab") 1078 + { 1079 + providers.insert(SsoProviderType::Gitlab, Arc::new(provider)); 1080 + } 1081 + 1082 + if let Some(ref cfg) = config.oidc 1083 + && let Some(provider) = OidcProvider::new( 1084 + SsoProviderType::Oidc, 1085 + cfg, 1086 + None, 1087 + cfg.display_name.as_deref().unwrap_or("SSO"), 1088 + ) { 1089 + providers.insert(SsoProviderType::Oidc, Arc::new(provider)); 1090 + } 1091 + 1092 + if let Some(ref cfg) = config.apple { 1093 + match AppleProvider::new(cfg) { 1094 + Ok(provider) => { 1095 + providers.insert(SsoProviderType::Apple, Arc::new(provider)); 1096 + } 1097 + Err(e) => { 1098 + tracing::error!(error = %e, "Failed to initialize Apple SSO provider"); 1099 + } 1100 + } 1101 + } 1102 + 1103 + Self { providers } 1104 + } 1105 + 1106 + pub fn get_provider(&self, provider_type: SsoProviderType) -> Option<Arc<dyn SsoProvider>> { 1107 + self.providers.get(&provider_type).cloned() 1108 + } 1109 + 1110 + pub fn enabled_providers(&self) -> Vec<(SsoProviderType, &str, &str)> { 1111 + self.providers 1112 + .iter() 1113 + .map(|(t, p)| (*t, p.display_name(), p.icon_name())) 1114 + .collect() 1115 + } 1116 + 1117 + pub fn is_any_enabled(&self) -> bool { 1118 + !self.providers.is_empty() 1119 + } 1120 + } 1121 + 1122 + impl Default for SsoManager { 1123 + fn default() -> Self { 1124 + Self::from_config(SsoConfig::get()) 1125 + } 1126 + }
+20 -1
crates/tranquil-pds/src/state.rs
··· 4 4 use crate::config::AuthConfig; 5 5 use crate::rate_limit::RateLimiters; 6 6 use crate::repo::PostgresBlockStore; 7 + use crate::sso::{SsoConfig, SsoManager}; 7 8 use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage}; 8 9 use crate::sync::firehose::SequencedEvent; 9 10 use sqlx::PgPool; ··· 13 14 use tranquil_db::{ 14 15 BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository, 15 16 OAuthRepository, PostgresRepositories, RepoEventNotifier, RepoRepository, SessionRepository, 16 - UserRepository, 17 + SsoRepository, UserRepository, 17 18 }; 18 19 19 20 #[derive(Clone)] ··· 38 39 pub cache: Arc<dyn Cache>, 39 40 pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>, 40 41 pub did_resolver: Arc<DidResolver>, 42 + pub sso_repo: Arc<dyn SsoRepository>, 43 + pub sso_manager: SsoManager, 41 44 } 42 45 43 46 pub enum RateLimitKind { ··· 56 59 HandleUpdate, 57 60 HandleUpdateDaily, 58 61 VerificationCheck, 62 + SsoInitiate, 63 + SsoCallback, 64 + SsoUnlink, 59 65 } 60 66 61 67 impl RateLimitKind { ··· 76 82 Self::HandleUpdate => "handle_update", 77 83 Self::HandleUpdateDaily => "handle_update_daily", 78 84 Self::VerificationCheck => "verification_check", 85 + Self::SsoInitiate => "sso_initiate", 86 + Self::SsoCallback => "sso_callback", 87 + Self::SsoUnlink => "sso_unlink", 79 88 } 80 89 } 81 90 ··· 96 105 Self::HandleUpdate => (10, 300_000), 97 106 Self::HandleUpdateDaily => (50, 86_400_000), 98 107 Self::VerificationCheck => (60, 60_000), 108 + Self::SsoInitiate => (10, 60_000), 109 + Self::SsoCallback => (30, 60_000), 110 + Self::SsoUnlink => (10, 60_000), 99 111 } 100 112 } 101 113 } ··· 163 175 let circuit_breakers = Arc::new(CircuitBreakers::new()); 164 176 let (cache, distributed_rate_limiter) = create_cache().await; 165 177 let did_resolver = Arc::new(DidResolver::new()); 178 + let sso_config = SsoConfig::init(); 179 + let sso_manager = SsoManager::from_config(sso_config); 166 180 167 181 Self { 168 182 user_repo: repos.user.clone(), ··· 175 189 backup_repo: repos.backup.clone(), 176 190 backlink_repo: repos.backlink.clone(), 177 191 event_notifier: repos.event_notifier.clone(), 192 + sso_repo: repos.sso.clone(), 178 193 repos, 179 194 block_store, 180 195 blob_store: Arc::new(blob_store), ··· 185 200 cache, 186 201 distributed_rate_limiter, 187 202 did_resolver, 203 + sso_manager, 188 204 } 189 205 } 190 206 ··· 232 248 RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update, 233 249 RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily, 234 250 RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check, 251 + RateLimitKind::SsoInitiate => &self.rate_limiters.sso_initiate, 252 + RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback, 253 + RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink, 235 254 }; 236 255 237 256 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+4 -15
crates/tranquil-pds/src/sync/commit.rs
··· 114 114 let mut repos: Vec<RepoInfo> = Vec::new(); 115 115 for row in rows.iter().take(limit as usize) { 116 116 let cid_str = row.repo_root_cid.to_string(); 117 - let rev = match get_rev_from_commit(&state, &cid_str).await { 118 - Some(r) => r, 119 - None => { 120 - if let Some(ref stored_rev) = row.repo_rev { 121 - stored_rev.clone() 122 - } else { 123 - tracing::warn!( 124 - "Failed to parse commit for DID {} in list_repos: CID {}", 125 - row.did, 126 - row.repo_root_cid 127 - ); 128 - continue; 129 - } 130 - } 131 - }; 117 + let rev = get_rev_from_commit(&state, &cid_str) 118 + .await 119 + .or_else(|| row.repo_rev.clone()) 120 + .unwrap_or_default(); 132 121 let status = if row.takedown_ref.is_some() { 133 122 AccountStatus::Takendown 134 123 } else if row.deactivated_at.is_some() {
+131
crates/tranquil-pds/tests/apple_sso_unit.rs
··· 1 + use base64::Engine as _; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode_header}; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + const TEST_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY----- 7 + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg1G9/WIOAqDBWQd/v 8 + fu+G8OdNg3cVx9sdnp90JRpm8j6hRANCAAR9NOwKON6tu9NG1jtyqqsAuDDq18lc 9 + z+h/EEbR9hbfBEuCzxKhLrlYFLDLNrE/N3KkIPlQm38hnjUO3QXW0ZhY 10 + -----END PRIVATE KEY-----"; 11 + 12 + const TEST_PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY----- 13 + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfTTsCjjerbvTRtY7cqqrALgw6tfJ 14 + XM/ofxBG0fYW3wRLgs8SoS65WBSwyzaxPzdypCD5UJt/IZ41Dt0F1tGYWA== 15 + -----END PUBLIC KEY-----"; 16 + 17 + const TEST_CLIENT_ID: &str = "com.example.test"; 18 + const TEST_TEAM_ID: &str = "ABCDE12345"; 19 + const TEST_KEY_ID: &str = "KEY123ABCD"; 20 + 21 + #[derive(Debug, Serialize, Deserialize)] 22 + struct AppleClientSecretClaims { 23 + iss: String, 24 + iat: u64, 25 + exp: u64, 26 + aud: String, 27 + sub: String, 28 + } 29 + 30 + fn generate_test_client_secret() -> Result<String, String> { 31 + use std::time::{SystemTime, UNIX_EPOCH}; 32 + 33 + let now = SystemTime::now() 34 + .duration_since(UNIX_EPOCH) 35 + .unwrap() 36 + .as_secs(); 37 + let exp = now + (150 * 24 * 60 * 60); 38 + 39 + let claims = AppleClientSecretClaims { 40 + iss: TEST_TEAM_ID.to_string(), 41 + iat: now, 42 + exp, 43 + aud: "https://appleid.apple.com".to_string(), 44 + sub: TEST_CLIENT_ID.to_string(), 45 + }; 46 + 47 + let mut header = jsonwebtoken::Header::new(Algorithm::ES256); 48 + header.kid = Some(TEST_KEY_ID.to_string()); 49 + 50 + let encoding_key = jsonwebtoken::EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()) 51 + .map_err(|e| format!("Failed to create encoding key: {}", e))?; 52 + 53 + jsonwebtoken::encode(&header, &claims, &encoding_key) 54 + .map_err(|e| format!("Failed to encode JWT: {}", e)) 55 + } 56 + 57 + #[test] 58 + fn test_apple_client_secret_generation() { 59 + let token = generate_test_client_secret().expect("Failed to generate client secret"); 60 + 61 + assert!(!token.is_empty()); 62 + 63 + let parts: Vec<&str> = token.split('.').collect(); 64 + assert_eq!(parts.len(), 3, "JWT should have 3 parts"); 65 + 66 + let header = decode_header(&token).expect("Failed to decode header"); 67 + assert_eq!(header.alg, Algorithm::ES256); 68 + assert_eq!(header.kid, Some(TEST_KEY_ID.to_string())); 69 + } 70 + 71 + #[test] 72 + fn test_apple_client_secret_claims() { 73 + let token = generate_test_client_secret().expect("Failed to generate client secret"); 74 + 75 + let parts: Vec<&str> = token.split('.').collect(); 76 + let payload_bytes = URL_SAFE_NO_PAD 77 + .decode(parts[1]) 78 + .expect("Failed to decode payload"); 79 + let claims: AppleClientSecretClaims = 80 + serde_json::from_slice(&payload_bytes).expect("Failed to parse claims"); 81 + 82 + assert_eq!(claims.iss, TEST_TEAM_ID); 83 + assert_eq!(claims.sub, TEST_CLIENT_ID); 84 + assert_eq!(claims.aud, "https://appleid.apple.com"); 85 + assert!(claims.exp > claims.iat); 86 + 87 + let expected_exp_days = (claims.exp - claims.iat) / (24 * 60 * 60); 88 + assert_eq!(expected_exp_days, 150, "Token should expire in 150 days"); 89 + } 90 + 91 + #[test] 92 + fn test_apple_client_secret_signature_valid() { 93 + let token = generate_test_client_secret().expect("Failed to generate client secret"); 94 + 95 + let decoding_key = DecodingKey::from_ec_pem(TEST_PUBLIC_KEY_PEM.as_bytes()) 96 + .expect("Failed to create decoding key"); 97 + 98 + let mut validation = Validation::new(Algorithm::ES256); 99 + validation.set_audience(&["https://appleid.apple.com"]); 100 + validation.set_issuer(&[TEST_TEAM_ID]); 101 + 102 + let token_data = 103 + jsonwebtoken::decode::<AppleClientSecretClaims>(&token, &decoding_key, &validation) 104 + .expect("Failed to decode and verify token"); 105 + 106 + assert_eq!(token_data.claims.iss, TEST_TEAM_ID); 107 + assert_eq!(token_data.claims.sub, TEST_CLIENT_ID); 108 + assert_eq!(token_data.claims.aud, "https://appleid.apple.com"); 109 + } 110 + 111 + #[test] 112 + fn test_apple_private_key_validation() { 113 + let result = jsonwebtoken::EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()); 114 + assert!( 115 + result.is_ok(), 116 + "Should parse valid PKCS#8 P-256 private key" 117 + ); 118 + 119 + let invalid_pem = "-----BEGIN PRIVATE KEY-----\ninvalid\n-----END PRIVATE KEY-----"; 120 + let result = jsonwebtoken::EncodingKey::from_ec_pem(invalid_pem.as_bytes()); 121 + assert!(result.is_err(), "Should reject invalid private key"); 122 + } 123 + 124 + #[test] 125 + fn test_apple_private_key_escaped_newlines() { 126 + let escaped_pem = TEST_PRIVATE_KEY_PEM.replace('\n', "\\n"); 127 + let unescaped = escaped_pem.replace("\\n", "\n"); 128 + 129 + let result = jsonwebtoken::EncodingKey::from_ec_pem(unescaped.as_bytes()); 130 + assert!(result.is_ok(), "Should handle escaped newlines in PEM"); 131 + }
+1159
crates/tranquil-pds/tests/sso.rs
··· 1 + mod common; 2 + 3 + use common::{base_url, client, create_account_and_login, get_test_db_pool}; 4 + use reqwest::StatusCode; 5 + use serde_json::{Value, json}; 6 + use tranquil_db_traits::SsoProviderType; 7 + use tranquil_types::Did; 8 + 9 + #[tokio::test] 10 + async fn test_sso_providers_endpoint() { 11 + let url = base_url().await; 12 + let client = client(); 13 + 14 + let res = client 15 + .get(format!("{}/oauth/sso/providers", url)) 16 + .send() 17 + .await 18 + .unwrap(); 19 + 20 + assert_eq!(res.status(), StatusCode::OK); 21 + let body: Value = res.json().await.unwrap(); 22 + assert!(body["providers"].is_array()); 23 + } 24 + 25 + #[tokio::test] 26 + async fn test_sso_initiate_invalid_provider() { 27 + let url = base_url().await; 28 + let client = client(); 29 + 30 + let res = client 31 + .post(format!("{}/oauth/sso/initiate", url)) 32 + .json(&json!({ 33 + "provider": "nonexistent_provider", 34 + "request_uri": "urn:test:request", 35 + "action": "login" 36 + })) 37 + .send() 38 + .await 39 + .unwrap(); 40 + 41 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 42 + let body: Value = res.json().await.unwrap(); 43 + assert_eq!(body["error"], "SsoProviderNotFound"); 44 + } 45 + 46 + #[tokio::test] 47 + async fn test_sso_initiate_invalid_action() { 48 + let url = base_url().await; 49 + let client = client(); 50 + 51 + let res = client 52 + .post(format!("{}/oauth/sso/initiate", url)) 53 + .json(&json!({ 54 + "provider": "github", 55 + "request_uri": "urn:test:request", 56 + "action": "invalid_action" 57 + })) 58 + .send() 59 + .await 60 + .unwrap(); 61 + 62 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 63 + let body: Value = res.json().await.unwrap(); 64 + assert!( 65 + body["error"] == "SsoInvalidAction" || body["error"] == "SsoProviderNotEnabled", 66 + "Expected SsoInvalidAction or SsoProviderNotEnabled, got: {}", 67 + body["error"] 68 + ); 69 + } 70 + 71 + #[tokio::test] 72 + async fn test_sso_linked_requires_auth() { 73 + let url = base_url().await; 74 + let client = client(); 75 + 76 + let res = client 77 + .get(format!("{}/oauth/sso/linked", url)) 78 + .send() 79 + .await 80 + .unwrap(); 81 + 82 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 83 + } 84 + 85 + #[tokio::test] 86 + async fn test_sso_linked_returns_empty_for_new_user() { 87 + let url = base_url().await; 88 + let client = client(); 89 + 90 + let (token, _did) = create_account_and_login(&client).await; 91 + 92 + let res = client 93 + .get(format!("{}/oauth/sso/linked", url)) 94 + .bearer_auth(&token) 95 + .send() 96 + .await 97 + .unwrap(); 98 + 99 + assert_eq!(res.status(), StatusCode::OK); 100 + let body: Value = res.json().await.unwrap(); 101 + assert!(body["accounts"].is_array()); 102 + assert_eq!(body["accounts"].as_array().unwrap().len(), 0); 103 + } 104 + 105 + #[tokio::test] 106 + async fn test_sso_unlink_requires_auth() { 107 + let url = base_url().await; 108 + let client = client(); 109 + 110 + let res = client 111 + .post(format!("{}/oauth/sso/unlink", url)) 112 + .json(&json!({ 113 + "id": "00000000-0000-0000-0000-000000000000" 114 + })) 115 + .send() 116 + .await 117 + .unwrap(); 118 + 119 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 120 + } 121 + 122 + #[tokio::test] 123 + async fn test_sso_unlink_invalid_id() { 124 + let url = base_url().await; 125 + let client = client(); 126 + 127 + let (token, _did) = create_account_and_login(&client).await; 128 + 129 + let res = client 130 + .post(format!("{}/oauth/sso/unlink", url)) 131 + .bearer_auth(&token) 132 + .json(&json!({ 133 + "id": "not-a-uuid" 134 + })) 135 + .send() 136 + .await 137 + .unwrap(); 138 + 139 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 140 + let body: Value = res.json().await.unwrap(); 141 + assert_eq!(body["error"], "InvalidId"); 142 + } 143 + 144 + #[tokio::test] 145 + async fn test_sso_unlink_not_found() { 146 + let url = base_url().await; 147 + let client = client(); 148 + 149 + let (token, _did) = create_account_and_login(&client).await; 150 + 151 + let res = client 152 + .post(format!("{}/oauth/sso/unlink", url)) 153 + .bearer_auth(&token) 154 + .json(&json!({ 155 + "id": "00000000-0000-0000-0000-000000000000" 156 + })) 157 + .send() 158 + .await 159 + .unwrap(); 160 + 161 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 162 + let body: Value = res.json().await.unwrap(); 163 + assert_eq!(body["error"], "SsoLinkNotFound"); 164 + } 165 + 166 + #[tokio::test] 167 + async fn test_sso_callback_missing_params() { 168 + let url = base_url().await; 169 + let client = reqwest::Client::builder() 170 + .redirect(reqwest::redirect::Policy::none()) 171 + .build() 172 + .unwrap(); 173 + 174 + let res = client 175 + .get(format!("{}/oauth/sso/callback", url)) 176 + .send() 177 + .await 178 + .unwrap(); 179 + 180 + assert_eq!(res.status(), StatusCode::SEE_OTHER); 181 + let location = res.headers().get("location").unwrap().to_str().unwrap(); 182 + assert!(location.contains("/app/oauth/error")); 183 + } 184 + 185 + #[tokio::test] 186 + async fn test_sso_callback_with_error() { 187 + let url = base_url().await; 188 + let client = reqwest::Client::builder() 189 + .redirect(reqwest::redirect::Policy::none()) 190 + .build() 191 + .unwrap(); 192 + 193 + let res = client 194 + .get(format!( 195 + "{}/oauth/sso/callback?error=access_denied&error_description=User%20cancelled", 196 + url 197 + )) 198 + .send() 199 + .await 200 + .unwrap(); 201 + 202 + assert_eq!(res.status(), StatusCode::SEE_OTHER); 203 + let location = res.headers().get("location").unwrap().to_str().unwrap(); 204 + assert!(location.contains("/app/oauth/error")); 205 + assert!(location.contains("access_denied")); 206 + } 207 + 208 + #[tokio::test] 209 + async fn test_sso_callback_invalid_state() { 210 + let url = base_url().await; 211 + let client = reqwest::Client::builder() 212 + .redirect(reqwest::redirect::Policy::none()) 213 + .build() 214 + .unwrap(); 215 + 216 + let res = client 217 + .get(format!( 218 + "{}/oauth/sso/callback?code=fake_code&state=invalid_state_token", 219 + url 220 + )) 221 + .send() 222 + .await 223 + .unwrap(); 224 + 225 + assert_eq!(res.status(), StatusCode::SEE_OTHER); 226 + let location = res.headers().get("location").unwrap().to_str().unwrap(); 227 + assert!(location.contains("/app/oauth/error")); 228 + } 229 + 230 + #[tokio::test] 231 + async fn test_external_identity_repository_crud() { 232 + let _url = base_url().await; 233 + let pool = get_test_db_pool().await; 234 + 235 + let did = Did::new_unchecked(format!( 236 + "did:plc:test{}", 237 + &uuid::Uuid::new_v4().simple().to_string()[..12] 238 + )); 239 + let provider = SsoProviderType::Github; 240 + let provider_user_id = format!("github_user_{}", uuid::Uuid::new_v4().simple()); 241 + 242 + sqlx::query!( 243 + "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", 244 + did.as_str(), 245 + format!("test{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), 246 + format!( 247 + "test{}@example.com", 248 + &uuid::Uuid::new_v4().simple().to_string()[..8] 249 + ) 250 + ) 251 + .execute(pool) 252 + .await 253 + .unwrap(); 254 + 255 + let id: uuid::Uuid = sqlx::query_scalar!( 256 + r#" 257 + INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email) 258 + VALUES ($1, $2, $3, $4, $5) 259 + RETURNING id 260 + "#, 261 + did.as_str(), 262 + provider as SsoProviderType, 263 + &provider_user_id, 264 + Some("testuser"), 265 + Some("test@github.com"), 266 + ) 267 + .fetch_one(pool) 268 + .await 269 + .unwrap(); 270 + 271 + let found = sqlx::query!( 272 + r#" 273 + SELECT id, did, provider as "provider: SsoProviderType", provider_user_id, provider_username, provider_email 274 + FROM external_identities 275 + WHERE provider = $1 AND provider_user_id = $2 276 + "#, 277 + provider as SsoProviderType, 278 + &provider_user_id, 279 + ) 280 + .fetch_optional(pool) 281 + .await 282 + .unwrap(); 283 + 284 + assert!(found.is_some()); 285 + let found = found.unwrap(); 286 + assert_eq!(found.id, id); 287 + assert_eq!(found.did, did.as_str()); 288 + assert_eq!(found.provider_username, Some("testuser".to_string())); 289 + 290 + let identities = sqlx::query!( 291 + r#" 292 + SELECT id FROM external_identities WHERE did = $1 293 + "#, 294 + did.as_str(), 295 + ) 296 + .fetch_all(pool) 297 + .await 298 + .unwrap(); 299 + 300 + assert_eq!(identities.len(), 1); 301 + 302 + sqlx::query!( 303 + r#" 304 + UPDATE external_identities 305 + SET provider_username = $2, last_login_at = NOW() 306 + WHERE id = $1 307 + "#, 308 + id, 309 + "updated_username", 310 + ) 311 + .execute(pool) 312 + .await 313 + .unwrap(); 314 + 315 + let updated = sqlx::query!( 316 + r#"SELECT provider_username, last_login_at FROM external_identities WHERE id = $1"#, 317 + id, 318 + ) 319 + .fetch_one(pool) 320 + .await 321 + .unwrap(); 322 + 323 + assert_eq!( 324 + updated.provider_username, 325 + Some("updated_username".to_string()) 326 + ); 327 + assert!(updated.last_login_at.is_some()); 328 + 329 + let deleted = sqlx::query!( 330 + r#"DELETE FROM external_identities WHERE id = $1 AND did = $2"#, 331 + id, 332 + did.as_str(), 333 + ) 334 + .execute(pool) 335 + .await 336 + .unwrap(); 337 + 338 + assert_eq!(deleted.rows_affected(), 1); 339 + 340 + let not_found = sqlx::query!(r#"SELECT id FROM external_identities WHERE id = $1"#, id,) 341 + .fetch_optional(pool) 342 + .await 343 + .unwrap(); 344 + 345 + assert!(not_found.is_none()); 346 + } 347 + 348 + #[tokio::test] 349 + async fn test_external_identity_unique_constraints() { 350 + let _url = base_url().await; 351 + let pool = get_test_db_pool().await; 352 + 353 + let did1 = Did::new_unchecked(format!( 354 + "did:plc:uc1{}", 355 + &uuid::Uuid::new_v4().simple().to_string()[..10] 356 + )); 357 + let did2 = Did::new_unchecked(format!( 358 + "did:plc:uc2{}", 359 + &uuid::Uuid::new_v4().simple().to_string()[..10] 360 + )); 361 + let provider_user_id = format!("unique_test_{}", uuid::Uuid::new_v4().simple()); 362 + 363 + sqlx::query!( 364 + "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", 365 + did1.as_str(), 366 + format!("uc1{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), 367 + format!( 368 + "uc1{}@example.com", 369 + &uuid::Uuid::new_v4().simple().to_string()[..8] 370 + ) 371 + ) 372 + .execute(pool) 373 + .await 374 + .unwrap(); 375 + 376 + sqlx::query!( 377 + "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", 378 + did2.as_str(), 379 + format!("uc2{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), 380 + format!( 381 + "uc2{}@example.com", 382 + &uuid::Uuid::new_v4().simple().to_string()[..8] 383 + ) 384 + ) 385 + .execute(pool) 386 + .await 387 + .unwrap(); 388 + 389 + sqlx::query!( 390 + r#" 391 + INSERT INTO external_identities (did, provider, provider_user_id) 392 + VALUES ($1, $2, $3) 393 + "#, 394 + did1.as_str(), 395 + SsoProviderType::Github as SsoProviderType, 396 + &provider_user_id, 397 + ) 398 + .execute(pool) 399 + .await 400 + .unwrap(); 401 + 402 + let duplicate_provider_user = sqlx::query!( 403 + r#" 404 + INSERT INTO external_identities (did, provider, provider_user_id) 405 + VALUES ($1, $2, $3) 406 + "#, 407 + did2.as_str(), 408 + SsoProviderType::Github as SsoProviderType, 409 + &provider_user_id, 410 + ) 411 + .execute(pool) 412 + .await; 413 + 414 + assert!(duplicate_provider_user.is_err()); 415 + 416 + let duplicate_did_provider = sqlx::query!( 417 + r#" 418 + INSERT INTO external_identities (did, provider, provider_user_id) 419 + VALUES ($1, $2, $3) 420 + "#, 421 + did1.as_str(), 422 + SsoProviderType::Github as SsoProviderType, 423 + "different_user_id", 424 + ) 425 + .execute(pool) 426 + .await; 427 + 428 + assert!(duplicate_did_provider.is_err()); 429 + 430 + let discord_user_id = format!("discord_user_{}", uuid::Uuid::new_v4().simple()); 431 + let different_provider = sqlx::query!( 432 + r#" 433 + INSERT INTO external_identities (did, provider, provider_user_id) 434 + VALUES ($1, $2, $3) 435 + "#, 436 + did1.as_str(), 437 + SsoProviderType::Discord as SsoProviderType, 438 + &discord_user_id, 439 + ) 440 + .execute(pool) 441 + .await; 442 + 443 + assert!( 444 + different_provider.is_ok(), 445 + "Expected OK but got: {:?}", 446 + different_provider.err() 447 + ); 448 + } 449 + 450 + #[tokio::test] 451 + async fn test_sso_auth_state_lifecycle() { 452 + let _url = base_url().await; 453 + let pool = get_test_db_pool().await; 454 + 455 + let state = format!("test_state_{}", uuid::Uuid::new_v4().simple()); 456 + let request_uri = "urn:ietf:params:oauth:request_uri:test123"; 457 + 458 + sqlx::query!( 459 + r#" 460 + INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier) 461 + VALUES ($1, $2, $3, $4, $5, $6) 462 + "#, 463 + &state, 464 + request_uri, 465 + SsoProviderType::Github as SsoProviderType, 466 + "login", 467 + Some("test_nonce"), 468 + Some("test_verifier"), 469 + ) 470 + .execute(pool) 471 + .await 472 + .unwrap(); 473 + 474 + let found = sqlx::query!( 475 + r#" 476 + SELECT state, request_uri, provider as "provider: SsoProviderType", action, nonce, code_verifier 477 + FROM sso_auth_state 478 + WHERE state = $1 479 + "#, 480 + &state, 481 + ) 482 + .fetch_optional(pool) 483 + .await 484 + .unwrap(); 485 + 486 + assert!(found.is_some()); 487 + let found = found.unwrap(); 488 + assert_eq!(found.request_uri, request_uri); 489 + assert_eq!(found.action, "login"); 490 + assert_eq!(found.nonce, Some("test_nonce".to_string())); 491 + assert_eq!(found.code_verifier, Some("test_verifier".to_string())); 492 + 493 + let consumed = sqlx::query!( 494 + r#" 495 + DELETE FROM sso_auth_state 496 + WHERE state = $1 AND expires_at > NOW() 497 + RETURNING state, request_uri 498 + "#, 499 + &state, 500 + ) 501 + .fetch_optional(pool) 502 + .await 503 + .unwrap(); 504 + 505 + assert!(consumed.is_some()); 506 + 507 + let not_found = sqlx::query!( 508 + r#"SELECT state FROM sso_auth_state WHERE state = $1"#, 509 + &state, 510 + ) 511 + .fetch_optional(pool) 512 + .await 513 + .unwrap(); 514 + 515 + assert!(not_found.is_none()); 516 + 517 + let double_consume = sqlx::query!( 518 + r#" 519 + DELETE FROM sso_auth_state 520 + WHERE state = $1 AND expires_at > NOW() 521 + RETURNING state 522 + "#, 523 + &state, 524 + ) 525 + .fetch_optional(pool) 526 + .await 527 + .unwrap(); 528 + 529 + assert!(double_consume.is_none()); 530 + } 531 + 532 + #[tokio::test] 533 + async fn test_sso_auth_state_expiration() { 534 + let _url = base_url().await; 535 + let pool = get_test_db_pool().await; 536 + 537 + let state = format!("expired_state_{}", uuid::Uuid::new_v4().simple()); 538 + 539 + sqlx::query!( 540 + r#" 541 + INSERT INTO sso_auth_state (state, request_uri, provider, action, expires_at) 542 + VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour') 543 + "#, 544 + &state, 545 + "urn:test:expired", 546 + SsoProviderType::Github as SsoProviderType, 547 + "login", 548 + ) 549 + .execute(pool) 550 + .await 551 + .unwrap(); 552 + 553 + let consumed = sqlx::query!( 554 + r#" 555 + DELETE FROM sso_auth_state 556 + WHERE state = $1 AND expires_at > NOW() 557 + RETURNING state 558 + "#, 559 + &state, 560 + ) 561 + .fetch_optional(pool) 562 + .await 563 + .unwrap(); 564 + 565 + assert!(consumed.is_none()); 566 + 567 + let cleaned = sqlx::query!(r#"DELETE FROM sso_auth_state WHERE expires_at < NOW()"#,) 568 + .execute(pool) 569 + .await 570 + .unwrap(); 571 + 572 + assert!(cleaned.rows_affected() >= 1); 573 + } 574 + 575 + #[tokio::test] 576 + async fn test_delete_external_identity_wrong_did() { 577 + let _url = base_url().await; 578 + let pool = get_test_db_pool().await; 579 + 580 + let did = Did::new_unchecked(format!( 581 + "did:plc:del{}", 582 + &uuid::Uuid::new_v4().simple().to_string()[..10] 583 + )); 584 + let wrong_did = Did::new_unchecked("did:plc:wrongdid12345"); 585 + 586 + sqlx::query!( 587 + "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", 588 + did.as_str(), 589 + format!("del{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), 590 + format!( 591 + "del{}@example.com", 592 + &uuid::Uuid::new_v4().simple().to_string()[..8] 593 + ) 594 + ) 595 + .execute(pool) 596 + .await 597 + .unwrap(); 598 + 599 + let id: uuid::Uuid = sqlx::query_scalar!( 600 + r#" 601 + INSERT INTO external_identities (did, provider, provider_user_id) 602 + VALUES ($1, $2, $3) 603 + RETURNING id 604 + "#, 605 + did.as_str(), 606 + SsoProviderType::Github as SsoProviderType, 607 + format!("delete_test_{}", uuid::Uuid::new_v4().simple()), 608 + ) 609 + .fetch_one(pool) 610 + .await 611 + .unwrap(); 612 + 613 + let wrong_delete = sqlx::query!( 614 + r#"DELETE FROM external_identities WHERE id = $1 AND did = $2"#, 615 + id, 616 + wrong_did.as_str(), 617 + ) 618 + .execute(pool) 619 + .await 620 + .unwrap(); 621 + 622 + assert_eq!(wrong_delete.rows_affected(), 0); 623 + 624 + let still_exists = sqlx::query!(r#"SELECT id FROM external_identities WHERE id = $1"#, id,) 625 + .fetch_optional(pool) 626 + .await 627 + .unwrap(); 628 + 629 + assert!(still_exists.is_some()); 630 + } 631 + 632 + #[tokio::test] 633 + async fn test_sso_pending_registration_lifecycle() { 634 + let _url = base_url().await; 635 + let pool = get_test_db_pool().await; 636 + 637 + let token = format!("pending_token_{}", uuid::Uuid::new_v4().simple()); 638 + let request_uri = "urn:ietf:params:oauth:request_uri:pendingtest"; 639 + let provider_user_id = format!("pending_user_{}", uuid::Uuid::new_v4().simple()); 640 + 641 + sqlx::query!( 642 + r#" 643 + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email) 644 + VALUES ($1, $2, $3, $4, $5, $6) 645 + "#, 646 + &token, 647 + request_uri, 648 + SsoProviderType::Github as SsoProviderType, 649 + &provider_user_id, 650 + Some("pendinguser"), 651 + Some("pending@github.com"), 652 + ) 653 + .execute(pool) 654 + .await 655 + .unwrap(); 656 + 657 + let found = sqlx::query!( 658 + r#" 659 + SELECT token, request_uri, provider as "provider: SsoProviderType", provider_user_id, 660 + provider_username, provider_email 661 + FROM sso_pending_registration 662 + WHERE token = $1 AND expires_at > NOW() 663 + "#, 664 + &token, 665 + ) 666 + .fetch_optional(pool) 667 + .await 668 + .unwrap(); 669 + 670 + assert!(found.is_some()); 671 + let found = found.unwrap(); 672 + assert_eq!(found.request_uri, request_uri); 673 + assert_eq!(found.provider_username, Some("pendinguser".to_string())); 674 + assert_eq!(found.provider_email, Some("pending@github.com".to_string())); 675 + 676 + let consumed = sqlx::query!( 677 + r#" 678 + DELETE FROM sso_pending_registration 679 + WHERE token = $1 AND expires_at > NOW() 680 + RETURNING token, request_uri 681 + "#, 682 + &token, 683 + ) 684 + .fetch_optional(pool) 685 + .await 686 + .unwrap(); 687 + 688 + assert!(consumed.is_some()); 689 + 690 + let double_consume = sqlx::query!( 691 + r#" 692 + DELETE FROM sso_pending_registration 693 + WHERE token = $1 AND expires_at > NOW() 694 + RETURNING token 695 + "#, 696 + &token, 697 + ) 698 + .fetch_optional(pool) 699 + .await 700 + .unwrap(); 701 + 702 + assert!(double_consume.is_none()); 703 + } 704 + 705 + #[tokio::test] 706 + async fn test_sso_pending_registration_expiration() { 707 + let _url = base_url().await; 708 + let pool = get_test_db_pool().await; 709 + 710 + let token = format!("expired_pending_{}", uuid::Uuid::new_v4().simple()); 711 + 712 + sqlx::query!( 713 + r#" 714 + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, expires_at) 715 + VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour') 716 + "#, 717 + &token, 718 + "urn:test:expired_pending", 719 + SsoProviderType::Github as SsoProviderType, 720 + "expired_provider_user", 721 + ) 722 + .execute(pool) 723 + .await 724 + .unwrap(); 725 + 726 + let consumed = sqlx::query!( 727 + r#" 728 + SELECT token FROM sso_pending_registration 729 + WHERE token = $1 AND expires_at > NOW() 730 + "#, 731 + &token, 732 + ) 733 + .fetch_optional(pool) 734 + .await 735 + .unwrap(); 736 + 737 + assert!(consumed.is_none()); 738 + } 739 + 740 + #[tokio::test] 741 + async fn test_sso_complete_registration_invalid_token() { 742 + let url = base_url().await; 743 + let client = client(); 744 + 745 + let res = client 746 + .post(format!("{}/oauth/sso/complete-registration", url)) 747 + .json(&json!({ 748 + "token": "nonexistent_token_12345", 749 + "handle": "newuser" 750 + })) 751 + .send() 752 + .await 753 + .unwrap(); 754 + 755 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 756 + let body: Value = res.json().await.unwrap(); 757 + assert_eq!(body["error"], "SsoSessionExpired"); 758 + } 759 + 760 + #[tokio::test] 761 + async fn test_sso_complete_registration_expired_token() { 762 + let _url = base_url().await; 763 + let pool = get_test_db_pool().await; 764 + 765 + let token = format!("expired_reg_token_{}", uuid::Uuid::new_v4().simple()); 766 + 767 + sqlx::query!( 768 + r#" 769 + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, expires_at) 770 + VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour') 771 + "#, 772 + &token, 773 + "urn:test:expired_registration", 774 + SsoProviderType::Github as SsoProviderType, 775 + "expired_user_123", 776 + ) 777 + .execute(pool) 778 + .await 779 + .unwrap(); 780 + 781 + let client = client(); 782 + let res = client 783 + .post(format!("{}/oauth/sso/complete-registration", _url)) 784 + .json(&json!({ 785 + "token": token, 786 + "handle": "newuser" 787 + })) 788 + .send() 789 + .await 790 + .unwrap(); 791 + 792 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 793 + let body: Value = res.json().await.unwrap(); 794 + assert_eq!(body["error"], "SsoSessionExpired"); 795 + } 796 + 797 + #[tokio::test] 798 + async fn test_sso_get_pending_registration_invalid_token() { 799 + let url = base_url().await; 800 + let client = client(); 801 + 802 + let res = client 803 + .get(format!( 804 + "{}/oauth/sso/pending-registration?token=nonexistent_token", 805 + url 806 + )) 807 + .send() 808 + .await 809 + .unwrap(); 810 + 811 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 812 + let body: Value = res.json().await.unwrap(); 813 + assert_eq!(body["error"], "SsoSessionExpired"); 814 + } 815 + 816 + #[tokio::test] 817 + async fn test_sso_get_pending_registration_token_too_long() { 818 + let url = base_url().await; 819 + let client = client(); 820 + 821 + let long_token = "a".repeat(200); 822 + let res = client 823 + .get(format!( 824 + "{}/oauth/sso/pending-registration?token={}", 825 + url, long_token 826 + )) 827 + .send() 828 + .await 829 + .unwrap(); 830 + 831 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 832 + let body: Value = res.json().await.unwrap(); 833 + assert_eq!(body["error"], "InvalidRequest"); 834 + } 835 + 836 + #[tokio::test] 837 + async fn test_sso_complete_registration_success() { 838 + let url = base_url().await; 839 + let pool = get_test_db_pool().await; 840 + let client = client(); 841 + 842 + let token = format!("success_reg_token_{}", uuid::Uuid::new_v4().simple()); 843 + let handle_prefix = format!("ssoreg{}", &uuid::Uuid::new_v4().simple().to_string()[..6]); 844 + let provider_user_id = format!("success_user_{}", uuid::Uuid::new_v4().simple()); 845 + let provider_email = format!("sso_{}@example.com", uuid::Uuid::new_v4().simple()); 846 + 847 + let request_uri = format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4()); 848 + 849 + sqlx::query!( 850 + r#" 851 + INSERT INTO oauth_authorization_request (id, client_id, parameters, expires_at) 852 + VALUES ($1, 'https://test.example.com', $2, NOW() + INTERVAL '1 hour') 853 + "#, 854 + &request_uri, 855 + serde_json::json!({ 856 + "redirect_uri": "https://test.example.com/callback", 857 + "scope": "atproto", 858 + "state": "teststate", 859 + "code_challenge": "testchallenge", 860 + "code_challenge_method": "S256" 861 + }), 862 + ) 863 + .execute(pool) 864 + .await 865 + .unwrap(); 866 + 867 + sqlx::query!( 868 + r#" 869 + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified) 870 + VALUES ($1, $2, $3, $4, $5, $6, $7) 871 + "#, 872 + &token, 873 + &request_uri, 874 + SsoProviderType::Github as SsoProviderType, 875 + &provider_user_id, 876 + Some("ssouser"), 877 + Some(&provider_email), 878 + true, 879 + ) 880 + .execute(pool) 881 + .await 882 + .unwrap(); 883 + 884 + let res = client 885 + .post(format!("{}/oauth/sso/complete-registration", url)) 886 + .json(&json!({ 887 + "token": token, 888 + "handle": handle_prefix, 889 + "email": provider_email, 890 + "verification_channel": "email" 891 + })) 892 + .send() 893 + .await 894 + .unwrap(); 895 + 896 + assert_eq!(res.status(), StatusCode::OK); 897 + let body: Value = res.json().await.unwrap(); 898 + assert!( 899 + body.get("did").is_some(), 900 + "Expected did in response, got: {:?}", 901 + body 902 + ); 903 + assert!( 904 + body.get("handle").is_some(), 905 + "Expected handle in response, got: {:?}", 906 + body 907 + ); 908 + assert!( 909 + body.get("redirectUrl").is_some(), 910 + "Expected redirectUrl in response, got: {:?}", 911 + body 912 + ); 913 + 914 + let did_str = body["did"].as_str().unwrap(); 915 + assert!(did_str.starts_with("did:plc:")); 916 + 917 + let redirect_url = body["redirectUrl"].as_str().unwrap(); 918 + assert!( 919 + redirect_url.contains("/app/oauth/consent"), 920 + "Auto-verified email should redirect to consent, got: {}", 921 + redirect_url 922 + ); 923 + 924 + let pending_consumed = sqlx::query!( 925 + r#"SELECT token FROM sso_pending_registration WHERE token = $1"#, 926 + &token, 927 + ) 928 + .fetch_optional(pool) 929 + .await 930 + .unwrap(); 931 + 932 + assert!( 933 + pending_consumed.is_none(), 934 + "Pending registration should be consumed after successful registration" 935 + ); 936 + 937 + let user_exists = sqlx::query!( 938 + r#"SELECT did, email_verified FROM users WHERE did = $1"#, 939 + did_str, 940 + ) 941 + .fetch_optional(pool) 942 + .await 943 + .unwrap(); 944 + 945 + assert!(user_exists.is_some(), "User should exist in database"); 946 + let user = user_exists.unwrap(); 947 + assert!( 948 + user.email_verified, 949 + "Email should be auto-verified when provider verified it" 950 + ); 951 + 952 + let external_identity = sqlx::query!( 953 + r#" 954 + SELECT provider_user_id, provider_email_verified 955 + FROM external_identities 956 + WHERE did = $1 AND provider = $2 957 + "#, 958 + did_str, 959 + SsoProviderType::Github as SsoProviderType, 960 + ) 961 + .fetch_optional(pool) 962 + .await 963 + .unwrap(); 964 + 965 + assert!( 966 + external_identity.is_some(), 967 + "External identity should be created" 968 + ); 969 + let ext_id = external_identity.unwrap(); 970 + assert_eq!(ext_id.provider_user_id, provider_user_id); 971 + assert!(ext_id.provider_email_verified); 972 + } 973 + 974 + #[tokio::test] 975 + async fn test_sso_complete_registration_multichannel_discord() { 976 + let url = base_url().await; 977 + let pool = get_test_db_pool().await; 978 + let client = client(); 979 + 980 + let token = format!("discord_reg_token_{}", uuid::Uuid::new_v4().simple()); 981 + let handle_prefix = format!( 982 + "discordreg{}", 983 + &uuid::Uuid::new_v4().simple().to_string()[..4] 984 + ); 985 + let provider_user_id = format!("discord_prov_{}", uuid::Uuid::new_v4().simple()); 986 + let discord_id = "123456789012345678"; 987 + 988 + let request_uri = format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4()); 989 + 990 + sqlx::query!( 991 + r#" 992 + INSERT INTO oauth_authorization_request (id, client_id, parameters, expires_at) 993 + VALUES ($1, 'https://test.example.com', $2, NOW() + INTERVAL '1 hour') 994 + "#, 995 + &request_uri, 996 + serde_json::json!({ 997 + "redirect_uri": "https://test.example.com/callback", 998 + "scope": "atproto", 999 + "state": "teststate", 1000 + "code_challenge": "testchallenge", 1001 + "code_challenge_method": "S256" 1002 + }), 1003 + ) 1004 + .execute(pool) 1005 + .await 1006 + .unwrap(); 1007 + 1008 + sqlx::query!( 1009 + r#" 1010 + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email_verified) 1011 + VALUES ($1, $2, $3, $4, $5, $6) 1012 + "#, 1013 + &token, 1014 + &request_uri, 1015 + SsoProviderType::Discord as SsoProviderType, 1016 + &provider_user_id, 1017 + Some("discorduser"), 1018 + false, 1019 + ) 1020 + .execute(pool) 1021 + .await 1022 + .unwrap(); 1023 + 1024 + let res = client 1025 + .post(format!("{}/oauth/sso/complete-registration", url)) 1026 + .json(&json!({ 1027 + "token": token, 1028 + "handle": handle_prefix, 1029 + "verification_channel": "discord", 1030 + "discord_id": discord_id 1031 + })) 1032 + .send() 1033 + .await 1034 + .unwrap(); 1035 + 1036 + assert_eq!(res.status(), StatusCode::OK); 1037 + let body: Value = res.json().await.unwrap(); 1038 + assert!(body.get("did").is_some()); 1039 + 1040 + let redirect_url = body["redirectUrl"].as_str().unwrap(); 1041 + assert!( 1042 + redirect_url.contains("/app/oauth/verify"), 1043 + "Non-auto-verified channel should redirect to verify, got: {}", 1044 + redirect_url 1045 + ); 1046 + 1047 + let did_str = body["did"].as_str().unwrap(); 1048 + let user = sqlx::query!( 1049 + r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_id FROM users WHERE did = $1"#, 1050 + did_str, 1051 + ) 1052 + .fetch_one(pool) 1053 + .await 1054 + .unwrap(); 1055 + 1056 + assert_eq!(user.preferred_comms_channel, "discord"); 1057 + assert_eq!(user.discord_id, Some(discord_id.to_string())); 1058 + } 1059 + 1060 + #[tokio::test] 1061 + async fn test_sso_check_handle_available() { 1062 + let url = base_url().await; 1063 + let client = client(); 1064 + 1065 + let unique_handle = format!("avail{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); 1066 + let res = client 1067 + .get(format!( 1068 + "{}/oauth/sso/check-handle-available?handle={}", 1069 + url, unique_handle 1070 + )) 1071 + .send() 1072 + .await 1073 + .unwrap(); 1074 + 1075 + assert_eq!(res.status(), StatusCode::OK); 1076 + let body: Value = res.json().await.unwrap(); 1077 + assert_eq!(body["available"], true); 1078 + assert!(body["reason"].is_null()); 1079 + } 1080 + 1081 + #[tokio::test] 1082 + async fn test_sso_check_handle_invalid() { 1083 + let url = base_url().await; 1084 + let client = client(); 1085 + 1086 + let res = client 1087 + .get(format!( 1088 + "{}/oauth/sso/check-handle-available?handle=ab", 1089 + url 1090 + )) 1091 + .send() 1092 + .await 1093 + .unwrap(); 1094 + 1095 + assert_eq!(res.status(), StatusCode::OK); 1096 + let body: Value = res.json().await.unwrap(); 1097 + assert_eq!(body["available"], false); 1098 + assert!(body["reason"].is_string()); 1099 + } 1100 + 1101 + #[tokio::test] 1102 + async fn test_sso_complete_registration_missing_channel_data() { 1103 + let url = base_url().await; 1104 + let pool = get_test_db_pool().await; 1105 + let client = client(); 1106 + 1107 + let token = format!("missing_channel_{}", uuid::Uuid::new_v4().simple()); 1108 + let handle_prefix = format!("missch{}", &uuid::Uuid::new_v4().simple().to_string()[..6]); 1109 + 1110 + let request_uri = format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4()); 1111 + 1112 + sqlx::query!( 1113 + r#" 1114 + INSERT INTO oauth_authorization_request (id, client_id, parameters, expires_at) 1115 + VALUES ($1, 'https://test.example.com', $2, NOW() + INTERVAL '1 hour') 1116 + "#, 1117 + &request_uri, 1118 + serde_json::json!({ 1119 + "redirect_uri": "https://test.example.com/callback", 1120 + "scope": "atproto", 1121 + "state": "teststate", 1122 + "code_challenge": "testchallenge", 1123 + "code_challenge_method": "S256" 1124 + }), 1125 + ) 1126 + .execute(pool) 1127 + .await 1128 + .unwrap(); 1129 + 1130 + sqlx::query!( 1131 + r#" 1132 + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_email_verified) 1133 + VALUES ($1, $2, $3, $4, $5) 1134 + "#, 1135 + &token, 1136 + &request_uri, 1137 + SsoProviderType::Github as SsoProviderType, 1138 + "missing_channel_user", 1139 + false, 1140 + ) 1141 + .execute(pool) 1142 + .await 1143 + .unwrap(); 1144 + 1145 + let res = client 1146 + .post(format!("{}/oauth/sso/complete-registration", url)) 1147 + .json(&json!({ 1148 + "token": token, 1149 + "handle": handle_prefix, 1150 + "verification_channel": "discord" 1151 + })) 1152 + .send() 1153 + .await 1154 + .unwrap(); 1155 + 1156 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 1157 + let body: Value = res.json().await.unwrap(); 1158 + assert_eq!(body["error"], "MissingDiscordId"); 1159 + }
+66 -38
crates/tranquil-pds/tests/sync_repo.rs
··· 110 110 let (_, did2) = create_account_and_login(&client).await; 111 111 let (_, did3) = create_account_and_login(&client).await; 112 112 let our_dids: std::collections::HashSet<String> = [did1, did2, did3].into_iter().collect(); 113 - let mut all_dids_seen: std::collections::HashSet<String> = std::collections::HashSet::new(); 114 - let mut cursor: Option<String> = None; 115 - let mut page_count = 0; 116 - let max_pages = 100; 117 - loop { 118 - let mut params: Vec<(&str, String)> = vec![("limit", "10".into())]; 119 - if let Some(ref c) = cursor { 120 - params.push(("cursor", c.clone())); 121 - } 122 - let res = client 123 - .get(format!( 124 - "{}/xrpc/com.atproto.sync.listRepos", 125 - base_url().await 126 - )) 127 - .query(&params) 128 - .send() 129 - .await 130 - .expect("Failed to send request"); 131 - assert_eq!(res.status(), StatusCode::OK); 132 - let body: Value = res.json().await.expect("Response was not valid JSON"); 133 - let repos = body["repos"].as_array().unwrap(); 134 - for repo in repos { 135 - let did = repo["did"].as_str().unwrap().to_string(); 136 - assert!( 137 - !all_dids_seen.contains(&did), 138 - "Pagination returned duplicate DID: {}", 113 + let base = base_url().await; 114 + let verify_futures = our_dids.iter().map(|did| { 115 + let client = &client; 116 + let base = &base; 117 + async move { 118 + let res = client 119 + .get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", base)) 120 + .query(&[("did", did.as_str())]) 121 + .send() 122 + .await 123 + .expect("Failed to send request"); 124 + assert_eq!( 125 + res.status(), 126 + StatusCode::OK, 127 + "Account {} should exist and be queryable via getRepoStatus", 139 128 did 140 129 ); 141 - all_dids_seen.insert(did); 142 130 } 143 - cursor = body["cursor"].as_str().map(String::from); 144 - page_count += 1; 145 - if cursor.is_none() || page_count >= max_pages { 146 - break; 131 + }); 132 + futures::future::join_all(verify_futures).await; 133 + async fn paginate_repos( 134 + client: &reqwest::Client, 135 + base: &str, 136 + ) -> std::collections::HashSet<String> { 137 + let mut all_dids = std::collections::HashSet::new(); 138 + let mut cursor: Option<String> = None; 139 + let mut pages = 0; 140 + while pages < 1000 { 141 + let params: Vec<(&str, String)> = cursor 142 + .as_ref() 143 + .map(|c| vec![("limit", "100".into()), ("cursor", c.clone())]) 144 + .unwrap_or_else(|| vec![("limit", "100".into())]); 145 + let res = client 146 + .get(format!("{}/xrpc/com.atproto.sync.listRepos", base)) 147 + .query(&params) 148 + .send() 149 + .await 150 + .expect("Failed to send request"); 151 + assert_eq!(res.status(), StatusCode::OK); 152 + let body: Value = res.json().await.expect("Response was not valid JSON"); 153 + body["repos"] 154 + .as_array() 155 + .unwrap() 156 + .iter() 157 + .map(|r| r["did"].as_str().unwrap().to_string()) 158 + .for_each(|did| { 159 + assert!( 160 + !all_dids.contains(&did), 161 + "Pagination returned duplicate DID: {}", 162 + did 163 + ); 164 + all_dids.insert(did); 165 + }); 166 + cursor = body["cursor"].as_str().map(String::from); 167 + pages += 1; 168 + if cursor.is_none() { 169 + break; 170 + } 147 171 } 148 - } 149 - for did in &our_dids { 150 - assert!( 151 - all_dids_seen.contains(did), 152 - "Our created DID {} was not found in paginated results", 153 - did 154 - ); 172 + all_dids 155 173 } 174 + let all_dids_seen = paginate_repos(&client, base).await; 175 + let missing: Vec<_> = our_dids 176 + .iter() 177 + .filter(|did| !all_dids_seen.contains(*did)) 178 + .collect(); 179 + assert!( 180 + missing.is_empty(), 181 + "DIDs not found in paginated results: {:?}", 182 + missing 183 + ); 156 184 } 157 185 158 186 #[tokio::test]
+6
frontend/src/App.svelte
··· 8 8 import Login from './routes/Login.svelte' 9 9 import Register from './routes/Register.svelte' 10 10 import RegisterPasskey from './routes/RegisterPasskey.svelte' 11 + import RegisterSso from './routes/RegisterSso.svelte' 11 12 import Verify from './routes/Verify.svelte' 12 13 import ResetPassword from './routes/ResetPassword.svelte' 13 14 import RecoverPasskey from './routes/RecoverPasskey.svelte' ··· 28 29 import OAuthPasskey from './routes/OAuthPasskey.svelte' 29 30 import OAuthDelegation from './routes/OAuthDelegation.svelte' 30 31 import OAuthError from './routes/OAuthError.svelte' 32 + import OAuthSsoRegister from './routes/OAuthSsoRegister.svelte' 31 33 import Security from './routes/Security.svelte' 32 34 import TrustedDevices from './routes/TrustedDevices.svelte' 33 35 import Controllers from './routes/Controllers.svelte' ··· 100 102 return RegisterPasskey 101 103 case '/register-password': 102 104 return Register 105 + case '/register-sso': 106 + return RegisterSso 103 107 case '/verify': 104 108 return Verify 105 109 case '/reset-password': ··· 140 144 return OAuthDelegation 141 145 case '/oauth/error': 142 146 return OAuthError 147 + case '/oauth/sso-register': 148 + return OAuthSsoRegister 143 149 case '/security': 144 150 return Security 145 151 case '/trusted-devices':
+22 -2
frontend/src/components/AccountTypeSwitcher.svelte
··· 4 4 import { routes } from '../lib/types/routes' 5 5 6 6 interface Props { 7 - active: 'passkey' | 'password' 7 + active: 'passkey' | 'password' | 'sso' 8 + ssoAvailable?: boolean 8 9 } 9 10 10 - let { active }: Props = $props() 11 + let { active, ssoAvailable = true }: Props = $props() 11 12 </script> 12 13 13 14 <div class="account-type-switcher"> ··· 17 18 <a href={getFullUrl(routes.registerPassword)} class="switcher-option" class:active={active === 'password'}> 18 19 {$_('register.passwordAccount')} 19 20 </a> 21 + {#if ssoAvailable || active === 'sso'} 22 + <a href={getFullUrl(routes.registerSso)} class="switcher-option" class:active={active === 'sso'}> 23 + {$_('register.ssoAccount')} 24 + </a> 25 + {:else} 26 + <span class="switcher-option disabled"> 27 + {$_('register.ssoAccount')} 28 + </span> 29 + {/if} 20 30 </div> 21 31 22 32 <style> ··· 52 62 background: var(--bg-primary); 53 63 color: var(--text-primary); 54 64 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 65 + } 66 + 67 + .switcher-option.disabled { 68 + opacity: 0.4; 69 + cursor: not-allowed; 70 + } 71 + 72 + .switcher-option.disabled:hover { 73 + color: var(--text-secondary); 74 + background: transparent; 55 75 } 56 76 </style>
+52
frontend/src/components/SsoIcon.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + provider: string 4 + size?: number 5 + } 6 + 7 + let { provider, size = 24 }: Props = $props() 8 + </script> 9 + 10 + {#if provider === 'github'} 11 + <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> 12 + <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/> 13 + </svg> 14 + {:else if provider === 'discord'} 15 + <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> 16 + <path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/> 17 + </svg> 18 + {:else if provider === 'google'} 19 + <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> 20 + <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/> 21 + <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/> 22 + <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/> 23 + <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/> 24 + </svg> 25 + {:else if provider === 'gitlab'} 26 + <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> 27 + <path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 00-.867 0L16.418 9.45H7.582L4.918 1.263a.455.455 0 00-.867 0L1.386 9.452.044 13.587a.924.924 0 00.331 1.023L12 23.054l11.625-8.443a.92.92 0 00.33-1.024" fill="#FC6D26"/> 28 + <path d="M12 23.054L16.418 9.45H7.582L12 23.054z" fill="#E24329"/> 29 + <path d="M12 23.054l-4.418-13.603H1.386L12 23.054z" fill="#FC6D26"/> 30 + <path d="M1.386 9.451L.044 13.586a.924.924 0 00.331 1.023L12 23.054 1.386 9.451z" fill="#FCA326"/> 31 + <path d="M1.386 9.452h6.196L4.918 1.263a.455.455 0 00-.867 0L1.386 9.452z" fill="#E24329"/> 32 + <path d="M12 23.054l4.418-13.603h6.196L12 23.054z" fill="#FC6D26"/> 33 + <path d="M22.614 9.451l1.342 4.135a.924.924 0 01-.331 1.023L12 23.054l10.614-13.603z" fill="#FCA326"/> 34 + <path d="M22.614 9.452h-6.196l2.664-8.189a.455.455 0 01.867 0l2.665 8.189z" fill="#E24329"/> 35 + </svg> 36 + {:else if provider === 'apple'} 37 + <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> 38 + <path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/> 39 + </svg> 40 + {:else} 41 + <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 42 + <path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4" /> 43 + <polyline points="10 17 15 12 10 7" /> 44 + <line x1="15" y1="12" x2="3" y2="12" /> 45 + </svg> 46 + {/if} 47 + 48 + <style> 49 + svg { 50 + display: block; 51 + } 52 + </style>
+22 -3
frontend/src/lib/api.ts
··· 143 143 return xrpc(method, { ...options, token: newToken, skipRetry: true }); 144 144 } 145 145 } 146 + const message = res.status === 429 147 + ? (errData.message || "Too many requests. Please try again later.") 148 + : errData.message; 146 149 throw new ApiError( 147 150 res.status, 148 151 errData.error as ApiErrorCode, 149 - errData.message, 152 + message, 150 153 errData.did, 151 154 errData.reauthMethods, 152 155 ); ··· 382 385 }); 383 386 }, 384 387 385 - requestEmailUpdate(token: AccessToken): Promise<EmailUpdateResponse> { 388 + requestEmailUpdate( 389 + token: AccessToken, 390 + newEmail?: string, 391 + ): Promise<EmailUpdateResponse> { 386 392 return xrpc("com.atproto.server.requestEmailUpdate", { 387 393 method: "POST", 388 394 token, 395 + body: newEmail ? { newEmail } : undefined, 389 396 }); 390 397 }, 391 398 ··· 398 405 method: "POST", 399 406 token, 400 407 body: { email, token: emailToken }, 408 + }); 409 + }, 410 + 411 + checkEmailUpdateStatus( 412 + token: AccessToken, 413 + ): Promise<{ pending: boolean; authorized: boolean; newEmail?: string }> { 414 + return xrpc("_account.checkEmailUpdateStatus", { 415 + method: "GET", 416 + token, 401 417 }); 402 418 }, 403 419 ··· 540 556 }); 541 557 }, 542 558 543 - setPassword(token: AccessToken, newPassword: string): Promise<SuccessResponse> { 559 + setPassword( 560 + token: AccessToken, 561 + newPassword: string, 562 + ): Promise<SuccessResponse> { 544 563 return xrpc("_account.setPassword", { 545 564 method: "POST", 546 565 token,
+27 -10
frontend/src/lib/migration/blob-migration.ts
··· 40 40 ): Promise<MigrateBlobResult> => { 41 41 try { 42 42 console.log( 43 - `[blob-migration] Fetching blob ${cid} from source (attempt ${attempt + 1})`, 43 + `[blob-migration] Fetching blob ${cid} from source (attempt ${ 44 + attempt + 1 45 + })`, 44 46 ); 45 47 const { data: blobData, contentType } = await sourceClient 46 48 .getBlobWithContentType(userDid, cid); ··· 59 61 } catch (e) { 60 62 const errorMessage = (e as Error).message || String(e); 61 63 console.error( 62 - `[blob-migration] Failed to migrate blob ${cid} (attempt ${attempt + 1}):`, 64 + `[blob-migration] Failed to migrate blob ${cid} (attempt ${ 65 + attempt + 1 66 + }):`, 63 67 errorMessage, 64 68 ); 65 69 ··· 115 119 console.log("[blob-migration] Starting blob migration for", userDid); 116 120 console.log( 117 121 "[blob-migration] Source client:", 118 - sourceClient ? `available (baseUrl: ${sourceClient.getBaseUrl()})` : "NOT AVAILABLE", 122 + sourceClient 123 + ? `available (baseUrl: ${sourceClient.getBaseUrl()})` 124 + : "NOT AVAILABLE", 125 + ); 126 + console.log( 127 + "[blob-migration] Local client baseUrl:", 128 + localClient.getBaseUrl(), 119 129 ); 120 - console.log("[blob-migration] Local client baseUrl:", localClient.getBaseUrl()); 121 130 console.log( 122 131 "[blob-migration] Local client has access token:", 123 132 localClient.getAccessToken() ? "yes" : "NO", 124 133 ); 125 134 126 - safeProgress(onProgress, { currentOperation: "Checking for missing blobs..." }); 135 + safeProgress(onProgress, { 136 + currentOperation: "Checking for missing blobs...", 137 + }); 127 138 128 139 const missingBlobs = await collectMissingBlobs(localClient); 129 140 ··· 137 148 } 138 149 139 150 if (!sourceClient) { 140 - console.warn("[blob-migration] No source client available, cannot fetch blobs"); 151 + console.warn( 152 + "[blob-migration] No source client available, cannot fetch blobs", 153 + ); 141 154 safeProgress(onProgress, { 142 155 currentOperation: 143 156 `${missingBlobs.length} media files missing. No source PDS URL available - your old server may have shut down. Posts will work, but some images/media may be unavailable.`, ··· 161 174 const acc = await accPromise; 162 175 163 176 safeProgress(onProgress, { 164 - currentOperation: `Migrating blob ${index + 1}/${missingBlobs.length}...`, 177 + currentOperation: `Migrating blob ${ 178 + index + 1 179 + }/${missingBlobs.length}...`, 165 180 blobsMigrated: acc.migrated, 166 181 }); 167 182 ··· 186 201 const statusMessage = migrated === missingBlobs.length 187 202 ? `All ${migrated} blobs migrated successfully` 188 203 : migrated > 0 189 - ? `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.` 190 - : `Could not migrate blobs (${failed.length} missing)`; 204 + ? `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.` 205 + : `Could not migrate blobs (${failed.length} missing)`; 191 206 192 207 safeProgress(onProgress, { currentOperation: statusMessage }); 193 208 194 - console.log(`[blob-migration] Complete: ${migrated} migrated, ${failed.length} failed`); 209 + console.log( 210 + `[blob-migration] Complete: ${migrated} migrated, ${failed.length} failed`, 211 + ); 195 212 failed.length > 0 && console.log("[blob-migration] Failed CIDs:", failed); 196 213 197 214 return {
+10 -4
frontend/src/lib/migration/flow.svelte.ts
··· 479 479 480 480 async function migrateBlobs(): Promise<void> { 481 481 if (!sourceClient) { 482 - console.error("[migration] migrateBlobs: sourceClient is null, skipping blob migration"); 482 + console.error( 483 + "[migration] migrateBlobs: sourceClient is null, skipping blob migration", 484 + ); 483 485 migrationLog("migrateBlobs SKIPPED: sourceClient is null"); 484 486 setProgress({ 485 - currentOperation: "Warning: Could not migrate blobs - source PDS connection lost", 487 + currentOperation: 488 + "Warning: Could not migrate blobs - source PDS connection lost", 486 489 }); 487 490 return; 488 491 } 489 492 if (!localClient) { 490 - console.error("[migration] migrateBlobs: localClient is null, skipping blob migration"); 493 + console.error( 494 + "[migration] migrateBlobs: localClient is null, skipping blob migration", 495 + ); 491 496 migrationLog("migrateBlobs SKIPPED: localClient is null"); 492 497 setProgress({ 493 - currentOperation: "Warning: Could not migrate blobs - local PDS connection lost", 498 + currentOperation: 499 + "Warning: Could not migrate blobs - local PDS connection lost", 494 500 }); 495 501 return; 496 502 }
+11 -3
frontend/src/lib/oauth.ts
··· 10 10 "repo:*?action=delete", 11 11 "blob:*/*", 12 12 "identity:*", 13 - "account:*", 13 + "account:*?action=manage", 14 14 ].join(" "); 15 15 16 16 const CLIENT_ID = !(import.meta.env.DEV) ··· 346 346 extractDPoPNonceFromResponse(response); 347 347 348 348 if (!response.ok) { 349 - const error = await response.json().catch(() => ({ error: "Unknown error" })); 349 + const error = await response.json().catch(() => ({ 350 + error: "Unknown error", 351 + })); 350 352 351 353 if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) { 352 354 return tokenRequest(params, false); ··· 431 433 const keyPair = await getOrCreateDPoPKeyPair(); 432 434 const tokenHash = await sha256(accessToken); 433 435 const ath = base64UrlEncode(tokenHash); 434 - return createDPoPProof(keyPair, method, url, getDPoPNonce() ?? undefined, ath); 436 + return createDPoPProof( 437 + keyPair, 438 + method, 439 + url, 440 + getDPoPNonce() ?? undefined, 441 + ath, 442 + ); 435 443 }
+14 -5
frontend/src/lib/registration/flow.svelte.ts
··· 19 19 SessionState, 20 20 } from "./types.ts"; 21 21 import { 22 - saveRegistrationState, 23 - loadRegistrationState, 24 22 clearRegistrationState, 23 + loadRegistrationState, 24 + saveRegistrationState, 25 25 } from "./storage.ts"; 26 26 27 27 export interface RegistrationFlowState { ··· 433 433 434 434 export function restoreRegistrationFlow(): RegistrationFlow | null { 435 435 const saved = loadRegistrationState(); 436 - if (!saved || saved.step === "info" || saved.step === "redirect-to-dashboard") { 436 + if ( 437 + !saved || saved.step === "info" || saved.step === "redirect-to-dashboard" 438 + ) { 437 439 return null; 438 440 } 439 441 ··· 441 443 442 444 flow.state.step = saved.step; 443 445 flow.state.info = { ...flow.state.info, ...saved.info }; 444 - flow.state.externalDidWeb = { ...flow.state.externalDidWeb, ...saved.externalDidWeb }; 446 + flow.state.externalDidWeb = { 447 + ...flow.state.externalDidWeb, 448 + ...saved.externalDidWeb, 449 + }; 445 450 flow.state.account = saved.account; 446 451 flow.state.session = saved.session; 447 452 448 453 return flow; 449 454 } 450 455 451 - export { hasPendingRegistration, getRegistrationResumeInfo, clearRegistrationState } from "./storage.ts"; 456 + export { 457 + clearRegistrationState, 458 + getRegistrationResumeInfo, 459 + hasPendingRegistration, 460 + } from "./storage.ts";
+26 -23
frontend/src/lib/registration/storage.ts
··· 1 1 import type { 2 + AccountResult, 3 + ExternalDidWebState, 4 + RegistrationInfo, 2 5 RegistrationMode, 3 6 RegistrationStep, 4 - RegistrationInfo, 5 - ExternalDidWebState, 6 - AccountResult, 7 7 SessionState, 8 8 } from "./types.ts"; 9 9 ··· 81 81 }, 82 82 account: account 83 83 ? { 84 - did: account.did, 85 - handle: account.handle, 86 - setupToken: account.setupToken, 87 - appPassword: account.appPassword, 88 - appPasswordName: account.appPasswordName, 89 - } 84 + did: account.did, 85 + handle: account.handle, 86 + setupToken: account.setupToken, 87 + appPassword: account.appPassword, 88 + appPasswordName: account.appPasswordName, 89 + } 90 90 : null, 91 91 session: session 92 92 ? { 93 - accessJwt: session.accessJwt, 94 - refreshJwt: session.refreshJwt, 95 - } 93 + accessJwt: session.accessJwt, 94 + refreshJwt: session.refreshJwt, 95 + } 96 96 : null, 97 97 }; 98 98 ··· 144 144 }, 145 145 account: state.account 146 146 ? { 147 - did: state.account.did as AccountResult["did"], 148 - handle: state.account.handle as AccountResult["handle"], 149 - setupToken: state.account.setupToken, 150 - appPassword: state.account.appPassword, 151 - appPasswordName: state.account.appPasswordName, 152 - } 147 + did: state.account.did as AccountResult["did"], 148 + handle: state.account.handle as AccountResult["handle"], 149 + setupToken: state.account.setupToken, 150 + appPassword: state.account.appPassword, 151 + appPasswordName: state.account.appPasswordName, 152 + } 153 153 : null, 154 154 session: state.session 155 155 ? { 156 - accessJwt: state.session.accessJwt as SessionState["accessJwt"], 157 - refreshJwt: state.session.refreshJwt as SessionState["refreshJwt"], 158 - } 156 + accessJwt: state.session.accessJwt as SessionState["accessJwt"], 157 + refreshJwt: state.session.refreshJwt as SessionState["refreshJwt"], 158 + } 159 159 : null, 160 160 }; 161 161 } catch { ··· 172 172 173 173 export function hasPendingRegistration(): boolean { 174 174 const state = loadRegistrationState(); 175 - return state !== null && state.step !== "info" && state.step !== "redirect-to-dashboard"; 175 + return state !== null && state.step !== "info" && 176 + state.step !== "redirect-to-dashboard"; 176 177 } 177 178 178 179 export function getRegistrationResumeInfo(): { ··· 182 183 did?: string; 183 184 } | null { 184 185 const state = loadRegistrationState(); 185 - if (!state || state.step === "info" || state.step === "redirect-to-dashboard") { 186 + if ( 187 + !state || state.step === "info" || state.step === "redirect-to-dashboard" 188 + ) { 186 189 return null; 187 190 } 188 191
+3
frontend/src/lib/types/routes.ts
··· 2 2 login: "/login", 3 3 register: "/register", 4 4 registerPassword: "/register-password", 5 + registerSso: "/register-sso", 5 6 dashboard: "/dashboard", 6 7 settings: "/settings", 7 8 security: "/security", ··· 29 30 oauthPasskey: "/oauth/passkey", 30 31 oauthDelegation: "/oauth/delegation", 31 32 oauthError: "/oauth/error", 33 + oauthSsoRegister: "/oauth/sso-register", 32 34 } as const; 33 35 34 36 export type Route = (typeof routes)[keyof typeof routes]; ··· 52 54 [routes.oauthDelegation]: { request_uri?: string; delegated_did?: string }; 53 55 [routes.oauthError]: { error?: string; error_description?: string }; 54 56 [routes.migrate]: { code?: string; state?: string }; 57 + [routes.oauthSsoRegister]: { token?: string }; 55 58 } 56 59 57 60 export type RoutesWithParams = keyof RouteParams;
+44 -3
frontend/src/locales/en.json
··· 170 170 "signIn": "Sign in", 171 171 "passkeyAccount": "Passkey", 172 172 "passwordAccount": "Password", 173 + "ssoAccount": "SSO", 174 + "ssoSubtitle": "Create an account using an external provider", 175 + "noSsoProviders": "No SSO providers are configured on this server.", 176 + "ssoHint": "Choose a provider to create your account:", 177 + "continueWith": "Continue with {provider}", 173 178 "validation": { 174 179 "handleRequired": "Handle is required", 175 180 "handleNoDots": "Handle cannot contain dots. You can set up a custom domain handle after creating your account.", ··· 275 280 "verificationCode": "Verification Code", 276 281 "verificationCodePlaceholder": "Enter verification code", 277 282 "confirmEmailChange": "Confirm Email Change", 283 + "emailTokenHint": "Enter the code from the email, or click the link in the email on any device.", 284 + "emailUpdateAuthorized": "Email change authorized! Click confirm to complete.", 278 285 "updating": "Updating...", 279 286 "changeHandle": "Change Handle", 280 287 "currentHandle": "Current: @{handle}", ··· 677 684 "checkingPasskey": "Checking passkey...", 678 685 "signInWithPasskey": "Sign in with passkey", 679 686 "passkeyNotSetUp": "Passkey not set up", 680 - "orUsePassword": "or use password", 687 + "orUsePassword": "Or use password", 681 688 "password": "Password", 682 689 "rememberDevice": "Remember this device", 683 690 "passkeyHintChecking": "Checking passkey status...", ··· 685 692 "passkeyHintNotAvailable": "No passkeys registered for this account", 686 693 "passkeyHint": "Use your device's biometrics or security key", 687 694 "passwordPlaceholder": "Enter your password", 688 - "usePasskey": "Use Passkey" 695 + "usePasskey": "Use Passkey", 696 + "orContinueWith": "Or continue with", 697 + "orUseCredentials": "Or sign in with credentials" 698 + }, 699 + "sso": { 700 + "linkedAccounts": "Linked Accounts", 701 + "linkedAccountsDesc": "External accounts linked to your identity for single sign-on.", 702 + "noLinkedAccounts": "No linked accounts", 703 + "noLinkedAccountsDesc": "Link an external account to enable quick sign-in with that provider.", 704 + "linkAccount": "Link Account", 705 + "unlinkAccount": "Unlink", 706 + "unlinkConfirm": "Are you sure you want to unlink this account?", 707 + "unlinked": "Unlinked {provider}", 708 + "lastLoginAt": "Last used", 709 + "linkedAt": "Linked" 689 710 }, 690 711 "consent": { 691 712 "title": "Authorize Application", ··· 798 819 "backToApp": "Back to Application" 799 820 } 800 821 }, 822 + "sso_register": { 823 + "title": "Complete Registration", 824 + "subtitle": "Creating account with {provider}", 825 + "handle_label": "Choose your handle", 826 + "handle_available": "Available", 827 + "handle_taken": "Already taken", 828 + "submit": "Create Account", 829 + "error_expired": "Registration session expired. Please try again.", 830 + "error_handle_required": "Please choose a handle", 831 + "emailVerifiedByProvider": "This email is verified by {provider}. No additional verification needed.", 832 + "emailChangedNeedsVerification": "If you use a different email, you will need to verify it.", 833 + "infoAfterTitle": "After creating your account", 834 + "infoAddPassword": "Add a password for traditional login", 835 + "infoAddPasskey": "Set up a passkey for passwordless sign-in", 836 + "infoLinkProviders": "Link additional SSO providers", 837 + "infoChangeHandle": "Change your handle or use a custom domain", 838 + "tryAgain": "Try again" 839 + }, 801 840 "verify": { 802 841 "title": "Verify Your Account", 803 842 "subtitle": "We've sent a verification code to your {channel}. Enter it below to complete registration.", ··· 834 873 "updateEmail": "Update Email", 835 874 "updating": "Updating...", 836 875 "emailUpdated": "Your email has been updated successfully.", 837 - "emailUpdatedInfo": "You may need to verify your new email address." 876 + "emailUpdatedInfo": "You may need to verify your new email address.", 877 + "emailAuthorizeSuccess": "Your email update has been authorized.", 878 + "emailAuthorizeInfo": "You can now complete the change on your original device." 838 879 }, 839 880 "resetPassword": { 840 881 "title": "Reset Password",
+36 -1
frontend/src/locales/fi.json
··· 170 170 "signIn": "Kirjaudu sisään", 171 171 "passkeyAccount": "Pääsyavain", 172 172 "passwordAccount": "Salasana", 173 + "ssoAccount": "SSO", 174 + "ssoSubtitle": "Luo tili ulkoisen palveluntarjoajan kautta", 175 + "noSsoProviders": "Tälle palvelimelle ei ole määritetty SSO-palveluntarjoajia.", 176 + "ssoHint": "Valitse palveluntarjoaja tilin luomiseksi:", 177 + "continueWith": "Jatka palvelulla {provider}", 173 178 "validation": { 174 179 "handleRequired": "Käyttäjänimi vaaditaan", 175 180 "handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.", ··· 275 280 "verificationCode": "Vahvistuskoodi", 276 281 "verificationCodePlaceholder": "Syötä vahvistuskoodi", 277 282 "confirmEmailChange": "Vahvista sähköpostin vaihto", 283 + "emailTokenHint": "Syötä sähköpostissa oleva koodi tai napsauta linkkiä sähköpostissa millä tahansa laitteella.", 284 + "emailUpdateAuthorized": "Sähköpostin vaihto hyväksytty! Napsauta vahvista viimeistelläksesi.", 278 285 "updating": "Päivitetään...", 279 286 "changeHandle": "Vaihda käyttäjänimi", 280 287 "currentHandle": "Nykyinen: @{handle}", ··· 685 692 "passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille", 686 693 "passkeyHint": "Käytä laitteesi biometriikkaa tai suojausavainta", 687 694 "passwordPlaceholder": "Syötä salasanasi", 688 - "usePasskey": "Käytä pääsyavainta" 695 + "usePasskey": "Käytä pääsyavainta", 696 + "orContinueWith": "Tai jatka käyttäen", 697 + "orUseCredentials": "Tai kirjaudu tunnuksilla" 698 + }, 699 + "sso": { 700 + "linkedAccounts": "Linkitetyt tilit", 701 + "linkedAccountsDesc": "Ulkoiset tilit, jotka on linkitetty identiteettiisi kertakirjautumista varten.", 702 + "noLinkedAccounts": "Ei linkitettyjä tilejä", 703 + "noLinkedAccountsDesc": "Linkitä ulkoinen tili ottaaksesi käyttöön nopean kirjautumisen kyseisellä palveluntarjoajalla.", 704 + "linkAccount": "Linkitä tili", 705 + "unlinkAccount": "Poista linkitys", 706 + "unlinkConfirm": "Haluatko varmasti poistaa tämän tilin linkityksen?", 707 + "unlinked": "Linkitys poistettu: {provider}", 708 + "lastLoginAt": "Viimeksi käytetty", 709 + "linkedAt": "Linkitetty" 689 710 }, 690 711 "consent": { 691 712 "title": "Valtuuta sovellus", ··· 798 819 "backToApp": "Takaisin sovellukseen" 799 820 } 800 821 }, 822 + "sso_register": { 823 + "title": "Viimeistele rekisteröinti", 824 + "subtitle": "Luo tili käyttäen {provider}", 825 + "handle_label": "Valitse käsittelynimi", 826 + "handle_available": "Saatavilla", 827 + "handle_taken": "Jo käytössä", 828 + "submit": "Luo tili", 829 + "error_expired": "Rekisteröintisessio on vanhentunut. Yritä uudelleen.", 830 + "error_handle_required": "Valitse käsittelynimi", 831 + "emailVerifiedByProvider": "Tämä sähköposti on vahvistettu {provider} kautta. Lisävahvistusta ei tarvita.", 832 + "emailChangedNeedsVerification": "Jos käytät eri sähköpostia, sinun täytyy vahvistaa se." 833 + }, 801 834 "verify": { 802 835 "title": "Vahvista tilisi", 803 836 "subtitle": "Olemme lähettäneet vahvistuskoodin {channel}. Syötä se alla viimeistelläksesi rekisteröinnin.", ··· 831 864 "emailUpdateTitle": "Päivitä sähköpostiosoite", 832 865 "emailUpdated": "Sähköpostiosoitteesi on päivitetty.", 833 866 "emailUpdatedInfo": "Sinun on ehkä vahvistettava uusi sähköpostiosoitteesi.", 867 + "emailAuthorizeSuccess": "Sähköpostipäivityksesi on valtuutettu.", 868 + "emailAuthorizeInfo": "Voit nyt viimeistellä muutoksen alkuperäisellä laitteellasi.", 834 869 "newEmailLabel": "Uusi sähköpostiosoite", 835 870 "newEmailPlaceholder": "uusi@esimerkki.fi", 836 871 "updateEmail": "Päivitä sähköposti",
+36 -1
frontend/src/locales/ja.json
··· 163 163 "signIn": "サインイン", 164 164 "passkeyAccount": "パスキー", 165 165 "passwordAccount": "パスワード", 166 + "ssoAccount": "SSO", 167 + "ssoSubtitle": "外部プロバイダーを使用してアカウントを作成", 168 + "noSsoProviders": "このサーバーにはSSOプロバイダーが設定されていません。", 169 + "ssoHint": "プロバイダーを選択してアカウントを作成:", 170 + "continueWith": "{provider}で続行", 166 171 "validation": { 167 172 "handleRequired": "ハンドルは必須です", 168 173 "handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。", ··· 268 273 "verificationCode": "確認コード", 269 274 "verificationCodePlaceholder": "認証コードを入力", 270 275 "confirmEmailChange": "メール変更を確認", 276 + "emailTokenHint": "メールに記載されたコードを入力するか、任意のデバイスでメール内のリンクをクリックしてください。", 277 + "emailUpdateAuthorized": "メール変更が承認されました!確認をクリックして完了してください。", 271 278 "updating": "更新中...", 272 279 "changeHandle": "ハンドル変更", 273 280 "currentHandle": "現在: @{handle}", ··· 678 685 "passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません", 679 686 "passkeyHint": "デバイスの生体認証またはセキュリティキーを使用", 680 687 "passwordPlaceholder": "パスワードを入力", 681 - "usePasskey": "パスキーを使用" 688 + "usePasskey": "パスキーを使用", 689 + "orContinueWith": "または次の方法で続行", 690 + "orUseCredentials": "または認証情報でサインイン" 691 + }, 692 + "sso": { 693 + "linkedAccounts": "連携アカウント", 694 + "linkedAccountsDesc": "シングルサインオン用に連携された外部アカウント。", 695 + "noLinkedAccounts": "連携アカウントなし", 696 + "noLinkedAccountsDesc": "外部アカウントを連携して、そのプロバイダーでのクイックサインインを有効にします。", 697 + "linkAccount": "アカウントを連携", 698 + "unlinkAccount": "連携解除", 699 + "unlinkConfirm": "このアカウントの連携を解除しますか?", 700 + "unlinked": "{provider} の連携を解除しました", 701 + "lastLoginAt": "最終使用", 702 + "linkedAt": "連携日時" 682 703 }, 683 704 "consent": { 684 705 "title": "アプリを承認", ··· 791 812 "backToApp": "アプリに戻る" 792 813 } 793 814 }, 815 + "sso_register": { 816 + "title": "登録を完了", 817 + "subtitle": "{provider}でアカウントを作成", 818 + "handle_label": "ハンドルを選択", 819 + "handle_available": "利用可能", 820 + "handle_taken": "既に使用されています", 821 + "submit": "アカウント作成", 822 + "error_expired": "登録セッションが期限切れです。もう一度お試しください。", 823 + "error_handle_required": "ハンドルを選択してください", 824 + "emailVerifiedByProvider": "このメールアドレスは{provider}で確認済みです。追加の確認は不要です。", 825 + "emailChangedNeedsVerification": "別のメールアドレスを使用する場合は、確認が必要です。" 826 + }, 794 827 "verify": { 795 828 "title": "アカウント確認", 796 829 "subtitle": "{channel} に確認コードを送信しました。以下に入力して登録を完了してください。", ··· 824 857 "emailUpdateTitle": "メールアドレスの更新", 825 858 "emailUpdated": "メールアドレスが正常に更新されました。", 826 859 "emailUpdatedInfo": "新しいメールアドレスの確認が必要な場合があります。", 860 + "emailAuthorizeSuccess": "メールアドレスの更新が承認されました。", 861 + "emailAuthorizeInfo": "元のデバイスで変更を完了できます。", 827 862 "newEmailLabel": "新しいメールアドレス", 828 863 "newEmailPlaceholder": "new@example.com", 829 864 "updateEmail": "メールを更新",
+36 -1
frontend/src/locales/ko.json
··· 163 163 "signIn": "로그인", 164 164 "passkeyAccount": "패스키", 165 165 "passwordAccount": "비밀번호", 166 + "ssoAccount": "SSO", 167 + "ssoSubtitle": "외부 제공자를 사용하여 계정 만들기", 168 + "noSsoProviders": "이 서버에 SSO 제공자가 설정되어 있지 않습니다.", 169 + "ssoHint": "계정을 만들 제공자를 선택하세요:", 170 + "continueWith": "{provider}로 계속", 166 171 "validation": { 167 172 "handleRequired": "핸들은 필수입니다", 168 173 "handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.", ··· 268 273 "verificationCode": "인증 코드", 269 274 "verificationCodePlaceholder": "인증 코드 입력", 270 275 "confirmEmailChange": "이메일 변경 확인", 276 + "emailTokenHint": "이메일의 코드를 입력하거나 다른 기기에서 이메일의 링크를 클릭하세요.", 277 + "emailUpdateAuthorized": "이메일 변경이 승인되었습니다! 확인을 클릭하여 완료하세요.", 271 278 "updating": "업데이트 중...", 272 279 "changeHandle": "핸들 변경", 273 280 "currentHandle": "현재: @{handle}", ··· 678 685 "passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다", 679 686 "passkeyHint": "기기의 생체 인식 또는 보안 키 사용", 680 687 "passwordPlaceholder": "비밀번호 입력", 681 - "usePasskey": "패스키 사용" 688 + "usePasskey": "패스키 사용", 689 + "orContinueWith": "또는 다음으로 계속", 690 + "orUseCredentials": "또는 자격 증명으로 로그인" 691 + }, 692 + "sso": { 693 + "linkedAccounts": "연결된 계정", 694 + "linkedAccountsDesc": "싱글 사인온을 위해 연결된 외부 계정입니다.", 695 + "noLinkedAccounts": "연결된 계정 없음", 696 + "noLinkedAccountsDesc": "외부 계정을 연결하여 해당 제공자로 빠르게 로그인하세요.", 697 + "linkAccount": "계정 연결", 698 + "unlinkAccount": "연결 해제", 699 + "unlinkConfirm": "이 계정의 연결을 해제하시겠습니까?", 700 + "unlinked": "{provider} 연결 해제됨", 701 + "lastLoginAt": "마지막 사용", 702 + "linkedAt": "연결됨" 682 703 }, 683 704 "consent": { 684 705 "title": "앱 승인", ··· 791 812 "backToApp": "앱으로 돌아가기" 792 813 } 793 814 }, 815 + "sso_register": { 816 + "title": "등록 완료", 817 + "subtitle": "{provider}로 계정 생성", 818 + "handle_label": "핸들 선택", 819 + "handle_available": "사용 가능", 820 + "handle_taken": "이미 사용 중", 821 + "submit": "계정 생성", 822 + "error_expired": "등록 세션이 만료되었습니다. 다시 시도해 주세요.", 823 + "error_handle_required": "핸들을 선택해 주세요", 824 + "emailVerifiedByProvider": "이 이메일은 {provider}에서 인증되었습니다. 추가 인증이 필요하지 않습니다.", 825 + "emailChangedNeedsVerification": "다른 이메일을 사용하시면 인증이 필요합니다." 826 + }, 794 827 "verify": { 795 828 "title": "계정 인증", 796 829 "subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 입력하여 등록을 완료하세요.", ··· 824 857 "emailUpdateTitle": "이메일 주소 업데이트", 825 858 "emailUpdated": "이메일 주소가 성공적으로 업데이트되었습니다.", 826 859 "emailUpdatedInfo": "새 이메일 주소를 인증해야 할 수 있습니다.", 860 + "emailAuthorizeSuccess": "이메일 업데이트가 승인되었습니다.", 861 + "emailAuthorizeInfo": "이제 원래 기기에서 변경을 완료할 수 있습니다.", 827 862 "newEmailLabel": "새 이메일 주소", 828 863 "newEmailPlaceholder": "new@example.com", 829 864 "updateEmail": "이메일 업데이트",
+36 -1
frontend/src/locales/sv.json
··· 163 163 "signIn": "Logga in", 164 164 "passkeyAccount": "Nyckel", 165 165 "passwordAccount": "Lösenord", 166 + "ssoAccount": "SSO", 167 + "ssoSubtitle": "Skapa ett konto med en extern leverantör", 168 + "noSsoProviders": "Inga SSO-leverantörer är konfigurerade på denna server.", 169 + "ssoHint": "Välj en leverantör för att skapa ditt konto:", 170 + "continueWith": "Fortsätt med {provider}", 166 171 "validation": { 167 172 "handleRequired": "Användarnamn krävs", 168 173 "handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.", ··· 268 273 "verificationCode": "Verifieringskod", 269 274 "verificationCodePlaceholder": "Ange verifieringskod", 270 275 "confirmEmailChange": "Bekräfta e-poständring", 276 + "emailTokenHint": "Ange koden från e-postmeddelandet, eller klicka på länken i e-postmeddelandet på valfri enhet.", 277 + "emailUpdateAuthorized": "E-poständring godkänd! Klicka på bekräfta för att slutföra.", 271 278 "updating": "Uppdaterar...", 272 279 "changeHandle": "Ändra användarnamn", 273 280 "currentHandle": "Nuvarande: @{handle}", ··· 678 685 "passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto", 679 686 "passkeyHint": "Använd enhetens biometri eller säkerhetsnyckel", 680 687 "passwordPlaceholder": "Ange ditt lösenord", 681 - "usePasskey": "Använd nyckel" 688 + "usePasskey": "Använd nyckel", 689 + "orContinueWith": "Eller fortsätt med", 690 + "orUseCredentials": "Eller logga in med uppgifter" 691 + }, 692 + "sso": { 693 + "linkedAccounts": "Länkade konton", 694 + "linkedAccountsDesc": "Externa konton länkade till din identitet för enkel inloggning.", 695 + "noLinkedAccounts": "Inga länkade konton", 696 + "noLinkedAccountsDesc": "Länka ett externt konto för att aktivera snabb inloggning med den leverantören.", 697 + "linkAccount": "Länka konto", 698 + "unlinkAccount": "Ta bort länk", 699 + "unlinkConfirm": "Är du säker på att du vill ta bort länken till detta konto?", 700 + "unlinked": "Länk till {provider} borttagen", 701 + "lastLoginAt": "Senast använd", 702 + "linkedAt": "Länkad" 682 703 }, 683 704 "consent": { 684 705 "title": "Auktorisera applikation", ··· 791 812 "backToApp": "Tillbaka till applikationen" 792 813 } 793 814 }, 815 + "sso_register": { 816 + "title": "Slutför registrering", 817 + "subtitle": "Skapar konto med {provider}", 818 + "handle_label": "Välj ditt användarnamn", 819 + "handle_available": "Tillgängligt", 820 + "handle_taken": "Redan taget", 821 + "submit": "Skapa konto", 822 + "error_expired": "Registreringssessionen har löpt ut. Försök igen.", 823 + "error_handle_required": "Välj ett användarnamn", 824 + "emailVerifiedByProvider": "Denna e-post är verifierad av {provider}. Ingen ytterligare verifiering behövs.", 825 + "emailChangedNeedsVerification": "Om du använder en annan e-post måste du verifiera den." 826 + }, 794 827 "verify": { 795 828 "title": "Verifiera ditt konto", 796 829 "subtitle": "Vi har skickat en verifieringskod till din {channel}. Ange den nedan för att slutföra registreringen.", ··· 824 857 "emailUpdateTitle": "Uppdatera e-postadress", 825 858 "emailUpdated": "Din e-postadress har uppdaterats.", 826 859 "emailUpdatedInfo": "Du kan behöva verifiera din nya e-postadress.", 860 + "emailAuthorizeSuccess": "Din e-postuppdatering har auktoriserats.", 861 + "emailAuthorizeInfo": "Du kan nu slutföra ändringen på din ursprungliga enhet.", 827 862 "newEmailLabel": "Ny e-postadress", 828 863 "newEmailPlaceholder": "ny@exempel.se", 829 864 "updateEmail": "Uppdatera e-post",
+36 -1
frontend/src/locales/zh.json
··· 163 163 "signIn": "立即登录", 164 164 "passkeyAccount": "通行密钥", 165 165 "passwordAccount": "密码", 166 + "ssoAccount": "SSO", 167 + "ssoSubtitle": "使用外部提供商创建账户", 168 + "noSsoProviders": "此服务器未配置SSO提供商。", 169 + "ssoHint": "选择一个提供商来创建您的账户:", 170 + "continueWith": "使用{provider}继续", 166 171 "validation": { 167 172 "handleRequired": "请输入用户名", 168 173 "handleNoDots": "用户名不能包含点号。您可以在创建账户后设置自定义域名。", ··· 268 273 "verificationCode": "验证码", 269 274 "verificationCodePlaceholder": "输入验证码", 270 275 "confirmEmailChange": "确认更改邮箱", 276 + "emailTokenHint": "输入邮件中的验证码,或在任意设备上点击邮件中的链接。", 277 + "emailUpdateAuthorized": "邮箱更改已授权!点击确认完成。", 271 278 "updating": "更新中...", 272 279 "changeHandle": "更改用户名", 273 280 "currentHandle": "当前:@{handle}", ··· 678 685 "passkeyHintNotAvailable": "此账户未注册通行密钥", 679 686 "passkeyHint": "使用设备的生物识别或安全密钥", 680 687 "passwordPlaceholder": "输入您的密码", 681 - "usePasskey": "使用通行密钥" 688 + "usePasskey": "使用通行密钥", 689 + "orContinueWith": "或使用以下方式继续", 690 + "orUseCredentials": "或使用凭证登录" 691 + }, 692 + "sso": { 693 + "linkedAccounts": "已关联账户", 694 + "linkedAccountsDesc": "已关联到您身份的外部账户,用于单点登录。", 695 + "noLinkedAccounts": "暂无关联账户", 696 + "noLinkedAccountsDesc": "关联外部账户以启用该服务商的快速登录。", 697 + "linkAccount": "关联账户", 698 + "unlinkAccount": "取消关联", 699 + "unlinkConfirm": "确定要取消关联此账户吗?", 700 + "unlinked": "已取消关联 {provider}", 701 + "lastLoginAt": "上次使用", 702 + "linkedAt": "关联时间" 682 703 }, 683 704 "consent": { 684 705 "title": "授权应用", ··· 791 812 "backToApp": "返回应用" 792 813 } 793 814 }, 815 + "sso_register": { 816 + "title": "完成注册", 817 + "subtitle": "使用{provider}创建账户", 818 + "handle_label": "选择您的昵称", 819 + "handle_available": "可用", 820 + "handle_taken": "已被使用", 821 + "submit": "创建账户", 822 + "error_expired": "注册会话已过期。请重试。", 823 + "error_handle_required": "请选择一个昵称", 824 + "emailVerifiedByProvider": "此邮箱已由{provider}验证。无需额外验证。", 825 + "emailChangedNeedsVerification": "如果您使用其他邮箱,则需要进行验证。" 826 + }, 794 827 "verify": { 795 828 "title": "验证账户", 796 829 "subtitle": "我们已将验证码发送到您的{channel}。请在下方输入以完成注册。", ··· 824 857 "emailUpdateTitle": "更新邮箱地址", 825 858 "emailUpdated": "您的邮箱地址已成功更新。", 826 859 "emailUpdatedInfo": "您可能需要验证新的邮箱地址。", 860 + "emailAuthorizeSuccess": "您的邮箱更新已授权。", 861 + "emailAuthorizeInfo": "您现在可以在原设备上完成更改。", 827 862 "newEmailLabel": "新邮箱地址", 828 863 "newEmailPlaceholder": "new@example.com", 829 864 "updateEmail": "更新邮箱",
+263 -55
frontend/src/routes/OAuthLogin.svelte
··· 7 7 serializeAssertionResponse, 8 8 type WebAuthnRequestOptionsResponse, 9 9 } from '../lib/webauthn' 10 + import SsoIcon from '../components/SsoIcon.svelte' 11 + 12 + interface SsoProvider { 13 + provider: string 14 + name: string 15 + icon: string 16 + } 10 17 11 18 let username = $state('') 19 + let ssoProviders = $state<SsoProvider[]>([]) 20 + let ssoLoading = $state<string | null>(null) 12 21 let password = $state('') 13 22 let rememberDevice = $state(false) 14 23 let submitting = $state(false) ··· 46 55 47 56 $effect(() => { 48 57 fetchAuthRequestInfo() 58 + fetchSsoProviders() 49 59 }) 60 + 61 + async function fetchSsoProviders() { 62 + try { 63 + const response = await fetch('/oauth/sso/providers') 64 + if (response.ok) { 65 + const data = await response.json() 66 + ssoProviders = data.providers || [] 67 + } 68 + } catch { 69 + ssoProviders = [] 70 + } 71 + } 72 + 73 + async function handleSsoLogin(provider: string) { 74 + const requestUri = getRequestUri() 75 + if (!requestUri) { 76 + error = $_('common.error') 77 + return 78 + } 79 + 80 + ssoLoading = provider 81 + error = null 82 + 83 + try { 84 + const response = await fetch('/oauth/sso/initiate', { 85 + method: 'POST', 86 + headers: { 87 + 'Content-Type': 'application/json', 88 + 'Accept': 'application/json' 89 + }, 90 + body: JSON.stringify({ 91 + provider, 92 + request_uri: requestUri, 93 + action: 'login' 94 + }) 95 + }) 96 + 97 + const data = await response.json() 98 + 99 + if (!response.ok) { 100 + error = data.error_description || data.error || 'Failed to start SSO login' 101 + ssoLoading = null 102 + return 103 + } 104 + 105 + if (data.redirect_url) { 106 + window.location.href = data.redirect_url 107 + return 108 + } 109 + 110 + error = $_('common.error') 111 + ssoLoading = null 112 + } catch { 113 + error = $_('common.error') 114 + ssoLoading = null 115 + } 116 + } 50 117 51 118 async function fetchAuthRequestInfo() { 52 119 const requestUri = getRequestUri() ··· 328 395 /> 329 396 </div> 330 397 398 + {#if ssoProviders.length > 0} 399 + <div class="sso-section sso-section-top"> 400 + <div class="sso-buttons"> 401 + {#each ssoProviders as provider} 402 + <button 403 + type="button" 404 + class="sso-btn sso-btn-prominent" 405 + onclick={() => handleSsoLogin(provider.provider)} 406 + disabled={submitting || ssoLoading !== null} 407 + > 408 + {#if ssoLoading === provider.provider} 409 + <span class="loading-spinner"></span> 410 + {:else} 411 + <SsoIcon provider={provider.icon} size={20} /> 412 + {/if} 413 + <span>{provider.name}</span> 414 + </button> 415 + {/each} 416 + </div> 417 + <div class="sso-divider"> 418 + <span>{$_('oauth.login.orUseCredentials')}</span> 419 + </div> 420 + </div> 421 + {/if} 422 + 331 423 {#if passkeySupported && username.length >= 3} 332 - <div class="auth-methods"> 424 + <div class="auth-methods" class:single-method={!hasPassword}> 333 425 <div class="passkey-method"> 334 426 <h3>{$_('oauth.login.signInWithPasskey')}</h3> 335 427 <button ··· 360 452 <p class="method-hint">{$_('oauth.login.passkeyHint')}</p> 361 453 </div> 362 454 363 - <div class="method-divider"> 364 - <span>{$_('oauth.login.orUsePassword')}</span> 365 - </div> 366 - 367 - <div class="password-method"> 368 - <h3>{$_('oauth.login.password')}</h3> 369 - <div class="field"> 370 - <input 371 - id="password" 372 - type="password" 373 - bind:value={password} 374 - disabled={submitting} 375 - required 376 - autocomplete="current-password" 377 - placeholder={$_('oauth.login.passwordPlaceholder')} 378 - /> 455 + {#if hasPassword} 456 + <div class="method-divider"> 457 + <span>{$_('oauth.login.orUsePassword')}</span> 379 458 </div> 380 459 381 - <label class="remember-device"> 382 - <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 383 - <span>{$_('oauth.login.rememberDevice')}</span> 384 - </label> 460 + <div class="password-method"> 461 + <h3>{$_('oauth.login.password')}</h3> 462 + <div class="field"> 463 + <input 464 + id="password" 465 + type="password" 466 + bind:value={password} 467 + disabled={submitting} 468 + required 469 + autocomplete="current-password" 470 + placeholder={$_('oauth.login.passwordPlaceholder')} 471 + /> 472 + </div> 385 473 386 - <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 387 - {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 388 - </button> 389 - </div> 474 + <label class="remember-device"> 475 + <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 476 + <span>{$_('oauth.login.rememberDevice')}</span> 477 + </label> 478 + 479 + <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 480 + {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 481 + </button> 482 + </div> 483 + {/if} 390 484 </div> 391 485 392 - <div class="actions"> 393 - <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 486 + <div class="cancel-row"> 487 + <button type="button" class="cancel-btn-subtle" onclick={handleCancel} disabled={submitting}> 394 488 {$_('common.cancel')} 395 489 </button> 396 490 </div> 397 491 {:else} 398 - <div class="field"> 399 - <label for="password">{$_('oauth.login.password')}</label> 400 - <input 401 - id="password" 402 - type="password" 403 - bind:value={password} 404 - disabled={submitting} 405 - required 406 - autocomplete="current-password" 407 - /> 408 - </div> 492 + {#if hasPassword || !securityStatusChecked} 493 + <div class="field"> 494 + <label for="password">{$_('oauth.login.password')}</label> 495 + <input 496 + id="password" 497 + type="password" 498 + bind:value={password} 499 + disabled={submitting} 500 + required 501 + autocomplete="current-password" 502 + /> 503 + </div> 409 504 410 - <label class="remember-device"> 411 - <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 412 - <span>{$_('oauth.login.rememberDevice')}</span> 413 - </label> 505 + <label class="remember-device"> 506 + <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 507 + <span>{$_('oauth.login.rememberDevice')}</span> 508 + </label> 414 509 415 - <div class="actions"> 416 - <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 510 + <div class="actions"> 511 + <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 512 + {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 513 + </button> 514 + </div> 515 + {/if} 516 + 517 + <div class="cancel-row"> 518 + <button type="button" class="cancel-btn-subtle" onclick={handleCancel} disabled={submitting}> 417 519 {$_('common.cancel')} 418 - </button> 419 - <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 420 - {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 421 520 </button> 422 521 </div> 423 522 {/if} ··· 623 722 cursor: not-allowed; 624 723 } 625 724 626 - .cancel-btn { 627 - background: var(--bg-secondary); 628 - color: var(--text-primary); 629 - border: 1px solid var(--border-color); 725 + .cancel-row { 726 + display: flex; 727 + justify-content: center; 728 + margin-top: var(--space-4); 630 729 } 631 730 632 - .cancel-btn:hover:not(:disabled) { 633 - background: var(--error-bg); 634 - border-color: var(--error-border); 635 - color: var(--error-text); 731 + .cancel-btn-subtle { 732 + padding: var(--space-2) var(--space-4); 733 + background: transparent; 734 + color: var(--text-muted); 735 + border: none; 736 + border-radius: var(--radius-md); 737 + font-size: var(--text-sm); 738 + cursor: pointer; 739 + transition: color var(--transition-fast); 740 + } 741 + 742 + .cancel-btn-subtle:hover:not(:disabled) { 743 + color: var(--text-secondary); 744 + } 745 + 746 + .cancel-btn-subtle:disabled { 747 + opacity: 0.6; 748 + cursor: not-allowed; 636 749 } 637 750 638 751 .submit-btn { ··· 685 798 .passkey-text { 686 799 flex: 1; 687 800 text-align: left; 801 + } 802 + 803 + .sso-section { 804 + margin-top: var(--space-6); 805 + } 806 + 807 + .sso-section-top { 808 + margin-top: var(--space-4); 809 + margin-bottom: 0; 810 + } 811 + 812 + .sso-section-top .sso-divider { 813 + margin-top: var(--space-5); 814 + margin-bottom: 0; 815 + } 816 + 817 + .sso-divider { 818 + display: flex; 819 + align-items: center; 820 + gap: var(--space-4); 821 + margin-bottom: var(--space-4); 822 + color: var(--text-muted); 823 + font-size: var(--text-sm); 824 + } 825 + 826 + .sso-divider::before, 827 + .sso-divider::after { 828 + content: ''; 829 + flex: 1; 830 + height: 1px; 831 + background: var(--border-color); 832 + } 833 + 834 + .sso-buttons { 835 + display: flex; 836 + flex-wrap: wrap; 837 + gap: var(--space-3); 838 + justify-content: center; 839 + } 840 + 841 + .sso-btn { 842 + display: flex; 843 + align-items: center; 844 + gap: var(--space-2); 845 + padding: var(--space-2) var(--space-4); 846 + background: var(--bg-secondary); 847 + color: var(--text-primary); 848 + border: 1px solid var(--border-color); 849 + border-radius: var(--radius-md); 850 + font-size: var(--text-sm); 851 + cursor: pointer; 852 + transition: background-color var(--transition-fast), border-color var(--transition-fast); 853 + } 854 + 855 + .sso-btn-prominent { 856 + padding: var(--space-3) var(--space-5); 857 + font-size: var(--text-base); 858 + font-weight: var(--font-medium); 859 + } 860 + 861 + .sso-btn:hover:not(:disabled) { 862 + background: var(--bg-tertiary); 863 + border-color: var(--accent); 864 + } 865 + 866 + .sso-btn:disabled { 867 + opacity: 0.6; 868 + cursor: not-allowed; 869 + } 870 + 871 + .auth-methods.single-method { 872 + grid-template-columns: 1fr; 873 + } 874 + 875 + @media (min-width: 600px) { 876 + .auth-methods.single-method { 877 + grid-template-columns: 1fr; 878 + max-width: 400px; 879 + margin: var(--space-4) auto 0; 880 + } 881 + } 882 + 883 + .loading-spinner { 884 + width: 20px; 885 + height: 20px; 886 + border: 2px solid var(--border-color); 887 + border-top-color: var(--accent); 888 + border-radius: 50%; 889 + animation: spin 0.8s linear infinite; 890 + } 891 + 892 + @keyframes spin { 893 + to { 894 + transform: rotate(360deg); 895 + } 688 896 } 689 897 </style>
+614
frontend/src/routes/OAuthSsoRegister.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte' 3 + import { _ } from '../lib/i18n' 4 + import { toast } from '../lib/toast.svelte' 5 + import SsoIcon from '../components/SsoIcon.svelte' 6 + 7 + interface PendingRegistration { 8 + request_uri: string 9 + provider: string 10 + provider_user_id: string 11 + provider_username: string | null 12 + provider_email: string | null 13 + provider_email_verified: boolean 14 + } 15 + 16 + interface CommsChannelConfig { 17 + email: boolean 18 + discord: boolean 19 + telegram: boolean 20 + signal: boolean 21 + } 22 + 23 + let pending = $state<PendingRegistration | null>(null) 24 + let loading = $state(true) 25 + let submitting = $state(false) 26 + let error = $state<string | null>(null) 27 + 28 + let handle = $state('') 29 + let email = $state('') 30 + let providerEmailOriginal = $state<string | null>(null) 31 + let inviteCode = $state('') 32 + let verificationChannel = $state('email') 33 + let discordId = $state('') 34 + let telegramUsername = $state('') 35 + let signalNumber = $state('') 36 + 37 + let handleAvailable = $state<boolean | null>(null) 38 + let checkingHandle = $state(false) 39 + let handleError = $state<string | null>(null) 40 + 41 + let serverInfo = $state<{ 42 + availableUserDomains: string[] 43 + inviteCodeRequired: boolean 44 + } | null>(null) 45 + 46 + let commsChannels = $state<CommsChannelConfig>({ 47 + email: true, 48 + discord: false, 49 + telegram: false, 50 + signal: false, 51 + }) 52 + 53 + function getToken(): string | null { 54 + const params = new URLSearchParams(window.location.search) 55 + return params.get('token') 56 + } 57 + 58 + function getProviderDisplayName(provider: string): string { 59 + const names: Record<string, string> = { 60 + github: 'GitHub', 61 + discord: 'Discord', 62 + google: 'Google', 63 + gitlab: 'GitLab', 64 + oidc: 'SSO', 65 + } 66 + return names[provider] || provider 67 + } 68 + 69 + function isChannelAvailable(ch: string): boolean { 70 + return commsChannels[ch as keyof CommsChannelConfig] ?? false 71 + } 72 + 73 + let fullHandle = $derived(() => { 74 + if (!handle.trim()) return '' 75 + const domain = serverInfo?.availableUserDomains?.[0] 76 + return domain ? `${handle.trim()}.${domain}` : handle.trim() 77 + }) 78 + 79 + onMount(() => { 80 + loadPendingRegistration() 81 + loadServerInfo() 82 + }) 83 + 84 + async function loadServerInfo() { 85 + try { 86 + const response = await fetch('/xrpc/com.atproto.server.describeServer') 87 + if (response.ok) { 88 + const data = await response.json() 89 + serverInfo = { 90 + availableUserDomains: data.availableUserDomains || [], 91 + inviteCodeRequired: data.inviteCodeRequired ?? false, 92 + } 93 + if (data.commsChannels) { 94 + commsChannels = { 95 + email: data.commsChannels.email ?? true, 96 + discord: data.commsChannels.discord ?? false, 97 + telegram: data.commsChannels.telegram ?? false, 98 + signal: data.commsChannels.signal ?? false, 99 + } 100 + } 101 + } 102 + } catch { 103 + serverInfo = null 104 + } 105 + } 106 + 107 + async function loadPendingRegistration() { 108 + const token = getToken() 109 + if (!token) { 110 + error = $_('sso_register.error_expired') 111 + loading = false 112 + return 113 + } 114 + 115 + try { 116 + const response = await fetch(`/oauth/sso/pending-registration?token=${encodeURIComponent(token)}`) 117 + if (!response.ok) { 118 + const data = await response.json() 119 + error = data.message || $_('sso_register.error_expired') 120 + loading = false 121 + return 122 + } 123 + 124 + pending = await response.json() 125 + if (pending?.provider_email) { 126 + email = pending.provider_email 127 + providerEmailOriginal = pending.provider_email 128 + } 129 + if (pending?.provider_username) { 130 + handle = pending.provider_username.toLowerCase().replace(/[^a-z0-9-]/g, '') 131 + } 132 + } catch { 133 + error = $_('sso_register.error_expired') 134 + } finally { 135 + loading = false 136 + } 137 + } 138 + 139 + let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null 140 + 141 + $effect(() => { 142 + if (checkHandleTimeout) { 143 + clearTimeout(checkHandleTimeout) 144 + } 145 + handleAvailable = null 146 + handleError = null 147 + if (handle.length >= 3) { 148 + checkHandleTimeout = setTimeout(() => checkHandleAvailability(), 400) 149 + } 150 + }) 151 + 152 + async function checkHandleAvailability() { 153 + if (!handle || handle.length < 3) return 154 + 155 + checkingHandle = true 156 + handleError = null 157 + 158 + try { 159 + const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`) 160 + const data = await response.json() 161 + handleAvailable = data.available 162 + if (!data.available && data.reason) { 163 + handleError = data.reason 164 + } 165 + } catch { 166 + handleAvailable = null 167 + handleError = $_('common.error') 168 + } finally { 169 + checkingHandle = false 170 + } 171 + } 172 + 173 + let usingVerifiedProviderEmail = $derived( 174 + pending?.provider_email_verified && 175 + verificationChannel === 'email' && 176 + email.trim().toLowerCase() === providerEmailOriginal?.toLowerCase() 177 + ) 178 + 179 + function isChannelValid(): boolean { 180 + switch (verificationChannel) { 181 + case 'email': 182 + return !!email.trim() 183 + case 'discord': 184 + return !!discordId.trim() 185 + case 'telegram': 186 + return !!telegramUsername.trim() 187 + case 'signal': 188 + return !!signalNumber.trim() 189 + default: 190 + return false 191 + } 192 + } 193 + 194 + async function handleSubmit(e: Event) { 195 + e.preventDefault() 196 + const token = getToken() 197 + if (!token || !pending) return 198 + 199 + if (!handle || handle.length < 3) { 200 + handleError = $_('sso_register.error_handle_required') 201 + return 202 + } 203 + 204 + if (handleAvailable === false) { 205 + handleError = $_('sso_register.handle_taken') 206 + return 207 + } 208 + 209 + if (!isChannelValid()) { 210 + toast.error($_(`register.validation.${verificationChannel === 'email' ? 'emailRequired' : verificationChannel + 'Required'}`)) 211 + return 212 + } 213 + 214 + submitting = true 215 + 216 + try { 217 + const response = await fetch('/oauth/sso/complete-registration', { 218 + method: 'POST', 219 + headers: { 220 + 'Content-Type': 'application/json', 221 + 'Accept': 'application/json', 222 + }, 223 + body: JSON.stringify({ 224 + token, 225 + handle, 226 + email: email || null, 227 + invite_code: inviteCode || null, 228 + verification_channel: verificationChannel, 229 + discord_id: discordId || null, 230 + telegram_username: telegramUsername || null, 231 + signal_number: signalNumber || null, 232 + }), 233 + }) 234 + 235 + const data = await response.json() 236 + 237 + if (!response.ok) { 238 + toast.error(data.message || data.error_description || data.error || $_('common.error')) 239 + submitting = false 240 + return 241 + } 242 + 243 + if (data.accessJwt && data.refreshJwt) { 244 + localStorage.setItem('accessJwt', data.accessJwt) 245 + localStorage.setItem('refreshJwt', data.refreshJwt) 246 + } 247 + 248 + if (data.redirectUrl) { 249 + if (data.redirectUrl.startsWith('/app/verify')) { 250 + localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({ 251 + did: data.did, 252 + handle: data.handle, 253 + channel: verificationChannel, 254 + })) 255 + } 256 + window.location.href = data.redirectUrl 257 + return 258 + } 259 + 260 + toast.error($_('common.error')) 261 + submitting = false 262 + } catch { 263 + toast.error($_('common.error')) 264 + submitting = false 265 + } 266 + } 267 + </script> 268 + 269 + <div class="sso-register-container"> 270 + {#if loading} 271 + <div class="loading"> 272 + <div class="spinner"></div> 273 + <p>{$_('common.loading')}</p> 274 + </div> 275 + {:else if error && !pending} 276 + <div class="error-container"> 277 + <div class="error-icon">!</div> 278 + <h2>{$_('common.error')}</h2> 279 + <p>{error}</p> 280 + <a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a> 281 + </div> 282 + {:else if pending} 283 + <header class="page-header"> 284 + <h1>{$_('sso_register.title')}</h1> 285 + <p class="subtitle">{$_('sso_register.subtitle', { values: { provider: getProviderDisplayName(pending.provider) } })}</p> 286 + </header> 287 + 288 + <div class="provider-info"> 289 + <div class="provider-badge"> 290 + <SsoIcon provider={pending.provider} size={32} /> 291 + <div class="provider-details"> 292 + <span class="provider-name">{getProviderDisplayName(pending.provider)}</span> 293 + {#if pending.provider_username} 294 + <span class="provider-username">@{pending.provider_username}</span> 295 + {/if} 296 + </div> 297 + </div> 298 + </div> 299 + 300 + <div class="split-layout sidebar-right"> 301 + <div class="form-section"> 302 + <form onsubmit={handleSubmit}> 303 + <div class="field"> 304 + <label for="handle">{$_('sso_register.handle_label')}</label> 305 + <input 306 + id="handle" 307 + type="text" 308 + bind:value={handle} 309 + placeholder={$_('register.handlePlaceholder')} 310 + disabled={submitting} 311 + required 312 + autocomplete="off" 313 + /> 314 + {#if checkingHandle} 315 + <p class="hint">{$_('common.checking')}</p> 316 + {:else if handleError} 317 + <p class="hint error">{handleError}</p> 318 + {:else if handleAvailable === false} 319 + <p class="hint error">{$_('sso_register.handle_taken')}</p> 320 + {:else if handleAvailable === true} 321 + <p class="hint success">{$_('sso_register.handle_available')}</p> 322 + {:else if fullHandle()} 323 + <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 324 + {/if} 325 + </div> 326 + 327 + <fieldset> 328 + <legend>{$_('register.contactMethod')}</legend> 329 + <div class="contact-fields"> 330 + <div class="field"> 331 + <label for="verification-channel">{$_('register.verificationMethod')}</label> 332 + <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 333 + <option value="email">{$_('register.email')}</option> 334 + <option value="discord" disabled={!isChannelAvailable('discord')}> 335 + {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 336 + </option> 337 + <option value="telegram" disabled={!isChannelAvailable('telegram')}> 338 + {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 339 + </option> 340 + <option value="signal" disabled={!isChannelAvailable('signal')}> 341 + {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 342 + </option> 343 + </select> 344 + </div> 345 + 346 + {#if verificationChannel === 'email'} 347 + <div class="field"> 348 + <label for="email">{$_('register.emailAddress')}</label> 349 + <input 350 + id="email" 351 + type="email" 352 + bind:value={email} 353 + placeholder={$_('register.emailPlaceholder')} 354 + disabled={submitting} 355 + required 356 + /> 357 + {#if pending?.provider_email && pending?.provider_email_verified} 358 + {#if usingVerifiedProviderEmail} 359 + <p class="hint success">{$_('sso_register.emailVerifiedByProvider', { values: { provider: getProviderDisplayName(pending.provider) } })}</p> 360 + {:else} 361 + <p class="hint">{$_('sso_register.emailChangedNeedsVerification')}</p> 362 + {/if} 363 + {/if} 364 + </div> 365 + {:else if verificationChannel === 'discord'} 366 + <div class="field"> 367 + <label for="discord-id">{$_('register.discordId')}</label> 368 + <input 369 + id="discord-id" 370 + type="text" 371 + bind:value={discordId} 372 + placeholder={$_('register.discordIdPlaceholder')} 373 + disabled={submitting} 374 + required 375 + /> 376 + <p class="hint">{$_('register.discordIdHint')}</p> 377 + </div> 378 + {:else if verificationChannel === 'telegram'} 379 + <div class="field"> 380 + <label for="telegram-username">{$_('register.telegramUsername')}</label> 381 + <input 382 + id="telegram-username" 383 + type="text" 384 + bind:value={telegramUsername} 385 + placeholder={$_('register.telegramUsernamePlaceholder')} 386 + disabled={submitting} 387 + required 388 + /> 389 + </div> 390 + {:else if verificationChannel === 'signal'} 391 + <div class="field"> 392 + <label for="signal-number">{$_('register.signalNumber')}</label> 393 + <input 394 + id="signal-number" 395 + type="tel" 396 + bind:value={signalNumber} 397 + placeholder={$_('register.signalNumberPlaceholder')} 398 + disabled={submitting} 399 + required 400 + /> 401 + <p class="hint">{$_('register.signalNumberHint')}</p> 402 + </div> 403 + {/if} 404 + </div> 405 + </fieldset> 406 + 407 + {#if serverInfo?.inviteCodeRequired} 408 + <div class="field"> 409 + <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 410 + <input 411 + id="invite-code" 412 + type="text" 413 + bind:value={inviteCode} 414 + placeholder={$_('register.inviteCodePlaceholder')} 415 + disabled={submitting} 416 + required 417 + /> 418 + </div> 419 + {/if} 420 + 421 + <button type="submit" disabled={submitting || !handle || handle.length < 3 || handleAvailable === false || checkingHandle || !isChannelValid()}> 422 + {submitting ? $_('common.creating') : $_('sso_register.submit')} 423 + </button> 424 + </form> 425 + </div> 426 + 427 + <aside class="info-panel"> 428 + <h3>{$_('sso_register.infoAfterTitle')}</h3> 429 + <ul class="info-list"> 430 + <li>{$_('sso_register.infoAddPassword')}</li> 431 + <li>{$_('sso_register.infoAddPasskey')}</li> 432 + <li>{$_('sso_register.infoLinkProviders')}</li> 433 + <li>{$_('sso_register.infoChangeHandle')}</li> 434 + </ul> 435 + </aside> 436 + </div> 437 + {/if} 438 + </div> 439 + 440 + <style> 441 + .sso-register-container { 442 + max-width: var(--width-lg); 443 + margin: var(--space-9) auto; 444 + padding: var(--space-7); 445 + } 446 + 447 + .loading { 448 + display: flex; 449 + flex-direction: column; 450 + align-items: center; 451 + gap: var(--space-4); 452 + padding: var(--space-8); 453 + } 454 + 455 + .loading p { 456 + color: var(--text-secondary); 457 + } 458 + 459 + .error-container { 460 + text-align: center; 461 + padding: var(--space-8); 462 + } 463 + 464 + .error-icon { 465 + width: 48px; 466 + height: 48px; 467 + border-radius: 50%; 468 + background: var(--error-text); 469 + color: var(--text-inverse); 470 + display: flex; 471 + align-items: center; 472 + justify-content: center; 473 + font-size: 24px; 474 + font-weight: bold; 475 + margin: 0 auto var(--space-4); 476 + } 477 + 478 + .error-container h2 { 479 + margin-bottom: var(--space-2); 480 + } 481 + 482 + .error-container p { 483 + color: var(--text-secondary); 484 + margin-bottom: var(--space-6); 485 + } 486 + 487 + .back-link { 488 + color: var(--accent); 489 + text-decoration: none; 490 + } 491 + 492 + .back-link:hover { 493 + text-decoration: underline; 494 + } 495 + 496 + .page-header { 497 + margin-bottom: var(--space-6); 498 + } 499 + 500 + .page-header h1 { 501 + margin: 0 0 var(--space-3) 0; 502 + } 503 + 504 + .subtitle { 505 + color: var(--text-secondary); 506 + margin: 0; 507 + } 508 + 509 + .form-section { 510 + min-width: 0; 511 + } 512 + 513 + form { 514 + display: flex; 515 + flex-direction: column; 516 + gap: var(--space-5); 517 + } 518 + 519 + .contact-fields { 520 + display: flex; 521 + flex-direction: column; 522 + gap: var(--space-4); 523 + } 524 + 525 + .contact-fields .field { 526 + margin-bottom: 0; 527 + } 528 + 529 + .hint.success { 530 + color: var(--success-text); 531 + } 532 + 533 + .hint.error { 534 + color: var(--error-text); 535 + } 536 + 537 + .info-panel { 538 + background: var(--bg-secondary); 539 + border-radius: var(--radius-xl); 540 + padding: var(--space-6); 541 + } 542 + 543 + .info-panel h3 { 544 + margin: 0 0 var(--space-4) 0; 545 + font-size: var(--text-base); 546 + font-weight: var(--font-semibold); 547 + } 548 + 549 + .info-list { 550 + margin: 0; 551 + padding-left: var(--space-5); 552 + } 553 + 554 + .info-list li { 555 + margin-bottom: var(--space-2); 556 + font-size: var(--text-sm); 557 + color: var(--text-secondary); 558 + line-height: var(--leading-relaxed); 559 + } 560 + 561 + .info-list li:last-child { 562 + margin-bottom: 0; 563 + } 564 + 565 + .provider-info { 566 + margin-bottom: var(--space-6); 567 + } 568 + 569 + .provider-badge { 570 + display: flex; 571 + align-items: center; 572 + gap: var(--space-3); 573 + padding: var(--space-4); 574 + background: var(--bg-secondary); 575 + border-radius: var(--radius-md); 576 + } 577 + 578 + .provider-details { 579 + display: flex; 580 + flex-direction: column; 581 + } 582 + 583 + .provider-name { 584 + font-weight: var(--font-semibold); 585 + } 586 + 587 + .provider-username { 588 + font-size: var(--text-sm); 589 + color: var(--text-secondary); 590 + } 591 + 592 + .required { 593 + color: var(--error-text); 594 + } 595 + 596 + button[type="submit"] { 597 + margin-top: var(--space-3); 598 + } 599 + 600 + .spinner { 601 + width: 32px; 602 + height: 32px; 603 + border: 3px solid var(--border-color); 604 + border-top-color: var(--accent); 605 + border-radius: 50%; 606 + animation: spin 1s linear infinite; 607 + } 608 + 609 + @keyframes spin { 610 + to { 611 + transform: rotate(360deg); 612 + } 613 + } 614 + </style>
+15 -1
frontend/src/routes/Register.svelte
··· 19 19 } | null>(null) 20 20 let loadingServerInfo = $state(true) 21 21 let serverInfoLoaded = false 22 + let ssoAvailable = $state(false) 22 23 23 24 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 24 25 let confirmPassword = $state('') ··· 27 28 if (!serverInfoLoaded) { 28 29 serverInfoLoaded = true 29 30 loadServerInfo() 31 + checkSsoAvailable() 30 32 } 31 33 }) 34 + 35 + async function checkSsoAvailable() { 36 + try { 37 + const response = await fetch('/oauth/sso/providers') 38 + if (response.ok) { 39 + const data = await response.json() 40 + ssoAvailable = (data.providers?.length ?? 0) > 0 41 + } 42 + } catch { 43 + ssoAvailable = false 44 + } 45 + } 32 46 33 47 $effect(() => { 34 48 if (flow?.state.step === 'redirect-to-dashboard') { ··· 187 201 </div> 188 202 </div> 189 203 190 - <AccountTypeSwitcher active="password" /> 204 + <AccountTypeSwitcher active="password" {ssoAvailable} /> 191 205 192 206 <div class="split-layout sidebar-right"> 193 207 <div class="form-section">
+15 -1
frontend/src/routes/RegisterPasskey.svelte
··· 25 25 } | null>(null) 26 26 let loadingServerInfo = $state(true) 27 27 let serverInfoLoaded = false 28 + let ssoAvailable = $state(false) 28 29 29 30 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 30 31 let passkeyName = $state('') ··· 33 34 if (!serverInfoLoaded) { 34 35 serverInfoLoaded = true 35 36 loadServerInfo() 37 + checkSsoAvailable() 36 38 } 37 39 }) 40 + 41 + async function checkSsoAvailable() { 42 + try { 43 + const response = await fetch('/oauth/sso/providers') 44 + if (response.ok) { 45 + const data = await response.json() 46 + ssoAvailable = (data.providers?.length ?? 0) > 0 47 + } 48 + } catch { 49 + ssoAvailable = false 50 + } 51 + } 38 52 39 53 $effect(() => { 40 54 if (flow?.state.step === 'redirect-to-dashboard') { ··· 247 261 </div> 248 262 </div> 249 263 250 - <AccountTypeSwitcher active="passkey" /> 264 + <AccountTypeSwitcher active="passkey" {ssoAvailable} /> 251 265 252 266 <div class="split-layout sidebar-right"> 253 267 <div class="form-section">
+293
frontend/src/routes/RegisterSso.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte' 3 + import { _ } from '../lib/i18n' 4 + import { getFullUrl } from '../lib/router.svelte' 5 + import { routes } from '../lib/types/routes' 6 + import { toast } from '../lib/toast.svelte' 7 + import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 8 + import SsoIcon from '../components/SsoIcon.svelte' 9 + 10 + interface SsoProvider { 11 + provider: string 12 + name: string 13 + icon: string 14 + } 15 + 16 + let providers = $state<SsoProvider[]>([]) 17 + let loading = $state(true) 18 + let initiating = $state<string | null>(null) 19 + 20 + onMount(() => { 21 + fetchProviders() 22 + }) 23 + 24 + async function fetchProviders() { 25 + try { 26 + const response = await fetch('/oauth/sso/providers') 27 + if (response.ok) { 28 + const data = await response.json() 29 + providers = data.providers || [] 30 + } 31 + } catch { 32 + toast.error($_('common.error')) 33 + } finally { 34 + loading = false 35 + } 36 + } 37 + 38 + async function initiateRegistration(provider: string) { 39 + initiating = provider 40 + 41 + try { 42 + const response = await fetch('/oauth/sso/initiate', { 43 + method: 'POST', 44 + headers: { 45 + 'Content-Type': 'application/json', 46 + 'Accept': 'application/json', 47 + }, 48 + body: JSON.stringify({ 49 + provider, 50 + action: 'register', 51 + }), 52 + }) 53 + 54 + const data = await response.json() 55 + 56 + if (!response.ok) { 57 + toast.error(data.error_description || data.error || $_('common.error')) 58 + initiating = null 59 + return 60 + } 61 + 62 + if (data.redirect_url) { 63 + window.location.href = data.redirect_url 64 + return 65 + } 66 + 67 + toast.error($_('common.error')) 68 + initiating = null 69 + } catch { 70 + toast.error($_('common.error')) 71 + initiating = null 72 + } 73 + } 74 + </script> 75 + 76 + <div class="register-sso-page"> 77 + <header class="page-header"> 78 + <h1>{$_('register.title')}</h1> 79 + <p class="subtitle">{$_('register.ssoSubtitle')}</p> 80 + </header> 81 + 82 + <div class="migrate-callout"> 83 + <div class="migrate-icon">↗</div> 84 + <div class="migrate-content"> 85 + <strong>{$_('register.migrateTitle')}</strong> 86 + <p>{$_('register.migrateDescription')}</p> 87 + <a href={getFullUrl(routes.migrate)} class="migrate-link"> 88 + {$_('register.migrateLink')} → 89 + </a> 90 + </div> 91 + </div> 92 + 93 + <AccountTypeSwitcher active="sso" ssoAvailable={providers.length > 0} /> 94 + 95 + {#if loading} 96 + <div class="loading"> 97 + <div class="spinner"></div> 98 + </div> 99 + {:else if providers.length === 0} 100 + <div class="no-providers"> 101 + <p>{$_('register.noSsoProviders')}</p> 102 + </div> 103 + {:else} 104 + <div class="provider-list"> 105 + <p class="provider-hint">{$_('register.ssoHint')}</p> 106 + <div class="provider-grid"> 107 + {#each providers as provider} 108 + <button 109 + class="provider-button" 110 + onclick={() => initiateRegistration(provider.provider)} 111 + disabled={initiating !== null} 112 + > 113 + <SsoIcon provider={provider.provider} size={24} /> 114 + <span class="provider-name"> 115 + {#if initiating === provider.provider} 116 + {$_('common.loading')} 117 + {:else} 118 + {$_('register.continueWith', { values: { provider: provider.name } })} 119 + {/if} 120 + </span> 121 + </button> 122 + {/each} 123 + </div> 124 + </div> 125 + {/if} 126 + 127 + <div class="form-links"> 128 + <p class="link-text"> 129 + {$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a> 130 + </p> 131 + </div> 132 + </div> 133 + 134 + <style> 135 + .register-sso-page { 136 + max-width: var(--width-lg); 137 + margin: var(--space-9) auto; 138 + padding: var(--space-7); 139 + } 140 + 141 + .page-header { 142 + margin-bottom: var(--space-6); 143 + } 144 + 145 + .page-header h1 { 146 + margin: 0 0 var(--space-3) 0; 147 + } 148 + 149 + .subtitle { 150 + color: var(--text-secondary); 151 + margin: 0; 152 + } 153 + 154 + .migrate-callout { 155 + display: flex; 156 + gap: var(--space-4); 157 + padding: var(--space-5); 158 + background: var(--accent-muted); 159 + border: 1px solid var(--accent); 160 + border-radius: var(--radius-xl); 161 + margin-bottom: var(--space-6); 162 + } 163 + 164 + .migrate-icon { 165 + font-size: var(--text-2xl); 166 + line-height: 1; 167 + color: var(--accent); 168 + } 169 + 170 + .migrate-content { 171 + flex: 1; 172 + } 173 + 174 + .migrate-content strong { 175 + display: block; 176 + color: var(--text-primary); 177 + margin-bottom: var(--space-2); 178 + } 179 + 180 + .migrate-content p { 181 + margin: 0 0 var(--space-3) 0; 182 + font-size: var(--text-sm); 183 + color: var(--text-secondary); 184 + line-height: var(--leading-relaxed); 185 + } 186 + 187 + .migrate-link { 188 + font-size: var(--text-sm); 189 + font-weight: var(--font-medium); 190 + color: var(--accent); 191 + text-decoration: none; 192 + } 193 + 194 + .migrate-link:hover { 195 + text-decoration: underline; 196 + } 197 + 198 + .loading { 199 + display: flex; 200 + justify-content: center; 201 + padding: var(--space-8); 202 + } 203 + 204 + .spinner { 205 + width: 32px; 206 + height: 32px; 207 + border: 3px solid var(--border-color); 208 + border-top-color: var(--accent); 209 + border-radius: 50%; 210 + animation: spin 1s linear infinite; 211 + } 212 + 213 + @keyframes spin { 214 + to { 215 + transform: rotate(360deg); 216 + } 217 + } 218 + 219 + .no-providers { 220 + text-align: center; 221 + padding: var(--space-8); 222 + color: var(--text-secondary); 223 + } 224 + 225 + .provider-list { 226 + display: flex; 227 + flex-direction: column; 228 + gap: var(--space-3); 229 + max-width: var(--width-md); 230 + } 231 + 232 + .provider-hint { 233 + color: var(--text-secondary); 234 + font-size: var(--text-sm); 235 + margin: 0 0 var(--space-4) 0; 236 + } 237 + 238 + .provider-grid { 239 + display: grid; 240 + grid-template-columns: 1fr; 241 + gap: var(--space-3); 242 + } 243 + 244 + @media (min-width: 500px) { 245 + .provider-grid { 246 + grid-template-columns: repeat(2, 1fr); 247 + } 248 + } 249 + 250 + .provider-button { 251 + display: flex; 252 + align-items: center; 253 + gap: var(--space-3); 254 + padding: var(--space-4); 255 + background: var(--bg-card); 256 + border: 1px solid var(--border-dark); 257 + border-radius: var(--radius-lg); 258 + cursor: pointer; 259 + transition: all var(--transition-normal); 260 + font-size: var(--text-base); 261 + font-weight: var(--font-medium); 262 + color: var(--text-primary); 263 + text-align: left; 264 + width: 100%; 265 + } 266 + 267 + .provider-button:hover:not(:disabled) { 268 + background: var(--bg-secondary); 269 + border-color: var(--accent); 270 + } 271 + 272 + .provider-button:disabled { 273 + opacity: 0.6; 274 + cursor: not-allowed; 275 + } 276 + 277 + .provider-name { 278 + flex: 1; 279 + } 280 + 281 + .form-links { 282 + margin-top: var(--space-8); 283 + } 284 + 285 + .link-text { 286 + text-align: center; 287 + color: var(--text-secondary); 288 + } 289 + 290 + .link-text a { 291 + color: var(--accent); 292 + } 293 + </style>
+327
frontend/src/routes/Security.svelte
··· 3 3 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 4 4 import { api, ApiError } from '../lib/api' 5 5 import ReauthModal from '../components/ReauthModal.svelte' 6 + import SsoIcon from '../components/SsoIcon.svelte' 6 7 import { _ } from '../lib/i18n' 7 8 import { formatDate as formatDateUtil } from '../lib/date' 8 9 import type { Session } from '../lib/types/api' ··· 12 13 type WebAuthnCreationOptionsResponse, 13 14 } from '../lib/webauthn' 14 15 import { toast } from '../lib/toast.svelte' 16 + 17 + interface SsoProvider { 18 + provider: string 19 + name: string 20 + icon: string 21 + } 22 + 23 + interface LinkedAccount { 24 + id: string 25 + provider: string 26 + provider_name: string 27 + provider_username: string | null 28 + provider_email: string | null 29 + created_at: string 30 + last_login_at: string | null 31 + } 15 32 16 33 const auth = $derived(getAuthState()) 17 34 ··· 69 86 let legacyLoginLoading = $state(true) 70 87 let legacyLoginUpdating = $state(false) 71 88 89 + let ssoProviders = $state<SsoProvider[]>([]) 90 + let linkedAccounts = $state<LinkedAccount[]>([]) 91 + let linkedAccountsLoading = $state(true) 92 + let linkingProvider = $state<string | null>(null) 93 + let unlinkingId = $state<string | null>(null) 94 + 72 95 let showReauthModal = $state(false) 73 96 let reauthMethods = $state<string[]>(['password']) 74 97 let pendingAction = $state<(() => Promise<void>) | null>(null) ··· 85 108 loadPasskeys() 86 109 loadPasswordStatus() 87 110 loadLegacyLoginPreference() 111 + loadSsoProviders() 112 + loadLinkedAccounts() 88 113 } 89 114 }) 115 + 116 + async function loadSsoProviders() { 117 + try { 118 + const response = await fetch('/oauth/sso/providers') 119 + if (response.ok) { 120 + const data = await response.json() 121 + ssoProviders = data.providers || [] 122 + } 123 + } catch { 124 + ssoProviders = [] 125 + } 126 + } 127 + 128 + async function loadLinkedAccounts() { 129 + if (!session) return 130 + linkedAccountsLoading = true 131 + try { 132 + const response = await fetch('/oauth/sso/linked', { 133 + headers: { 'Authorization': `Bearer ${session.accessJwt}` } 134 + }) 135 + if (response.ok) { 136 + const data = await response.json() 137 + linkedAccounts = data.accounts || [] 138 + } 139 + } catch { 140 + linkedAccounts = [] 141 + } finally { 142 + linkedAccountsLoading = false 143 + } 144 + } 145 + 146 + async function handleLinkAccount(provider: string) { 147 + linkingProvider = provider 148 + 149 + const linkRequestUri = `urn:tranquil:sso:link:${Date.now()}` 150 + 151 + try { 152 + const response = await fetch('/oauth/sso/initiate', { 153 + method: 'POST', 154 + headers: { 155 + 'Content-Type': 'application/json', 156 + 'Accept': 'application/json', 157 + 'Authorization': `Bearer ${session?.accessJwt}` 158 + }, 159 + body: JSON.stringify({ 160 + provider, 161 + request_uri: linkRequestUri, 162 + action: 'link' 163 + }) 164 + }) 165 + 166 + const data = await response.json() 167 + 168 + if (!response.ok) { 169 + if (data.error === 'ReauthRequired') { 170 + reauthMethods = data.reauthMethods || ['password'] 171 + pendingAction = () => handleLinkAccount(provider) 172 + showReauthModal = true 173 + } else { 174 + toast.error(data.error_description || data.error || 'Failed to start SSO linking') 175 + } 176 + linkingProvider = null 177 + return 178 + } 179 + 180 + if (data.redirect_url) { 181 + window.location.href = data.redirect_url 182 + return 183 + } 184 + 185 + toast.error($_('common.error')) 186 + linkingProvider = null 187 + } catch { 188 + toast.error($_('common.error')) 189 + linkingProvider = null 190 + } 191 + } 192 + 193 + async function handleUnlinkAccount(id: string) { 194 + const account = linkedAccounts.find(a => a.id === id) 195 + if (!confirm($_('oauth.sso.unlinkConfirm'))) return 196 + 197 + unlinkingId = id 198 + try { 199 + const response = await fetch('/oauth/sso/unlink', { 200 + method: 'POST', 201 + headers: { 202 + 'Content-Type': 'application/json', 203 + 'Authorization': `Bearer ${session?.accessJwt}` 204 + }, 205 + body: JSON.stringify({ id }) 206 + }) 207 + 208 + if (!response.ok) { 209 + const data = await response.json() 210 + if (data.error === 'ReauthRequired') { 211 + reauthMethods = data.reauthMethods || ['password'] 212 + pendingAction = () => handleUnlinkAccount(id) 213 + showReauthModal = true 214 + } else { 215 + toast.error(data.error_description || data.error || 'Failed to unlink account') 216 + } 217 + unlinkingId = null 218 + return 219 + } 220 + 221 + await loadLinkedAccounts() 222 + toast.success($_('oauth.sso.unlinked', { values: { provider: account?.provider_name || 'account' } })) 223 + } catch { 224 + toast.error($_('common.error')) 225 + } finally { 226 + unlinkingId = null 227 + } 228 + } 90 229 91 230 async function loadPasswordStatus() { 92 231 if (!session) return ··· 696 835 {$_('security.manageTrustedDevices')} &rarr; 697 836 </a> 698 837 </section> 838 + 839 + {#if ssoProviders.length > 0} 840 + <section> 841 + <h2>{$_('oauth.sso.linkedAccounts')}</h2> 842 + <p class="description"> 843 + {$_('oauth.sso.linkedAccountsDesc')} 844 + </p> 845 + 846 + {#if !linkedAccountsLoading} 847 + {#if linkedAccounts.length > 0} 848 + <div class="linked-accounts-list"> 849 + {#each linkedAccounts as account} 850 + <div class="linked-account-item"> 851 + <div class="linked-account-icon"> 852 + <SsoIcon provider={account.provider} size={24} /> 853 + </div> 854 + <div class="linked-account-info"> 855 + <span class="linked-account-provider">{account.provider_name}</span> 856 + <span class="linked-account-meta"> 857 + {#if account.provider_username} 858 + {account.provider_username} 859 + {:else if account.provider_email} 860 + {account.provider_email} 861 + {/if} 862 + {#if account.last_login_at} 863 + &middot; {$_('oauth.sso.lastLoginAt')} {formatDate(account.last_login_at)} 864 + {/if} 865 + </span> 866 + </div> 867 + <button 868 + type="button" 869 + class="small danger-outline" 870 + onclick={() => handleUnlinkAccount(account.id)} 871 + disabled={unlinkingId !== null} 872 + > 873 + {unlinkingId === account.id ? $_('common.loading') : $_('oauth.sso.unlinkAccount')} 874 + </button> 875 + </div> 876 + {/each} 877 + </div> 878 + {:else} 879 + <div class="status disabled"> 880 + <span>{$_('oauth.sso.noLinkedAccounts')}</span> 881 + </div> 882 + <p class="hint">{$_('oauth.sso.noLinkedAccountsDesc')}</p> 883 + {/if} 884 + 885 + {#if ssoProviders.some(p => !linkedAccounts.some(a => a.provider === p.provider))} 886 + <div class="link-account-section"> 887 + <h3>{$_('oauth.sso.linkAccount')}</h3> 888 + <div class="sso-link-buttons"> 889 + {#each ssoProviders.filter(p => !linkedAccounts.some(a => a.provider === p.provider)) as provider} 890 + <button 891 + type="button" 892 + class="sso-link-btn" 893 + onclick={() => handleLinkAccount(provider.provider)} 894 + disabled={linkingProvider !== null} 895 + > 896 + {#if linkingProvider === provider.provider} 897 + <span class="loading-spinner small"></span> 898 + {:else} 899 + <SsoIcon provider={provider.icon} size={18} /> 900 + {/if} 901 + <span>{provider.name}</span> 902 + </button> 903 + {/each} 904 + </div> 905 + </div> 906 + {/if} 907 + {:else} 908 + <div class="loading-text">{$_('common.loading')}</div> 909 + {/if} 910 + </section> 911 + {/if} 699 912 </div> 700 913 701 914 {#if hasMfa} ··· 1207 1420 .skeleton-grid { 1208 1421 grid-template-columns: 1fr; 1209 1422 } 1423 + } 1424 + 1425 + .linked-accounts-list { 1426 + display: flex; 1427 + flex-direction: column; 1428 + gap: var(--space-2); 1429 + margin-bottom: var(--space-4); 1430 + } 1431 + 1432 + .linked-account-item { 1433 + display: flex; 1434 + align-items: center; 1435 + gap: var(--space-3); 1436 + padding: var(--space-3); 1437 + background: var(--bg-card); 1438 + border: 1px solid var(--border-color); 1439 + border-radius: var(--radius-lg); 1440 + } 1441 + 1442 + .linked-account-icon { 1443 + flex-shrink: 0; 1444 + display: flex; 1445 + align-items: center; 1446 + justify-content: center; 1447 + color: var(--text-secondary); 1448 + } 1449 + 1450 + .linked-account-info { 1451 + flex: 1; 1452 + min-width: 0; 1453 + display: flex; 1454 + flex-direction: column; 1455 + gap: var(--space-1); 1456 + } 1457 + 1458 + .linked-account-provider { 1459 + font-weight: var(--font-medium); 1460 + } 1461 + 1462 + .linked-account-meta { 1463 + font-size: var(--text-xs); 1464 + color: var(--text-secondary); 1465 + overflow: hidden; 1466 + text-overflow: ellipsis; 1467 + white-space: nowrap; 1468 + } 1469 + 1470 + .link-account-section { 1471 + margin-top: var(--space-4); 1472 + padding-top: var(--space-4); 1473 + border-top: 1px solid var(--border-color); 1474 + } 1475 + 1476 + .link-account-section h3 { 1477 + margin: 0 0 var(--space-3) 0; 1478 + font-size: var(--text-sm); 1479 + font-weight: var(--font-medium); 1480 + color: var(--text-secondary); 1481 + } 1482 + 1483 + .sso-link-buttons { 1484 + display: flex; 1485 + flex-wrap: wrap; 1486 + gap: var(--space-2); 1487 + } 1488 + 1489 + .sso-link-btn { 1490 + display: flex; 1491 + align-items: center; 1492 + gap: var(--space-2); 1493 + padding: var(--space-2) var(--space-3); 1494 + background: var(--bg-card); 1495 + color: var(--text-primary); 1496 + border: 1px solid var(--border-color); 1497 + border-radius: var(--radius-md); 1498 + font-size: var(--text-sm); 1499 + cursor: pointer; 1500 + transition: background-color var(--transition-fast), border-color var(--transition-fast); 1501 + } 1502 + 1503 + .sso-link-btn:hover:not(:disabled) { 1504 + background: var(--bg-secondary); 1505 + border-color: var(--accent); 1506 + } 1507 + 1508 + .sso-link-btn:disabled { 1509 + opacity: 0.6; 1510 + cursor: not-allowed; 1511 + } 1512 + 1513 + .loading-spinner.small { 1514 + width: 18px; 1515 + height: 18px; 1516 + border-width: 2px; 1517 + } 1518 + 1519 + .loading-spinner { 1520 + border: 3px solid var(--border-color); 1521 + border-top-color: var(--accent); 1522 + border-radius: 50%; 1523 + animation: spin 0.8s linear infinite; 1524 + } 1525 + 1526 + @keyframes spin { 1527 + to { 1528 + transform: rotate(360deg); 1529 + } 1530 + } 1531 + 1532 + .loading-text { 1533 + color: var(--text-secondary); 1534 + font-size: var(--text-sm); 1535 + text-align: center; 1536 + padding: var(--space-4); 1210 1537 } 1211 1538 </style>
+84 -19
frontend/src/routes/Settings.svelte
··· 31 31 pdsHostname = info.availableUserDomains[0] 32 32 } 33 33 }).catch(() => {}) 34 + 35 + return () => { 36 + stopEmailPolling() 37 + } 34 38 }) 35 39 36 40 let localeLoading = $state(false) ··· 51 55 let newEmail = $state('') 52 56 let emailToken = $state('') 53 57 let emailTokenRequired = $state(false) 58 + let emailUpdateAuthorized = $state(false) 59 + let emailPollingInterval = $state<ReturnType<typeof setInterval> | null>(null) 54 60 let handleLoading = $state(false) 55 61 let newHandle = $state('') 56 62 let deleteLoading = $state(false) ··· 97 103 } 98 104 99 105 async function handleRequestEmailUpdate() { 100 - if (!session) return 106 + if (!session || !newEmail.trim()) return 101 107 emailLoading = true 102 108 try { 103 - const result = await api.requestEmailUpdate(session.accessJwt) 109 + const result = await api.requestEmailUpdate(session.accessJwt, newEmail.trim()) 104 110 emailTokenRequired = result.tokenRequired 105 111 if (emailTokenRequired) { 106 112 toast.success($_('settings.messages.emailCodeSentToCurrent')) 113 + startEmailPolling() 107 114 } else { 108 115 emailTokenRequired = true 109 116 } 117 + } catch (e) { 118 + toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 119 + } finally { 120 + emailLoading = false 121 + } 122 + } 123 + 124 + function startEmailPolling() { 125 + if (emailPollingInterval) return 126 + emailPollingInterval = setInterval(async () => { 127 + if (!session) return 128 + try { 129 + const status = await api.checkEmailUpdateStatus(session.accessJwt) 130 + if (status.authorized) { 131 + emailUpdateAuthorized = true 132 + stopEmailPolling() 133 + await completeAuthorizedEmailUpdate() 134 + } 135 + } catch { 136 + } 137 + }, 3000) 138 + } 139 + 140 + function stopEmailPolling() { 141 + if (emailPollingInterval) { 142 + clearInterval(emailPollingInterval) 143 + emailPollingInterval = null 144 + } 145 + } 146 + 147 + async function completeAuthorizedEmailUpdate() { 148 + if (!session || !newEmail.trim()) return 149 + emailLoading = true 150 + try { 151 + await api.updateEmail(session.accessJwt, newEmail.trim()) 152 + await refreshSession() 153 + toast.success($_('settings.messages.emailUpdated')) 154 + newEmail = '' 155 + emailToken = '' 156 + emailTokenRequired = false 157 + emailUpdateAuthorized = false 110 158 } catch (e) { 111 159 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 112 160 } finally { ··· 474 522 {/if} 475 523 {#if emailTokenRequired} 476 524 <form onsubmit={handleConfirmEmailUpdate}> 477 - <div class="field"> 478 - <label for="email-token">{$_('settings.verificationCode')}</label> 479 - <input 480 - id="email-token" 481 - type="text" 482 - bind:value={emailToken} 483 - placeholder={$_('settings.verificationCodePlaceholder')} 484 - disabled={emailLoading} 485 - required 486 - /> 487 - </div> 525 + {#if emailUpdateAuthorized} 526 + <p class="hint success">{$_('settings.emailUpdateAuthorized')}</p> 527 + {:else} 528 + <div class="field"> 529 + <label for="email-token">{$_('settings.verificationCode')}</label> 530 + <input 531 + id="email-token" 532 + type="text" 533 + bind:value={emailToken} 534 + placeholder={$_('settings.verificationCodePlaceholder')} 535 + disabled={emailLoading} 536 + /> 537 + <p class="hint">{$_('settings.emailTokenHint')}</p> 538 + </div> 539 + {/if} 488 540 <div class="field"> 489 541 <label for="new-email">{$_('settings.newEmail')}</label> 490 542 <input ··· 492 544 type="email" 493 545 bind:value={newEmail} 494 546 placeholder={$_('settings.newEmailPlaceholder')} 495 - disabled={emailLoading} 547 + disabled={emailLoading || emailUpdateAuthorized} 496 548 required 497 549 /> 498 550 </div> 499 551 <div class="actions"> 500 - <button type="submit" disabled={emailLoading || !emailToken || !newEmail}> 552 + <button type="submit" disabled={emailLoading || (!emailToken && !emailUpdateAuthorized) || !newEmail}> 501 553 {emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')} 502 554 </button> 503 - <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = ''; newEmail = '' }}> 555 + <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = ''; newEmail = ''; emailUpdateAuthorized = false; stopEmailPolling() }}> 504 556 {$_('common.cancel')} 505 557 </button> 506 558 </div> 507 559 </form> 508 560 {:else} 509 - <button onclick={handleRequestEmailUpdate} disabled={emailLoading}> 510 - {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 511 - </button> 561 + <form onsubmit={(e) => { e.preventDefault(); handleRequestEmailUpdate() }}> 562 + <div class="field"> 563 + <label for="new-email">{$_('settings.newEmail')}</label> 564 + <input 565 + id="new-email" 566 + type="email" 567 + bind:value={newEmail} 568 + placeholder={$_('settings.newEmailPlaceholder')} 569 + disabled={emailLoading} 570 + required 571 + /> 572 + </div> 573 + <button type="submit" disabled={emailLoading || !newEmail.trim()}> 574 + {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 575 + </button> 576 + </form> 512 577 {/if} 513 578 </section> 514 579 <section>
+28 -17
frontend/src/routes/Verify.svelte
··· 15 15 channel: string 16 16 } 17 17 18 - type VerificationMode = 'signup' | 'token' | 'email-update' 18 + type VerificationMode = 'signup' | 'token' | 'email-update' | 'email-authorize-success' 19 19 20 20 let mode = $state<VerificationMode>('signup') 21 21 let newEmail = $state('') ··· 30 30 let autoSubmitting = $state(false) 31 31 let successPurpose = $state<string | null>(null) 32 32 let successChannel = $state<string | null>(null) 33 + let tokenFromUrl = $state(false) 33 34 34 35 const auth = $derived(getAuthState()) 35 36 ··· 46 47 onMount(async () => { 47 48 const params = parseQueryParams() 48 49 49 - if (params.type === 'email-update') { 50 + if (params.type === 'email-authorize-success') { 51 + mode = 'email-authorize-success' 52 + success = true 53 + successPurpose = 'email-authorize' 54 + } else if (params.type === 'email-update') { 50 55 mode = 'email-update' 51 56 if (params.token) { 52 57 verificationCode = params.token 58 + tokenFromUrl = true 53 59 } 54 60 } else if (params.token) { 55 61 mode = 'token' ··· 231 237 {:else if success} 232 238 <div class="success-container"> 233 239 <h1>{$_('verify.verified')}</h1> 234 - {#if successPurpose === 'email-update'} 240 + {#if successPurpose === 'email-authorize'} 241 + <p class="subtitle">{$_('verify.emailAuthorizeSuccess')}</p> 242 + <p class="info-text">{$_('verify.emailAuthorizeInfo')}</p> 243 + {:else if successPurpose === 'email-update'} 235 244 <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 245 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 246 <div class="actions"> ··· 283 292 /> 284 293 </div> 285 294 286 - <div class="field"> 287 - <label for="verification-code">{$_('verify.codeLabel')}</label> 288 - <input 289 - id="verification-code" 290 - type="text" 291 - bind:value={verificationCode} 292 - placeholder={$_('verify.codePlaceholder')} 293 - disabled={submitting} 294 - required 295 - autocomplete="off" 296 - class="token-input" 297 - /> 298 - <p class="field-help">{$_('verify.emailUpdateCodeHelp')}</p> 299 - </div> 295 + {#if !tokenFromUrl} 296 + <div class="field"> 297 + <label for="verification-code">{$_('verify.codeLabel')}</label> 298 + <input 299 + id="verification-code" 300 + type="text" 301 + bind:value={verificationCode} 302 + placeholder={$_('verify.codePlaceholder')} 303 + disabled={submitting} 304 + required 305 + autocomplete="off" 306 + class="token-input" 307 + /> 308 + <p class="field-help">{$_('verify.emailUpdateCodeHelp')}</p> 309 + </div> 310 + {/if} 300 311 301 312 <button type="submit" disabled={submitting || !verificationCode.trim() || !newEmail.trim()}> 302 313 {submitting ? $_('verify.updating') : $_('verify.updateEmail')}
+5 -2
frontend/src/tests/AppPasswords.test.ts
··· 61 61 ), 62 62 ); 63 63 const { container } = render(AppPasswords); 64 - expect(container.querySelectorAll(".skeleton-item").length).toBeGreaterThan(0); 64 + expect(container.querySelectorAll(".skeleton-item").length) 65 + .toBeGreaterThan(0); 65 66 }); 66 67 }); 67 68 describe("empty state", () => { ··· 392 393 render(AppPasswords); 393 394 await waitFor(() => { 394 395 const errors = getErrorToasts(); 395 - expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true); 396 + expect(errors.some((e) => /database connection failed/i.test(e))).toBe( 397 + true, 398 + ); 396 399 }); 397 400 }); 398 401 });
+11 -5
frontend/src/tests/Comms.test.ts
··· 12 12 setupAuthenticatedUser, 13 13 setupDefaultMocks, 14 14 setupUnauthenticatedUser, 15 - } from "./mocks"; 15 + } from "./mocks.ts"; 16 16 describe("Comms", () => { 17 17 beforeEach(() => { 18 18 clearMocks(); ··· 85 85 ), 86 86 ); 87 87 const { container } = render(Comms); 88 - expect(container.querySelectorAll(".skeleton-section").length).toBeGreaterThan(0); 88 + expect(container.querySelectorAll(".skeleton-section").length) 89 + .toBeGreaterThan(0); 89 90 }); 90 91 }); 91 92 describe("channel options", () => { ··· 375 376 ); 376 377 await waitFor(() => { 377 378 const toasts = getToasts(); 378 - expect(toasts.some((t) => t.type === "success" && /saved/i.test(t.message))).toBe(true); 379 + expect( 380 + toasts.some((t) => t.type === "success" && /saved/i.test(t.message)), 381 + ).toBe(true); 379 382 }); 380 383 }); 381 384 it("shows error toast when save fails", async () => { ··· 398 401 ); 399 402 await waitFor(() => { 400 403 const errors = getErrorToasts(); 401 - expect(errors.some((e) => /invalid channel configuration/i.test(e))).toBe(true); 404 + expect(errors.some((e) => /invalid channel configuration/i.test(e))) 405 + .toBe(true); 402 406 }); 403 407 }); 404 408 it("reloads preferences after successful save", async () => { ··· 495 499 render(Comms); 496 500 await waitFor(() => { 497 501 const errors = getErrorToasts(); 498 - expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true); 502 + expect(errors.some((e) => /database connection failed/i.test(e))).toBe( 503 + true, 504 + ); 499 505 }); 500 506 }); 501 507 });
+3 -2
frontend/src/tests/Dashboard.test.ts
··· 9 9 setupAuthenticatedUser, 10 10 setupFetchMock, 11 11 setupUnauthenticatedUser, 12 - } from "./mocks"; 12 + } from "./mocks.ts"; 13 13 const STORAGE_KEY = "tranquil_pds_session"; 14 14 describe("Dashboard", () => { 15 15 beforeEach(() => { ··· 27 27 it("shows loading state while checking auth", () => { 28 28 const { container } = render(Dashboard); 29 29 expect(container.querySelector(".skeleton-section")).toBeInTheDocument(); 30 - expect(container.querySelectorAll(".skeleton-card").length).toBeGreaterThan(0); 30 + expect(container.querySelectorAll(".skeleton-card").length) 31 + .toBeGreaterThan(0); 31 32 }); 32 33 }); 33 34 describe("authenticated view", () => {
+63 -26
frontend/src/tests/Settings.test.ts
··· 12 12 setupAuthenticatedUser, 13 13 setupDefaultMocks, 14 14 setupUnauthenticatedUser, 15 - } from "./mocks"; 15 + } from "./mocks.ts"; 16 16 describe("Settings", () => { 17 17 beforeEach(() => { 18 18 clearMocks(); ··· 68 68 requestCalled = true; 69 69 return jsonResponse({ tokenRequired: true }); 70 70 }); 71 + mockEndpoint( 72 + "_account.checkEmailUpdateStatus", 73 + () => jsonResponse({ pending: false, authorized: false }), 74 + ); 71 75 render(Settings); 72 76 await waitFor(() => { 73 - expect(screen.getByRole("button", { name: /change email/i })) 74 - .toBeInTheDocument(); 77 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 78 + }); 79 + await fireEvent.input(screen.getByLabelText(/new email/i), { 80 + target: { value: "newemail@example.com" }, 75 81 }); 76 82 await fireEvent.click( 77 83 screen.getByRole("button", { name: /change email/i }), ··· 85 91 "com.atproto.server.requestEmailUpdate", 86 92 () => jsonResponse({ tokenRequired: true }), 87 93 ); 94 + mockEndpoint( 95 + "_account.checkEmailUpdateStatus", 96 + () => jsonResponse({ pending: false, authorized: false }), 97 + ); 88 98 render(Settings); 89 99 await waitFor(() => { 90 - expect(screen.getByRole("button", { name: /change email/i })) 91 - .toBeInTheDocument(); 100 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 101 + }); 102 + await fireEvent.input(screen.getByLabelText(/new email/i), { 103 + target: { value: "newemail@example.com" }, 92 104 }); 93 105 await fireEvent.click( 94 106 screen.getByRole("button", { name: /change email/i }), ··· 107 119 "com.atproto.server.requestEmailUpdate", 108 120 () => jsonResponse({ tokenRequired: true }), 109 121 ); 122 + mockEndpoint( 123 + "_account.checkEmailUpdateStatus", 124 + () => jsonResponse({ pending: false, authorized: false }), 125 + ); 110 126 mockEndpoint("com.atproto.server.updateEmail", (_url, options) => { 111 127 updateCalled = true; 112 128 capturedBody = JSON.parse((options?.body as string) || "{}"); ··· 118 134 ); 119 135 render(Settings); 120 136 await waitFor(() => { 121 - expect(screen.getByRole("button", { name: /change email/i })) 122 - .toBeInTheDocument(); 137 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 138 + }); 139 + await fireEvent.input(screen.getByLabelText(/new email/i), { 140 + target: { value: "newemail@example.com" }, 123 141 }); 124 142 await fireEvent.click( 125 143 screen.getByRole("button", { name: /change email/i }), ··· 130 148 await fireEvent.input(screen.getByLabelText(/verification code/i), { 131 149 target: { value: "123456" }, 132 150 }); 133 - await fireEvent.input(screen.getByLabelText(/new email/i), { 134 - target: { value: "newemail@example.com" }, 135 - }); 136 151 await fireEvent.click( 137 152 screen.getByRole("button", { name: /confirm email change/i }), 138 153 ); ··· 147 162 "com.atproto.server.requestEmailUpdate", 148 163 () => jsonResponse({ tokenRequired: true }), 149 164 ); 165 + mockEndpoint( 166 + "_account.checkEmailUpdateStatus", 167 + () => jsonResponse({ pending: false, authorized: false }), 168 + ); 150 169 mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({})); 151 170 mockEndpoint( 152 171 "com.atproto.server.getSession", ··· 154 173 ); 155 174 render(Settings); 156 175 await waitFor(() => { 157 - expect(screen.getByRole("button", { name: /change email/i })) 158 - .toBeInTheDocument(); 176 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 177 + }); 178 + await fireEvent.input(screen.getByLabelText(/new email/i), { 179 + target: { value: "new@test.com" }, 159 180 }); 160 181 await fireEvent.click( 161 182 screen.getByRole("button", { name: /change email/i }), ··· 166 187 await fireEvent.input(screen.getByLabelText(/verification code/i), { 167 188 target: { value: "123456" }, 168 189 }); 169 - await fireEvent.input(screen.getByLabelText(/new email/i), { 170 - target: { value: "new@test.com" }, 171 - }); 172 190 await fireEvent.click( 173 191 screen.getByRole("button", { name: /confirm email change/i }), 174 192 ); 175 193 await waitFor(() => { 176 194 const toasts = getToasts(); 177 - expect(toasts.some((t) => t.type === "success" && /email.*updated/i.test(t.message))).toBe(true); 195 + expect( 196 + toasts.some((t) => 197 + t.type === "success" && /email.*updated/i.test(t.message) 198 + ), 199 + ).toBe(true); 178 200 }); 179 201 }); 180 202 it("shows cancel button to return to initial state", async () => { ··· 182 204 "com.atproto.server.requestEmailUpdate", 183 205 () => jsonResponse({ tokenRequired: true }), 184 206 ); 207 + mockEndpoint( 208 + "_account.checkEmailUpdateStatus", 209 + () => jsonResponse({ pending: false, authorized: false }), 210 + ); 185 211 render(Settings); 186 212 await waitFor(() => { 187 - expect(screen.getByRole("button", { name: /change email/i })) 188 - .toBeInTheDocument(); 213 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 214 + }); 215 + await fireEvent.input(screen.getByLabelText(/new email/i), { 216 + target: { value: "newemail@example.com" }, 189 217 }); 190 218 await fireEvent.click( 191 219 screen.getByRole("button", { name: /change email/i }), ··· 214 242 ); 215 243 render(Settings); 216 244 await waitFor(() => { 217 - expect(screen.getByRole("button", { name: /change email/i })) 218 - .toBeInTheDocument(); 245 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 219 246 }); 220 - await fireEvent.click( 221 - screen.getByRole("button", { name: /change email/i }), 222 - ); 247 + await fireEvent.input(screen.getByLabelText(/new email/i), { 248 + target: { value: "invalid@email.com" }, 249 + }); 250 + const button = screen.getByRole("button", { name: /change email/i }); 251 + await fireEvent.submit(button.closest("form")!); 223 252 await waitFor(() => { 224 253 const errors = getErrorToasts(); 225 254 expect(errors.some((e) => /invalid email format/i.test(e))).toBe(true); ··· 283 312 await fireEvent.submit(button.closest("form")!); 284 313 await waitFor(() => { 285 314 const toasts = getToasts(); 286 - expect(toasts.some((t) => t.type === "success" && /handle.*updated/i.test(t.message))).toBe(true); 315 + expect( 316 + toasts.some((t) => 317 + t.type === "success" && /handle.*updated/i.test(t.message) 318 + ), 319 + ).toBe(true); 287 320 }); 288 321 }); 289 322 it("shows error toast when handle change fails", async () => { ··· 306 339 await fireEvent.submit(button.closest("form")!); 307 340 await waitFor(() => { 308 341 const errors = getErrorToasts(); 309 - expect(errors.some((e) => /handle is already taken/i.test(e))).toBe(true); 342 + expect(errors.some((e) => /handle is already taken/i.test(e))).toBe( 343 + true, 344 + ); 310 345 }); 311 346 }); 312 347 }); ··· 535 570 ); 536 571 await waitFor(() => { 537 572 const errors = getErrorToasts(); 538 - expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe(true); 573 + expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe( 574 + true, 575 + ); 539 576 }); 540 577 }); 541 578 });
+3 -3
frontend/src/tests/mocks.ts
··· 1 1 import { vi } from "vitest"; 2 2 import type { AppPassword, InviteCode, Session } from "../lib/api.ts"; 3 - import { _testSetState, _testResetState } from "../lib/auth.svelte.ts"; 4 - import { toast, clearAllToasts, getToasts } from "../lib/toast.svelte.ts"; 3 + import { _testResetState, _testSetState } from "../lib/auth.svelte.ts"; 4 + import { clearAllToasts, getToasts, toast } from "../lib/toast.svelte.ts"; 5 5 import { 6 6 unsafeAsAccessToken, 7 7 unsafeAsDid, ··· 81 81 .map((t) => t.message); 82 82 } 83 83 84 - export { toast, getToasts }; 84 + export { getToasts, toast }; 85 85 function extractEndpoint(url: string): string { 86 86 const match = url.match(/\/xrpc\/([^?]+)/); 87 87 return match ? match[1] : url;
+15 -8
frontend/src/tests/oauth.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 2 import { 3 + checkForOAuthCallback, 4 + clearOAuthCallbackParams, 3 5 generateCodeChallenge, 4 6 generateCodeVerifier, 5 7 generateState, 6 8 saveOAuthState, 7 - checkForOAuthCallback, 8 - clearOAuthCallbackParams, 9 - } from "../lib/oauth"; 9 + } from "../lib/oauth.ts"; 10 10 11 11 describe("OAuth utilities", () => { 12 12 beforeEach(() => { ··· 21 21 }); 22 22 23 23 it("generates unique values", () => { 24 - const states = new Set(Array.from({ length: 100 }, () => generateState())); 24 + const states = new Set( 25 + Array.from({ length: 100 }, () => generateState()), 26 + ); 25 27 expect(states.size).toBe(100); 26 28 }); 27 29 }); ··· 67 69 }); 68 70 69 71 it("produces correct S256 challenge", async () => { 70 - const challenge = await generateCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); 72 + const challenge = await generateCodeChallenge( 73 + "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", 74 + ); 71 75 expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); 72 76 }); 73 77 }); ··· 191 195 describe("DPoP proof generation", () => { 192 196 it("base64url encoding produces valid output", async () => { 193 197 const testData = new Uint8Array([72, 101, 108, 108, 111]); 194 - const buffer = testData.buffer; 198 + const _buffer = testData.buffer; 195 199 196 - const binary = Array.from(testData, (byte) => String.fromCharCode(byte)).join(""); 200 + const binary = Array.from(testData, (byte) => String.fromCharCode(byte)) 201 + .join(""); 197 202 const base64url = btoa(binary) 198 203 .replace(/\+/g, "-") 199 204 .replace(/\//g, "_") ··· 220 225 y: jwk.y, 221 226 }); 222 227 223 - expect(canonical).toBe('{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}'); 228 + expect(canonical).toBe( 229 + '{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}', 230 + ); 224 231 225 232 const keys = Object.keys(JSON.parse(canonical)); 226 233 expect(keys).toEqual(["crv", "kty", "x", "y"]);
+44
migrations/20260115_sso_external_identities.sql
··· 1 + CREATE TYPE sso_provider_type AS ENUM ('github', 'discord', 'google', 'gitlab', 'oidc'); 2 + 3 + CREATE TABLE external_identities ( 4 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 5 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 6 + provider sso_provider_type NOT NULL, 7 + provider_user_id TEXT NOT NULL, 8 + provider_username TEXT, 9 + provider_email TEXT, 10 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 11 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 12 + last_login_at TIMESTAMPTZ, 13 + UNIQUE(provider, provider_user_id), 14 + UNIQUE(did, provider) 15 + ); 16 + 17 + CREATE INDEX idx_external_identities_did ON external_identities(did); 18 + CREATE INDEX idx_external_identities_provider_user ON external_identities(provider, provider_user_id); 19 + 20 + CREATE TABLE sso_auth_state ( 21 + state TEXT PRIMARY KEY, 22 + request_uri TEXT NOT NULL, 23 + provider sso_provider_type NOT NULL, 24 + action TEXT NOT NULL, 25 + nonce TEXT, 26 + code_verifier TEXT, 27 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 28 + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes' 29 + ); 30 + 31 + CREATE INDEX idx_sso_auth_state_expires ON sso_auth_state(expires_at); 32 + 33 + CREATE TABLE sso_pending_registration ( 34 + token TEXT PRIMARY KEY, 35 + request_uri TEXT NOT NULL, 36 + provider sso_provider_type NOT NULL, 37 + provider_user_id TEXT NOT NULL, 38 + provider_username TEXT, 39 + provider_email TEXT, 40 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 41 + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes' 42 + ); 43 + 44 + CREATE INDEX idx_sso_pending_registration_expires ON sso_pending_registration(expires_at);
+1
migrations/20260116_sso_auth_state_did.sql
··· 1 + ALTER TABLE sso_auth_state ADD COLUMN did TEXT;
+8
migrations/20260117_handle_reservations.sql
··· 1 + CREATE TABLE handle_reservations ( 2 + handle TEXT PRIMARY KEY, 3 + reserved_by TEXT NOT NULL, 4 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 5 + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '5 minutes' 6 + ); 7 + 8 + CREATE INDEX idx_handle_reservations_expires ON handle_reservations(expires_at);
+3
migrations/20260118_external_identity_email_verified.sql
··· 1 + ALTER TABLE external_identities ADD COLUMN provider_email_verified BOOLEAN NOT NULL DEFAULT FALSE; 2 + 3 + ALTER TABLE sso_pending_registration ADD COLUMN provider_email_verified BOOLEAN NOT NULL DEFAULT FALSE;
+1
migrations/20260119_add_apple_sso_provider.sql
··· 1 + ALTER TYPE sso_provider_type ADD VALUE IF NOT EXISTS 'apple';