Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
222
fork

Configure Feed

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

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';