this repo has no description

Security improvements

lewis 18b49421 36f6be7f

Changed files
+1551 -379
.sqlx
frontend
migrations
src
tests
+23
.sqlx/query-14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Bool", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09" 23 + }
+2 -1
.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
··· 39 "plc_operation", 40 "two_factor_code", 41 "channel_verification", 42 - "passkey_recovery" 43 ] 44 } 45 }
··· 39 "plc_operation", 40 "two_factor_code", 41 "channel_verification", 42 + "passkey_recovery", 43 + "legacy_login_alert" 44 ] 45 } 46 }
+2 -1
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
··· 47 "plc_operation", 48 "two_factor_code", 49 "channel_verification", 50 - "passkey_recovery" 51 ] 52 } 53 }
··· 47 "plc_operation", 48 "two_factor_code", 49 "channel_verification", 50 + "passkey_recovery", 51 + "legacy_login_alert" 52 ] 53 } 54 }
+20
.sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Timestamptz", 12 + "Timestamptz", 13 + "Bool", 14 + "Bool" 15 + ] 16 + }, 17 + "nullable": [] 18 + }, 19 + "hash": "301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e" 20 + }
+2 -1
.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
··· 39 "plc_operation", 40 "two_factor_code", 41 "channel_verification", 42 - "passkey_recovery" 43 ] 44 } 45 }
··· 39 "plc_operation", 40 "two_factor_code", 41 "channel_verification", 42 + "passkey_recovery", 43 + "legacy_login_alert" 44 ] 45 } 46 }
+28
.sqlx/query-5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT\n u.allow_legacy_login,\n (EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR\n EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as \"has_mfa!\"\n FROM users u WHERE u.did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "allow_legacy_login", 9 + "type_info": "Bool" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "has_mfa!", 14 + "type_info": "Bool" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + null 25 + ] 26 + }, 27 + "hash": "5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414" 28 + }
+14
.sqlx/query-6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8" 14 + }
-22
.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "one", 9 - "type_info": "Int4" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034" 22 - }
···
-15
.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Timestamptz", 9 - "Text" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979" 15 - }
···
+34
.sqlx/query-8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "legacy_login", 9 + "type_info": "Bool" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "mfa_verified", 14 + "type_info": "Bool" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "last_reauth_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": "8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd" 34 + }
-76
.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", 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": "handle", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "password_hash", 24 - "type_info": "Text" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "email_verified", 29 - "type_info": "Bool" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "discord_verified", 34 - "type_info": "Bool" 35 - }, 36 - { 37 - "ordinal": 6, 38 - "name": "telegram_verified", 39 - "type_info": "Bool" 40 - }, 41 - { 42 - "ordinal": 7, 43 - "name": "signal_verified", 44 - "type_info": "Bool" 45 - }, 46 - { 47 - "ordinal": 8, 48 - "name": "key_bytes", 49 - "type_info": "Bytea" 50 - }, 51 - { 52 - "ordinal": 9, 53 - "name": "encryption_version", 54 - "type_info": "Int4" 55 - } 56 - ], 57 - "parameters": { 58 - "Left": [ 59 - "Text" 60 - ] 61 - }, 62 - "nullable": [ 63 - false, 64 - false, 65 - false, 66 - true, 67 - false, 68 - false, 69 - false, 70 - false, 71 - false, 72 - true 73 - ] 74 - }, 75 - "hash": "c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590" 76 - }
···
+15
.sqlx/query-e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Timestamptz", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f" 15 + }
+2 -1
.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
··· 42 "plc_operation", 43 "two_factor_code", 44 "channel_verification", 45 - "passkey_recovery" 46 ] 47 } 48 }
··· 42 "plc_operation", 43 "two_factor_code", 44 "channel_verification", 45 + "passkey_recovery", 46 + "legacy_login_alert" 47 ] 48 } 49 }
+106
.sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login,\n u.preferred_comms_channel as \"preferred_comms_channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", 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": "handle", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "password_hash", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "email_verified", 29 + "type_info": "Bool" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "discord_verified", 34 + "type_info": "Bool" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "telegram_verified", 39 + "type_info": "Bool" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "signal_verified", 44 + "type_info": "Bool" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "allow_legacy_login", 49 + "type_info": "Bool" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "preferred_comms_channel: crate::comms::CommsChannel", 54 + "type_info": { 55 + "Custom": { 56 + "name": "comms_channel", 57 + "kind": { 58 + "Enum": [ 59 + "email", 60 + "discord", 61 + "telegram", 62 + "signal" 63 + ] 64 + } 65 + } 66 + } 67 + }, 68 + { 69 + "ordinal": 10, 70 + "name": "key_bytes", 71 + "type_info": "Bytea" 72 + }, 73 + { 74 + "ordinal": 11, 75 + "name": "encryption_version", 76 + "type_info": "Int4" 77 + }, 78 + { 79 + "ordinal": 12, 80 + "name": "totp_enabled", 81 + "type_info": "Bool" 82 + } 83 + ], 84 + "parameters": { 85 + "Left": [ 86 + "Text" 87 + ] 88 + }, 89 + "nullable": [ 90 + false, 91 + false, 92 + false, 93 + true, 94 + false, 95 + false, 96 + false, 97 + false, 98 + false, 99 + false, 100 + false, 101 + true, 102 + null 103 + ] 104 + }, 105 + "hash": "fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38" 106 + }
+2 -15
TODO.md
··· 9 - [ ] Unique "brand" style both unauthed and authed 10 - [ ] Better documentation on how to sub out the entire frontend for whatever the users want 11 12 - ### Passkeys and 2FA 13 - Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth. 14 - 15 - - [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 16 - - [x] user_totp table (did, secret_encrypted, verified, created_at, last_used) 17 - - [x] WebAuthn registration challenge generation and attestation verification 18 - - [x] TOTP secret generation with QR code setup flow 19 - - [x] Backup codes (hashed, one-time use) with recovery flow 20 - - [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative) 21 - - [ ] Passkey-only account creation (no password) 22 - - [x] Settings UI for managing passkeys, TOTP, backup codes 23 - - [ ] Trusted devices option (remember this browser) 24 - - [x] Rate limit 2FA attempts 25 - - [ ] Re-auth for sensitive actions (email change, adding new auth methods) 26 - 27 ### Delegated accounts 28 Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model. 29 ··· 103 Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel, OAuth consent screen with scope selection. 104 105 Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons.
··· 9 - [ ] Unique "brand" style both unauthed and authed 10 - [ ] Better documentation on how to sub out the entire frontend for whatever the users want 11 12 ### Delegated accounts 13 Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model. 14 ··· 88 Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel, OAuth consent screen with scope selection. 89 90 Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons. 91 + 92 + Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods.
+3
frontend/src/components/ReauthModal.svelte
··· 29 activeMethod = 'totp' 30 } else if (availableMethods.includes('passkey')) { 31 activeMethod = 'passkey' 32 } 33 } 34 })
··· 29 activeMethod = 'totp' 30 } else if (availableMethods.includes('passkey')) { 31 activeMethod = 'passkey' 32 + if (availableMethods.length === 1) { 33 + handlePasskeyAuth() 34 + } 35 } 36 } 37 })
+22 -1
frontend/src/lib/api.ts
··· 37 }) 38 if (!res.ok) { 39 const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) 40 - throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods) 41 } 42 return res.json() 43 } ··· 331 return xrpc('com.tranquil.account.getPasswordStatus', { token }) 332 }, 333 334 async listSessions(token: string): Promise<{ 335 sessions: Array<{ 336 id: string 337 createdAt: string 338 expiresAt: string 339 isCurrent: boolean ··· 347 method: 'POST', 348 token, 349 body: { sessionId }, 350 }) 351 }, 352
··· 37 }) 38 if (!res.ok) { 39 const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) 40 + throw new ApiError(res.status, err.error, err.message, err.did, err.reauthMethods) 41 } 42 return res.json() 43 } ··· 331 return xrpc('com.tranquil.account.getPasswordStatus', { token }) 332 }, 333 334 + async getLegacyLoginPreference(token: string): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 335 + return xrpc('com.tranquil.account.getLegacyLoginPreference', { token }) 336 + }, 337 + 338 + async updateLegacyLoginPreference(token: string, allowLegacyLogin: boolean): Promise<{ allowLegacyLogin: boolean }> { 339 + return xrpc('com.tranquil.account.updateLegacyLoginPreference', { 340 + method: 'POST', 341 + token, 342 + body: { allowLegacyLogin }, 343 + }) 344 + }, 345 + 346 async listSessions(token: string): Promise<{ 347 sessions: Array<{ 348 id: string 349 + sessionType: string 350 + clientName: string | null 351 createdAt: string 352 expiresAt: string 353 isCurrent: boolean ··· 361 method: 'POST', 362 token, 363 body: { sessionId }, 364 + }) 365 + }, 366 + 367 + async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 368 + return xrpc('com.tranquil.account.revokeAllSessions', { 369 + method: 'POST', 370 + token, 371 }) 372 }, 373
-6
frontend/src/routes/Register.svelte
··· 28 const auth = getAuthState() 29 30 $effect(() => { 31 - if (auth.session) { 32 - navigate('/dashboard') 33 - } 34 - }) 35 - 36 - $effect(() => { 37 if (!serverInfoLoaded) { 38 serverInfoLoaded = true 39 loadServerInfo()
··· 28 const auth = getAuthState() 29 30 $effect(() => { 31 if (!serverInfoLoaded) { 32 serverInfoLoaded = true 33 loadServerInfo()
-6
frontend/src/routes/RegisterPasskey.svelte
··· 31 let resendMessage = $state<string | null>(null) 32 33 $effect(() => { 34 - if (auth.session) { 35 - navigate('/dashboard') 36 - } 37 - }) 38 - 39 - $effect(() => { 40 if (!serverInfoLoaded) { 41 serverInfoLoaded = true 42 loadServerInfo()
··· 31 let resendMessage = $state<string | null>(null) 32 33 $effect(() => { 34 if (!serverInfoLoaded) { 35 serverInfoLoaded = true 36 loadServerInfo()
+219 -3
frontend/src/routes/Security.svelte
··· 44 let showRemovePasswordForm = $state(false) 45 let removePasswordLoading = $state(false) 46 47 let showReauthModal = $state(false) 48 let reauthMethods = $state<string[]>(['password']) 49 let pendingAction = $state<(() => Promise<void>) | null>(null) ··· 59 loadTotpStatus() 60 loadPasskeys() 61 loadPasswordStatus() 62 } 63 }) 64 ··· 72 hasPassword = true 73 } finally { 74 passwordLoading = false 75 } 76 } 77 ··· 572 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}> 573 Rename 574 </button> 575 - <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 576 - Delete 577 - </button> 578 </div> 579 {/if} 580 </div> ··· 670 Manage Trusted Devices &rarr; 671 </a> 672 </section> 673 {/if} 674 </div> 675 ··· 1075 1076 .info-box-inline li { 1077 margin-bottom: 0.25rem; 1078 } 1079 </style>
··· 44 let showRemovePasswordForm = $state(false) 45 let removePasswordLoading = $state(false) 46 47 + let allowLegacyLogin = $state(true) 48 + let hasMfa = $state(false) 49 + let legacyLoginLoading = $state(true) 50 + let legacyLoginUpdating = $state(false) 51 + 52 let showReauthModal = $state(false) 53 let reauthMethods = $state<string[]>(['password']) 54 let pendingAction = $state<(() => Promise<void>) | null>(null) ··· 64 loadTotpStatus() 65 loadPasskeys() 66 loadPasswordStatus() 67 + loadLegacyLoginPreference() 68 } 69 }) 70 ··· 78 hasPassword = true 79 } finally { 80 passwordLoading = false 81 + } 82 + } 83 + 84 + async function loadLegacyLoginPreference() { 85 + if (!auth.session) return 86 + legacyLoginLoading = true 87 + try { 88 + const pref = await api.getLegacyLoginPreference(auth.session.accessJwt) 89 + allowLegacyLogin = pref.allowLegacyLogin 90 + hasMfa = pref.hasMfa 91 + } catch { 92 + allowLegacyLogin = true 93 + hasMfa = false 94 + } finally { 95 + legacyLoginLoading = false 96 + } 97 + } 98 + 99 + async function handleToggleLegacyLogin() { 100 + if (!auth.session) return 101 + legacyLoginUpdating = true 102 + try { 103 + const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin) 104 + allowLegacyLogin = result.allowLegacyLogin 105 + showMessage('success', allowLegacyLogin 106 + ? 'Legacy app login enabled' 107 + : 'Legacy app login disabled - only OAuth apps can sign in') 108 + } catch (e) { 109 + if (e instanceof ApiError) { 110 + if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') { 111 + reauthMethods = e.reauthMethods || ['password'] 112 + pendingAction = handleToggleLegacyLogin 113 + showReauthModal = true 114 + } else { 115 + showMessage('error', e.message) 116 + } 117 + } else { 118 + showMessage('error', 'Failed to update preference') 119 + } 120 + } finally { 121 + legacyLoginUpdating = false 122 } 123 } 124 ··· 619 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}> 620 Rename 621 </button> 622 + {#if hasPassword || passkeys.length > 1} 623 + <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 624 + Delete 625 + </button> 626 + {/if} 627 </div> 628 {/if} 629 </div> ··· 719 Manage Trusted Devices &rarr; 720 </a> 721 </section> 722 + 723 + {#if hasMfa} 724 + <section> 725 + <h2>App Compatibility</h2> 726 + <p class="description"> 727 + Control whether apps that don't support modern authentication (like the official Bluesky app) can sign in to your account. 728 + </p> 729 + 730 + {#if legacyLoginLoading} 731 + <div class="loading">Loading...</div> 732 + {:else} 733 + <div class="toggle-row"> 734 + <div class="toggle-info"> 735 + <span class="toggle-label">Allow legacy app login</span> 736 + <span class="toggle-description"> 737 + {#if allowLegacyLogin} 738 + Legacy apps can sign in with just your password, but sensitive actions (like changing your password) will require MFA verification. 739 + {:else} 740 + Only OAuth-compatible apps can sign in. Legacy apps will be blocked. 741 + {/if} 742 + </span> 743 + </div> 744 + <button 745 + type="button" 746 + class="toggle-button {allowLegacyLogin ? 'on' : 'off'}" 747 + onclick={handleToggleLegacyLogin} 748 + disabled={legacyLoginUpdating} 749 + > 750 + <span class="toggle-slider"></span> 751 + </button> 752 + </div> 753 + 754 + {#if totpEnabled} 755 + <div class="warning-box"> 756 + <strong>Important: Password changes in Bluesky app will fail</strong> 757 + <p> 758 + With TOTP enabled, changing your password from the Bluesky app (or other legacy apps) will be blocked. 759 + To change your password, you have two options: 760 + </p> 761 + <ol> 762 + <li><strong>Change it here:</strong> Use this website's <a href="#/settings">Settings page</a> where you can verify with your authenticator app.</li> 763 + <li><strong>Verify your session first:</strong> Use the <a href="#/settings">re-authenticate option</a> to verify your Bluesky session with TOTP, then password changes will work temporarily.</li> 764 + </ol> 765 + </div> 766 + {/if} 767 + 768 + <div class="info-box-inline"> 769 + <strong>What are legacy apps?</strong> 770 + <p> 771 + Some apps (like the official Bluesky app) use older authentication that only requires your password. 772 + When you have MFA enabled, these apps bypass your second factor. 773 + Disabling legacy login forces all apps to use OAuth, which properly enforces MFA. 774 + </p> 775 + </div> 776 + {/if} 777 + </section> 778 + {/if} 779 {/if} 780 </div> 781 ··· 1181 1182 .info-box-inline li { 1183 margin-bottom: 0.25rem; 1184 + } 1185 + 1186 + .info-box-inline p { 1187 + margin: 0; 1188 + color: var(--text-secondary); 1189 + } 1190 + 1191 + .toggle-row { 1192 + display: flex; 1193 + justify-content: space-between; 1194 + align-items: flex-start; 1195 + gap: 1rem; 1196 + padding: 1rem; 1197 + background: var(--bg-card); 1198 + border: 1px solid var(--border-color-light); 1199 + border-radius: 6px; 1200 + margin-bottom: 1rem; 1201 + } 1202 + 1203 + .toggle-info { 1204 + display: flex; 1205 + flex-direction: column; 1206 + gap: 0.25rem; 1207 + } 1208 + 1209 + .toggle-label { 1210 + font-weight: 500; 1211 + } 1212 + 1213 + .toggle-description { 1214 + font-size: 0.875rem; 1215 + color: var(--text-secondary); 1216 + } 1217 + 1218 + .toggle-button { 1219 + position: relative; 1220 + width: 50px; 1221 + height: 26px; 1222 + padding: 0; 1223 + border: none; 1224 + border-radius: 13px; 1225 + cursor: pointer; 1226 + transition: background 0.2s; 1227 + flex-shrink: 0; 1228 + } 1229 + 1230 + .toggle-button.on { 1231 + background: var(--success-text); 1232 + } 1233 + 1234 + .toggle-button.off { 1235 + background: var(--text-secondary); 1236 + } 1237 + 1238 + .toggle-button:disabled { 1239 + opacity: 0.6; 1240 + cursor: not-allowed; 1241 + } 1242 + 1243 + .toggle-slider { 1244 + position: absolute; 1245 + top: 3px; 1246 + width: 20px; 1247 + height: 20px; 1248 + background: white; 1249 + border-radius: 50%; 1250 + transition: left 0.2s; 1251 + } 1252 + 1253 + .toggle-button.on .toggle-slider { 1254 + left: 27px; 1255 + } 1256 + 1257 + .toggle-button.off .toggle-slider { 1258 + left: 3px; 1259 + } 1260 + 1261 + .warning-box { 1262 + background: var(--warning-bg); 1263 + border: 1px solid var(--warning-border, var(--border-color)); 1264 + border-left: 4px solid var(--warning-text); 1265 + border-radius: 6px; 1266 + padding: 1rem; 1267 + margin-bottom: 1rem; 1268 + } 1269 + 1270 + .warning-box strong { 1271 + display: block; 1272 + margin-bottom: 0.5rem; 1273 + color: var(--warning-text); 1274 + } 1275 + 1276 + .warning-box p { 1277 + margin: 0 0 0.75rem 0; 1278 + font-size: 0.875rem; 1279 + color: var(--text-primary); 1280 + } 1281 + 1282 + .warning-box ol { 1283 + margin: 0; 1284 + padding-left: 1.25rem; 1285 + font-size: 0.875rem; 1286 + } 1287 + 1288 + .warning-box li { 1289 + margin-bottom: 0.5rem; 1290 + } 1291 + 1292 + .warning-box a { 1293 + color: var(--accent); 1294 } 1295 </style>
+63 -7
frontend/src/routes/Sessions.svelte
··· 7 let error = $state<string | null>(null) 8 let sessions = $state<Array<{ 9 id: string 10 createdAt: string 11 expiresAt: string 12 isCurrent: boolean ··· 51 error = e instanceof ApiError ? e.message : 'Failed to revoke session' 52 } 53 } 54 function formatDate(dateStr: string): string { 55 return new Date(dateStr).toLocaleString() 56 } ··· 87 <div class="session-info"> 88 <div class="session-header"> 89 {#if session.isCurrent} 90 - <span class="badge current">Current Session</span> 91 - {:else} 92 - <span class="session-label">Session</span> 93 {/if} 94 </div> 95 <div class="session-details"> ··· 115 </div> 116 {/each} 117 </div> 118 - <button class="refresh-btn" onclick={loadSessions}>Refresh</button> 119 {/if} 120 {/if} 121 </div> ··· 174 } 175 .session-header { 176 margin-bottom: 0.5rem; 177 } 178 - .session-label { 179 font-weight: 500; 180 - color: var(--text-secondary); 181 } 182 .badge { 183 display: inline-block; ··· 189 .badge.current { 190 background: var(--accent); 191 color: white; 192 } 193 .session-details { 194 display: flex; ··· 224 .revoke-btn.danger:hover { 225 background: var(--error-bg); 226 } 227 - .refresh-btn { 228 margin-top: 1rem; 229 padding: 0.5rem 1rem; 230 background: transparent; 231 border: 1px solid var(--border-color); ··· 236 .refresh-btn:hover { 237 background: var(--bg-card); 238 border-color: var(--accent); 239 } 240 </style>
··· 7 let error = $state<string | null>(null) 8 let sessions = $state<Array<{ 9 id: string 10 + sessionType: string 11 + clientName: string | null 12 createdAt: string 13 expiresAt: string 14 isCurrent: boolean ··· 53 error = e instanceof ApiError ? e.message : 'Failed to revoke session' 54 } 55 } 56 + async function revokeAllSessions() { 57 + if (!auth.session) return 58 + const otherCount = sessions.filter(s => !s.isCurrent).length 59 + if (otherCount === 0) { 60 + error = 'No other sessions to revoke' 61 + return 62 + } 63 + if (!confirm(`This will revoke ${otherCount} other session${otherCount > 1 ? 's' : ''}. Continue?`)) return 64 + try { 65 + await api.revokeAllSessions(auth.session.accessJwt) 66 + sessions = sessions.filter(s => s.isCurrent) 67 + } catch (e) { 68 + error = e instanceof ApiError ? e.message : 'Failed to revoke sessions' 69 + } 70 + } 71 function formatDate(dateStr: string): string { 72 return new Date(dateStr).toLocaleString() 73 } ··· 104 <div class="session-info"> 105 <div class="session-header"> 106 {#if session.isCurrent} 107 + <span class="badge current">Current</span> 108 + {/if} 109 + <span class="badge type" class:oauth={session.sessionType === 'oauth'}> 110 + {session.sessionType === 'oauth' ? 'OAuth' : 'Session'} 111 + </span> 112 + {#if session.clientName} 113 + <span class="client-name">{session.clientName}</span> 114 {/if} 115 </div> 116 <div class="session-details"> ··· 136 </div> 137 {/each} 138 </div> 139 + <div class="actions-bar"> 140 + <button class="refresh-btn" onclick={loadSessions}>Refresh</button> 141 + {#if sessions.filter(s => !s.isCurrent).length > 0} 142 + <button class="revoke-all-btn" onclick={revokeAllSessions}>Revoke All Other Sessions</button> 143 + {/if} 144 + </div> 145 {/if} 146 {/if} 147 </div> ··· 200 } 201 .session-header { 202 margin-bottom: 0.5rem; 203 + display: flex; 204 + align-items: center; 205 + gap: 0.5rem; 206 + flex-wrap: wrap; 207 } 208 + .client-name { 209 font-weight: 500; 210 + color: var(--text-primary); 211 } 212 .badge { 213 display: inline-block; ··· 219 .badge.current { 220 background: var(--accent); 221 color: white; 222 + } 223 + .badge.type { 224 + background: var(--bg-secondary); 225 + color: var(--text-secondary); 226 + border: 1px solid var(--border-color); 227 + } 228 + .badge.type.oauth { 229 + background: #e6f4ea; 230 + color: #1e7e34; 231 + border-color: #b8d9c5; 232 } 233 .session-details { 234 display: flex; ··· 264 .revoke-btn.danger:hover { 265 background: var(--error-bg); 266 } 267 + .actions-bar { 268 margin-top: 1rem; 269 + display: flex; 270 + gap: 0.5rem; 271 + flex-wrap: wrap; 272 + } 273 + .refresh-btn { 274 padding: 0.5rem 1rem; 275 background: transparent; 276 border: 1px solid var(--border-color); ··· 281 .refresh-btn:hover { 282 background: var(--bg-card); 283 border-color: var(--accent); 284 + } 285 + .revoke-all-btn { 286 + padding: 0.5rem 1rem; 287 + background: transparent; 288 + border: 1px solid var(--error-text); 289 + border-radius: 4px; 290 + cursor: pointer; 291 + color: var(--error-text); 292 + } 293 + .revoke-all-btn:hover { 294 + background: var(--error-bg); 295 } 296 </style>
+8
migrations/20251229_legacy_login_security.sql
···
··· 1 + ALTER TABLE users ADD COLUMN allow_legacy_login BOOLEAN NOT NULL DEFAULT TRUE; 2 + 3 + ALTER TABLE session_tokens ADD COLUMN mfa_verified BOOLEAN NOT NULL DEFAULT FALSE; 4 + ALTER TABLE session_tokens ADD COLUMN legacy_login BOOLEAN NOT NULL DEFAULT FALSE; 5 + 6 + CREATE INDEX idx_session_tokens_legacy ON session_tokens(did, legacy_login) WHERE legacy_login = TRUE; 7 + 8 + ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'legacy_login_alert';
+35 -32
src/api/error.rs
··· 4 response::{IntoResponse, Response}, 5 }; 6 use serde::Serialize; 7 8 #[derive(Debug, Serialize)] 9 - struct ErrorBody { 10 - error: &'static str, 11 #[serde(skip_serializing_if = "Option::is_none")] 12 message: Option<String>, 13 } ··· 90 | Self::InvalidSwap => StatusCode::BAD_REQUEST, 91 } 92 } 93 - fn error_name(&self) -> &'static str { 94 match self { 95 - Self::InternalError | Self::DatabaseError => "InternalError", 96 - Self::UpstreamFailure | Self::UpstreamUnavailable(_) => "UpstreamFailure", 97 - Self::UpstreamTimeout => "UpstreamTimeout", 98 Self::UpstreamError { error, .. } => { 99 if let Some(e) = error { 100 - return Box::leak(e.clone().into_boxed_str()); 101 } 102 - "UpstreamError" 103 } 104 - Self::AuthenticationRequired => "AuthenticationRequired", 105 - Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => "AuthenticationFailed", 106 - Self::InvalidToken => "InvalidToken", 107 - Self::ExpiredToken | Self::ExpiredTokenMsg(_) => "ExpiredToken", 108 - Self::TokenRequired => "TokenRequired", 109 - Self::AccountDeactivated => "AccountDeactivated", 110 - Self::AccountTakedown => "AccountTakedown", 111 - Self::Forbidden => "Forbidden", 112 - Self::InvitesDisabled => "InvitesDisabled", 113 - Self::AccountNotFound => "AccountNotFound", 114 - Self::RepoNotFound | Self::RepoNotFoundMsg(_) => "RepoNotFound", 115 - Self::RecordNotFound => "RecordNotFound", 116 - Self::BlobNotFound => "BlobNotFound", 117 - Self::AppPasswordNotFound => "AppPasswordNotFound", 118 - Self::InvalidRequest(_) => "InvalidRequest", 119 - Self::InvalidHandle => "InvalidHandle", 120 - Self::HandleNotAvailable => "HandleNotAvailable", 121 - Self::HandleTaken => "HandleTaken", 122 - Self::InvalidEmail => "InvalidEmail", 123 - Self::EmailTaken => "EmailTaken", 124 - Self::InvalidInviteCode => "InvalidInviteCode", 125 - Self::DuplicateCreate => "DuplicateCreate", 126 - Self::DuplicateAppPassword => "DuplicateAppPassword", 127 - Self::InvalidSwap => "InvalidSwap", 128 } 129 } 130 fn message(&self) -> Option<String> {
··· 4 response::{IntoResponse, Response}, 5 }; 6 use serde::Serialize; 7 + use std::borrow::Cow; 8 9 #[derive(Debug, Serialize)] 10 + struct ErrorBody<'a> { 11 + error: Cow<'a, str>, 12 #[serde(skip_serializing_if = "Option::is_none")] 13 message: Option<String>, 14 } ··· 91 | Self::InvalidSwap => StatusCode::BAD_REQUEST, 92 } 93 } 94 + fn error_name(&self) -> Cow<'static, str> { 95 match self { 96 + Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"), 97 + Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"), 98 + Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"), 99 Self::UpstreamError { error, .. } => { 100 if let Some(e) = error { 101 + return Cow::Owned(e.clone()); 102 } 103 + Cow::Borrowed("UpstreamError") 104 } 105 + Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"), 106 + Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => { 107 + Cow::Borrowed("AuthenticationFailed") 108 + } 109 + Self::InvalidToken => Cow::Borrowed("InvalidToken"), 110 + Self::ExpiredToken | Self::ExpiredTokenMsg(_) => Cow::Borrowed("ExpiredToken"), 111 + Self::TokenRequired => Cow::Borrowed("TokenRequired"), 112 + Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), 113 + Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), 114 + Self::Forbidden => Cow::Borrowed("Forbidden"), 115 + Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"), 116 + Self::AccountNotFound => Cow::Borrowed("AccountNotFound"), 117 + Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"), 118 + Self::RecordNotFound => Cow::Borrowed("RecordNotFound"), 119 + Self::BlobNotFound => Cow::Borrowed("BlobNotFound"), 120 + Self::AppPasswordNotFound => Cow::Borrowed("AppPasswordNotFound"), 121 + Self::InvalidRequest(_) => Cow::Borrowed("InvalidRequest"), 122 + Self::InvalidHandle => Cow::Borrowed("InvalidHandle"), 123 + Self::HandleNotAvailable => Cow::Borrowed("HandleNotAvailable"), 124 + Self::HandleTaken => Cow::Borrowed("HandleTaken"), 125 + Self::InvalidEmail => Cow::Borrowed("InvalidEmail"), 126 + Self::EmailTaken => Cow::Borrowed("EmailTaken"), 127 + Self::InvalidInviteCode => Cow::Borrowed("InvalidInviteCode"), 128 + Self::DuplicateCreate => Cow::Borrowed("DuplicateCreate"), 129 + Self::DuplicateAppPassword => Cow::Borrowed("DuplicateAppPassword"), 130 + Self::InvalidSwap => Cow::Borrowed("InvalidSwap"), 131 } 132 } 133 fn message(&self) -> Option<String> {
+25 -12
src/api/identity/account.rs
··· 2 use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; 3 use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; 4 use crate::state::{AppState, RateLimitKind}; 5 use axum::{ 6 Json, 7 extract::State, ··· 124 .unwrap_or(false); 125 126 if is_migration { 127 - let migration_did = input.did.as_ref().unwrap(); 128 - let auth_did = migration_auth.as_ref().unwrap(); 129 - if migration_did != auth_did { 130 - return ( 131 - StatusCode::FORBIDDEN, 132 - Json(json!({ 133 - "error": "AuthorizationError", 134 - "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) 135 - })), 136 - ) 137 - .into_response(); 138 } 139 - info!(did = %migration_did, "Processing account migration"); 140 } 141 142 let hostname_for_validation = ··· 670 } 671 } 672 } 673 let password_hash = match hash(&input.password, DEFAULT_COST) { 674 Ok(h) => h, 675 Err(e) => {
··· 2 use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; 3 use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; 4 use crate::state::{AppState, RateLimitKind}; 5 + use crate::validation::validate_password; 6 use axum::{ 7 Json, 8 extract::State, ··· 125 .unwrap_or(false); 126 127 if is_migration { 128 + if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) 129 + { 130 + if migration_did != auth_did { 131 + return ( 132 + StatusCode::FORBIDDEN, 133 + Json(json!({ 134 + "error": "AuthorizationError", 135 + "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) 136 + })), 137 + ) 138 + .into_response(); 139 + } 140 + info!(did = %migration_did, "Processing account migration"); 141 } 142 } 143 144 let hostname_for_validation = ··· 672 } 673 } 674 } 675 + if let Err(e) = validate_password(&input.password) { 676 + return ( 677 + StatusCode::BAD_REQUEST, 678 + Json(json!({ 679 + "error": "InvalidPassword", 680 + "message": e.to_string() 681 + })), 682 + ) 683 + .into_response(); 684 + } 685 + 686 let password_hash = match hash(&input.password, DEFAULT_COST) { 687 Ok(h) => h, 688 Err(e) => {
+8 -2
src/api/server/account_status.rs
··· 304 "https://{}/xrpc/com.atproto.server.requestAccountDelete", 305 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 306 ); 307 - let did = match crate::auth::validate_token_with_dpop( 308 &state.db, 309 &extracted.token, 310 extracted.is_dpop, ··· 315 ) 316 .await 317 { 318 - Ok(user) => user.did, 319 Err(e) => return ApiError::from(e).into_response(), 320 }; 321 let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 322 .fetch_optional(&state.db) 323 .await
··· 304 "https://{}/xrpc/com.atproto.server.requestAccountDelete", 305 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 306 ); 307 + let validated = match crate::auth::validate_token_with_dpop( 308 &state.db, 309 &extracted.token, 310 extracted.is_dpop, ··· 315 ) 316 .await 317 { 318 + Ok(user) => user, 319 Err(e) => return ApiError::from(e).into_response(), 320 }; 321 + let did = validated.did.clone(); 322 + 323 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &did).await { 324 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &did).await; 325 + } 326 + 327 let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 328 .fetch_optional(&state.db) 329 .await
+6 -4
src/api/server/mod.rs
··· 33 change_password, get_password_status, remove_password, request_password_reset, reset_password, 34 }; 35 pub use reauth::{ 36 - check_reauth_required, get_reauth_status, reauth_passkey_finish, reauth_passkey_start, 37 - reauth_password, reauth_required_response, reauth_totp, 38 }; 39 pub use service_auth::get_service_auth; 40 pub use session::{ 41 - confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session, 42 - resend_verification, revoke_session, 43 }; 44 pub use signing_key::reserve_signing_key; 45 pub use totp::{
··· 33 change_password, get_password_status, remove_password, request_password_reset, reset_password, 34 }; 35 pub use reauth::{ 36 + check_legacy_session_mfa, check_reauth_required, get_reauth_status, legacy_mfa_required_response, 37 + reauth_passkey_finish, reauth_passkey_start, reauth_password, reauth_required_response, 38 + reauth_totp, update_mfa_verified, 39 }; 40 pub use service_auth::get_service_auth; 41 pub use session::{ 42 + confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session, 43 + list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session, 44 + update_legacy_login_preference, 45 }; 46 pub use signing_key::reserve_signing_key; 47 pub use totp::{
+6 -2
src/api/server/passkey_account.rs
··· 16 use uuid::Uuid; 17 18 use crate::state::{AppState, RateLimitKind}; 19 20 fn extract_client_ip(headers: &HeaderMap) -> String { 21 if let Some(forwarded) = headers.get("x-forwarded-for") ··· 1108 State(state): State<AppState>, 1109 Json(input): Json<RecoverPasskeyAccountInput>, 1110 ) -> Response { 1111 - if input.new_password.len() < 8 { 1112 return ( 1113 StatusCode::BAD_REQUEST, 1114 - Json(json!({"error": "WeakPassword", "message": "Password must be at least 8 characters"})), 1115 ) 1116 .into_response(); 1117 }
··· 16 use uuid::Uuid; 17 18 use crate::state::{AppState, RateLimitKind}; 19 + use crate::validation::validate_password; 20 21 fn extract_client_ip(headers: &HeaderMap) -> String { 22 if let Some(forwarded) = headers.get("x-forwarded-for") ··· 1109 State(state): State<AppState>, 1110 Json(input): Json<RecoverPasskeyAccountInput>, 1111 ) -> Response { 1112 + if let Err(e) = validate_password(&input.new_password) { 1113 return ( 1114 StatusCode::BAD_REQUEST, 1115 + Json(json!({ 1116 + "error": "InvalidPassword", 1117 + "message": e.to_string() 1118 + })), 1119 ) 1120 .into_response(); 1121 }
+9
src/api/server/passkeys.rs
··· 294 auth: BearerAuth, 295 Json(input): Json<DeletePasskeyInput>, 296 ) -> Response { 297 let id: uuid::Uuid = match input.id.parse() { 298 Ok(id) => id, 299 Err(_) => {
··· 294 auth: BearerAuth, 295 Json(input): Json<DeletePasskeyInput>, 296 ) -> Response { 297 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 298 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 299 + .await; 300 + } 301 + 302 + if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { 303 + return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 304 + } 305 + 306 let id: uuid::Uuid = match input.id.parse() { 307 Ok(id) => id, 308 Err(_) => {
+26 -2
src/api/server/password.rs
··· 1 use crate::auth::BearerAuth; 2 use crate::state::{AppState, RateLimitKind}; 3 use axum::{ 4 Json, 5 extract::State, ··· 164 ) 165 .into_response(); 166 } 167 let user = sqlx::query!( 168 "SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", 169 token ··· 326 auth: BearerAuth, 327 Json(input): Json<ChangePasswordInput>, 328 ) -> Response { 329 let current_password = &input.current_password; 330 let new_password = &input.new_password; 331 if current_password.is_empty() { ··· 342 ) 343 .into_response(); 344 } 345 - if new_password.len() < 8 { 346 return ( 347 StatusCode::BAD_REQUEST, 348 - Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})), 349 ) 350 .into_response(); 351 } ··· 447 } 448 449 pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response { 450 if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { 451 return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 452 }
··· 1 use crate::auth::BearerAuth; 2 use crate::state::{AppState, RateLimitKind}; 3 + use crate::validation::validate_password; 4 use axum::{ 5 Json, 6 extract::State, ··· 165 ) 166 .into_response(); 167 } 168 + if let Err(e) = validate_password(password) { 169 + return ( 170 + StatusCode::BAD_REQUEST, 171 + Json(json!({ 172 + "error": "InvalidPassword", 173 + "message": e.to_string() 174 + })), 175 + ) 176 + .into_response(); 177 + } 178 let user = sqlx::query!( 179 "SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", 180 token ··· 337 auth: BearerAuth, 338 Json(input): Json<ChangePasswordInput>, 339 ) -> Response { 340 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 341 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 342 + .await; 343 + } 344 + 345 let current_password = &input.current_password; 346 let new_password = &input.new_password; 347 if current_password.is_empty() { ··· 358 ) 359 .into_response(); 360 } 361 + if let Err(e) = validate_password(new_password) { 362 return ( 363 StatusCode::BAD_REQUEST, 364 + Json(json!({ 365 + "error": "InvalidPassword", 366 + "message": e.to_string() 367 + })), 368 ) 369 .into_response(); 370 } ··· 466 } 467 468 pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response { 469 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 470 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 471 + .await; 472 + } 473 + 474 if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { 475 return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 476 }
+85 -18
src/api/server/reauth.rs
··· 11 use tracing::{error, info, warn}; 12 13 use crate::auth::BearerAuth; 14 - use crate::state::AppState; 15 16 const REAUTH_WINDOW_SECONDS: i64 = 300; 17 ··· 155 auth: BearerAuth, 156 Json(input): Json<TotpReauthInput>, 157 ) -> Response { 158 let valid = 159 crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code) 160 .await; ··· 352 }; 353 354 let cred_id_bytes = auth_result.cred_id().as_ref(); 355 - if let Err(e) = crate::auth::webauthn::update_passkey_counter( 356 &state.db, 357 cred_id_bytes, 358 auth_result.counter(), 359 ) 360 .await 361 { 362 - error!("Failed to update passkey counter: {:?}", e); 363 } 364 365 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; ··· 383 async fn update_last_reauth(db: &PgPool, did: &str) -> Result<DateTime<Utc>, sqlx::Error> { 384 let now = Utc::now(); 385 sqlx::query!( 386 - "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", 387 now, 388 did 389 ) ··· 416 .unwrap_or(Some(false)); 417 418 if has_password == Some(true) { 419 - methods.push("password".to_string()); 420 - } 421 - 422 - let has_app_password = sqlx::query_scalar!( 423 - "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", 424 - did 425 - ) 426 - .fetch_optional(db) 427 - .await 428 - .ok() 429 - .flatten() 430 - .is_some(); 431 - 432 - if has_app_password && !methods.contains(&"password".to_string()) { 433 methods.push("password".to_string()); 434 } 435 ··· 480 ) 481 .into_response() 482 }
··· 11 use tracing::{error, info, warn}; 12 13 use crate::auth::BearerAuth; 14 + use crate::state::{AppState, RateLimitKind}; 15 16 const REAUTH_WINDOW_SECONDS: i64 = 300; 17 ··· 155 auth: BearerAuth, 156 Json(input): Json<TotpReauthInput>, 157 ) -> Response { 158 + if !state 159 + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) 160 + .await 161 + { 162 + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); 163 + return ( 164 + StatusCode::TOO_MANY_REQUESTS, 165 + Json(json!({ 166 + "error": "RateLimitExceeded", 167 + "message": "Too many verification attempts. Please try again in a few minutes." 168 + })), 169 + ) 170 + .into_response(); 171 + } 172 + 173 let valid = 174 crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code) 175 .await; ··· 367 }; 368 369 let cred_id_bytes = auth_result.cred_id().as_ref(); 370 + match crate::auth::webauthn::update_passkey_counter( 371 &state.db, 372 cred_id_bytes, 373 auth_result.counter(), 374 ) 375 .await 376 { 377 + Ok(false) => { 378 + warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key"); 379 + let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; 380 + return ( 381 + StatusCode::UNAUTHORIZED, 382 + Json(json!({ 383 + "error": "PasskeyCounterAnomaly", 384 + "message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key." 385 + })), 386 + ) 387 + .into_response(); 388 + } 389 + Err(e) => { 390 + error!("Failed to update passkey counter: {:?}", e); 391 + } 392 + Ok(true) => {} 393 } 394 395 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; ··· 413 async fn update_last_reauth(db: &PgPool, did: &str) -> Result<DateTime<Utc>, sqlx::Error> { 414 let now = Utc::now(); 415 sqlx::query!( 416 + "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", 417 now, 418 did 419 ) ··· 446 .unwrap_or(Some(false)); 447 448 if has_password == Some(true) { 449 methods.push("password".to_string()); 450 } 451 ··· 496 ) 497 .into_response() 498 } 499 + 500 + pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool { 501 + let session = sqlx::query!( 502 + "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 503 + did 504 + ) 505 + .fetch_optional(db) 506 + .await; 507 + 508 + match session { 509 + Ok(Some(row)) => { 510 + if !row.legacy_login { 511 + return true; 512 + } 513 + if row.mfa_verified { 514 + return true; 515 + } 516 + if let Some(last_reauth) = row.last_reauth_at { 517 + let elapsed = chrono::Utc::now().signed_duration_since(last_reauth); 518 + if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { 519 + return true; 520 + } 521 + } 522 + false 523 + } 524 + _ => true, 525 + } 526 + } 527 + 528 + pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> { 529 + sqlx::query!( 530 + "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", 531 + did 532 + ) 533 + .execute(db) 534 + .await?; 535 + Ok(()) 536 + } 537 + 538 + pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response { 539 + let methods = get_available_reauth_methods(db, did).await; 540 + ( 541 + StatusCode::FORBIDDEN, 542 + Json(serde_json::json!({ 543 + "error": "MfaVerificationRequired", 544 + "message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.", 545 + "reauthMethods": methods 546 + })), 547 + ) 548 + .into_response() 549 + }
+385 -55
src/api/server/session.rs
··· 92 r#"SELECT 93 u.id, u.did, u.handle, u.password_hash, 94 u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, 95 - k.key_bytes, k.encryption_version 96 FROM users u 97 JOIN user_keys k ON u.id = k.user_id 98 WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#, ··· 161 ) 162 .into_response(); 163 } 164 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 165 Ok(m) => m, 166 Err(e) => { ··· 176 } 177 }; 178 if let Err(e) = sqlx::query!( 179 - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 180 row.did, 181 access_meta.jti, 182 refresh_meta.jti, 183 access_meta.expires_at, 184 - refresh_meta.expires_at 185 ) 186 .execute(&state.db) 187 .await 188 { 189 error!("Failed to insert session: {:?}", e); 190 return ApiError::InternalError.into_response(); 191 } 192 let handle = full_handle(&row.handle, &pds_hostname); 193 Json(CreateSessionOutput { ··· 617 } 618 }; 619 if let Err(e) = sqlx::query!( 620 - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 621 row.did, 622 access_meta.jti, 623 refresh_meta.jti, 624 access_meta.expires_at, 625 - refresh_meta.expires_at 626 ) 627 .execute(&state.db) 628 .await ··· 746 #[serde(rename_all = "camelCase")] 747 pub struct SessionInfo { 748 pub id: String, 749 pub created_at: String, 750 pub expires_at: String, 751 pub is_current: bool, ··· 767 .and_then(|v| v.to_str().ok()) 768 .and_then(|v| v.strip_prefix("Bearer ")) 769 .and_then(|token| crate::auth::get_jti_from_token(token).ok()); 770 - let result = sqlx::query_as::< 771 _, 772 ( 773 i32, ··· 786 .bind(&auth.0.did) 787 .fetch_all(&state.db) 788 .await; 789 - match result { 790 Ok(rows) => { 791 - let sessions: Vec<SessionInfo> = rows 792 - .into_iter() 793 - .map(|(id, access_jti, created_at, expires_at)| SessionInfo { 794 - id: id.to_string(), 795 created_at: created_at.to_rfc3339(), 796 expires_at: expires_at.to_rfc3339(), 797 is_current: current_jti.as_ref() == Some(&access_jti), 798 - }) 799 - .collect(); 800 - (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() 801 } 802 Err(e) => { 803 - error!("DB error in list_sessions: {:?}", e); 804 - ( 805 StatusCode::INTERNAL_SERVER_ERROR, 806 Json(json!({"error": "InternalError"})), 807 ) 808 - .into_response() 809 } 810 } 811 } 812 813 #[derive(Deserialize)] ··· 821 auth: BearerAuth, 822 Json(input): Json<RevokeSessionInput>, 823 ) -> Response { 824 - let session_id: i32 = match input.session_id.parse() { 825 - Ok(id) => id, 826 - Err(_) => { 827 return ( 828 - StatusCode::BAD_REQUEST, 829 - Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), 830 ) 831 .into_response(); 832 } 833 - }; 834 - let session = sqlx::query_as::<_, (String,)>( 835 - "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", 836 ) 837 - .bind(session_id) 838 - .bind(&auth.0.did) 839 .fetch_optional(&state.db) 840 .await; 841 - let access_jti = match session { 842 - Ok(Some((jti,))) => jti, 843 - Ok(None) => { 844 - return ( 845 - StatusCode::NOT_FOUND, 846 - Json(json!({"error": "SessionNotFound", "message": "Session not found"})), 847 ) 848 - .into_response(); 849 } 850 Err(e) => { 851 - error!("DB error in revoke_session: {:?}", e); 852 - return ( 853 StatusCode::INTERNAL_SERVER_ERROR, 854 Json(json!({"error": "InternalError"})), 855 ) 856 - .into_response(); 857 } 858 - }; 859 - if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1") 860 - .bind(session_id) 861 - .execute(&state.db) 862 - .await 863 - { 864 - error!("DB error deleting session: {:?}", e); 865 - return ( 866 - StatusCode::INTERNAL_SERVER_ERROR, 867 - Json(json!({"error": "InternalError"})), 868 - ) 869 - .into_response(); 870 } 871 - let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti); 872 - if let Err(e) = state.cache.delete(&cache_key).await { 873 - warn!("Failed to invalidate session cache: {:?}", e); 874 - } 875 - info!(did = %auth.0.did, session_id = %session_id, "Session revoked"); 876 - (StatusCode::OK, Json(json!({}))).into_response() 877 }
··· 92 r#"SELECT 93 u.id, u.did, u.handle, u.password_hash, 94 u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, 95 + u.allow_legacy_login, 96 + u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel", 97 + k.key_bytes, k.encryption_version, 98 + (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled 99 FROM users u 100 JOIN user_keys k ON u.id = k.user_id 101 WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#, ··· 164 ) 165 .into_response(); 166 } 167 + let has_totp = row.totp_enabled.unwrap_or(false); 168 + let is_legacy_login = has_totp; 169 + if has_totp && !row.allow_legacy_login { 170 + warn!( 171 + "Legacy login blocked for TOTP-enabled account: {}", 172 + row.did 173 + ); 174 + return ( 175 + StatusCode::FORBIDDEN, 176 + Json(json!({ 177 + "error": "MfaRequired", 178 + "message": "This account requires MFA. Please use an OAuth client that supports TOTP verification.", 179 + "did": row.did 180 + })), 181 + ) 182 + .into_response(); 183 + } 184 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 185 Ok(m) => m, 186 Err(e) => { ··· 196 } 197 }; 198 if let Err(e) = sqlx::query!( 199 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", 200 row.did, 201 access_meta.jti, 202 refresh_meta.jti, 203 access_meta.expires_at, 204 + refresh_meta.expires_at, 205 + is_legacy_login, 206 + false 207 ) 208 .execute(&state.db) 209 .await 210 { 211 error!("Failed to insert session: {:?}", e); 212 return ApiError::InternalError.into_response(); 213 + } 214 + if is_legacy_login { 215 + warn!( 216 + did = %row.did, 217 + ip = %client_ip, 218 + "Legacy login on TOTP-enabled account - sending notification" 219 + ); 220 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 221 + if let Err(e) = crate::comms::queue_legacy_login_notification( 222 + &state.db, 223 + row.id, 224 + &hostname, 225 + &client_ip, 226 + row.preferred_comms_channel, 227 + ) 228 + .await 229 + { 230 + error!("Failed to queue legacy login notification: {:?}", e); 231 + } 232 } 233 let handle = full_handle(&row.handle, &pds_hostname); 234 Json(CreateSessionOutput { ··· 658 } 659 }; 660 if let Err(e) = sqlx::query!( 661 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", 662 row.did, 663 access_meta.jti, 664 refresh_meta.jti, 665 access_meta.expires_at, 666 + refresh_meta.expires_at, 667 + false, 668 + false 669 ) 670 .execute(&state.db) 671 .await ··· 789 #[serde(rename_all = "camelCase")] 790 pub struct SessionInfo { 791 pub id: String, 792 + pub session_type: String, 793 + pub client_name: Option<String>, 794 pub created_at: String, 795 pub expires_at: String, 796 pub is_current: bool, ··· 812 .and_then(|v| v.to_str().ok()) 813 .and_then(|v| v.strip_prefix("Bearer ")) 814 .and_then(|token| crate::auth::get_jti_from_token(token).ok()); 815 + 816 + let mut sessions: Vec<SessionInfo> = Vec::new(); 817 + 818 + let jwt_result = sqlx::query_as::< 819 _, 820 ( 821 i32, ··· 834 .bind(&auth.0.did) 835 .fetch_all(&state.db) 836 .await; 837 + 838 + match jwt_result { 839 Ok(rows) => { 840 + for (id, access_jti, created_at, expires_at) in rows { 841 + sessions.push(SessionInfo { 842 + id: format!("jwt:{}", id), 843 + session_type: "legacy".to_string(), 844 + client_name: None, 845 created_at: created_at.to_rfc3339(), 846 expires_at: expires_at.to_rfc3339(), 847 is_current: current_jti.as_ref() == Some(&access_jti), 848 + }); 849 + } 850 } 851 Err(e) => { 852 + error!("DB error fetching JWT sessions: {:?}", e); 853 + return ( 854 StatusCode::INTERNAL_SERVER_ERROR, 855 Json(json!({"error": "InternalError"})), 856 ) 857 + .into_response(); 858 } 859 } 860 + 861 + let oauth_result = sqlx::query_as::< 862 + _, 863 + ( 864 + i32, 865 + String, 866 + chrono::DateTime<chrono::Utc>, 867 + chrono::DateTime<chrono::Utc>, 868 + String, 869 + ), 870 + >( 871 + r#" 872 + SELECT id, token_id, created_at, expires_at, client_id 873 + FROM oauth_token 874 + WHERE did = $1 AND expires_at > NOW() 875 + ORDER BY created_at DESC 876 + "#, 877 + ) 878 + .bind(&auth.0.did) 879 + .fetch_all(&state.db) 880 + .await; 881 + 882 + match oauth_result { 883 + Ok(rows) => { 884 + for (id, token_id, created_at, expires_at, client_id) in rows { 885 + let client_name = extract_client_name(&client_id); 886 + let is_current_oauth = auth.0.is_oauth 887 + && current_jti.as_ref() == Some(&token_id); 888 + sessions.push(SessionInfo { 889 + id: format!("oauth:{}", id), 890 + session_type: "oauth".to_string(), 891 + client_name: Some(client_name), 892 + created_at: created_at.to_rfc3339(), 893 + expires_at: expires_at.to_rfc3339(), 894 + is_current: is_current_oauth, 895 + }); 896 + } 897 + } 898 + Err(e) => { 899 + error!("DB error fetching OAuth sessions: {:?}", e); 900 + return ( 901 + StatusCode::INTERNAL_SERVER_ERROR, 902 + Json(json!({"error": "InternalError"})), 903 + ) 904 + .into_response(); 905 + } 906 + } 907 + 908 + sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); 909 + 910 + (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() 911 + } 912 + 913 + fn extract_client_name(client_id: &str) -> String { 914 + if client_id.starts_with("http://localhost") || client_id.starts_with("http://127.0.0.1") { 915 + "Localhost App".to_string() 916 + } else if let Ok(parsed) = reqwest::Url::parse(client_id) { 917 + parsed.host_str().unwrap_or("Unknown App").to_string() 918 + } else { 919 + client_id.to_string() 920 + } 921 } 922 923 #[derive(Deserialize)] ··· 931 auth: BearerAuth, 932 Json(input): Json<RevokeSessionInput>, 933 ) -> Response { 934 + if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") { 935 + let session_id: i32 = match jwt_id.parse() { 936 + Ok(id) => id, 937 + Err(_) => { 938 + return ( 939 + StatusCode::BAD_REQUEST, 940 + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), 941 + ) 942 + .into_response(); 943 + } 944 + }; 945 + let session = sqlx::query_as::<_, (String,)>( 946 + "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", 947 + ) 948 + .bind(session_id) 949 + .bind(&auth.0.did) 950 + .fetch_optional(&state.db) 951 + .await; 952 + let access_jti = match session { 953 + Ok(Some((jti,))) => jti, 954 + Ok(None) => { 955 + return ( 956 + StatusCode::NOT_FOUND, 957 + Json(json!({"error": "SessionNotFound", "message": "Session not found"})), 958 + ) 959 + .into_response(); 960 + } 961 + Err(e) => { 962 + error!("DB error in revoke_session: {:?}", e); 963 + return ( 964 + StatusCode::INTERNAL_SERVER_ERROR, 965 + Json(json!({"error": "InternalError"})), 966 + ) 967 + .into_response(); 968 + } 969 + }; 970 + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1") 971 + .bind(session_id) 972 + .execute(&state.db) 973 + .await 974 + { 975 + error!("DB error deleting session: {:?}", e); 976 return ( 977 + StatusCode::INTERNAL_SERVER_ERROR, 978 + Json(json!({"error": "InternalError"})), 979 ) 980 .into_response(); 981 } 982 + let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti); 983 + if let Err(e) = state.cache.delete(&cache_key).await { 984 + warn!("Failed to invalidate session cache: {:?}", e); 985 + } 986 + info!(did = %auth.0.did, session_id = %session_id, "JWT session revoked"); 987 + } else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") { 988 + let session_id: i32 = match oauth_id.parse() { 989 + Ok(id) => id, 990 + Err(_) => { 991 + return ( 992 + StatusCode::BAD_REQUEST, 993 + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), 994 + ) 995 + .into_response(); 996 + } 997 + }; 998 + let result = sqlx::query("DELETE FROM oauth_token WHERE id = $1 AND did = $2") 999 + .bind(session_id) 1000 + .bind(&auth.0.did) 1001 + .execute(&state.db) 1002 + .await; 1003 + match result { 1004 + Ok(r) if r.rows_affected() == 0 => { 1005 + return ( 1006 + StatusCode::NOT_FOUND, 1007 + Json(json!({"error": "SessionNotFound", "message": "Session not found"})), 1008 + ) 1009 + .into_response(); 1010 + } 1011 + Err(e) => { 1012 + error!("DB error deleting OAuth session: {:?}", e); 1013 + return ( 1014 + StatusCode::INTERNAL_SERVER_ERROR, 1015 + Json(json!({"error": "InternalError"})), 1016 + ) 1017 + .into_response(); 1018 + } 1019 + _ => {} 1020 + } 1021 + info!(did = %auth.0.did, session_id = %session_id, "OAuth session revoked"); 1022 + } else { 1023 + return ( 1024 + StatusCode::BAD_REQUEST, 1025 + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID format"})), 1026 + ) 1027 + .into_response(); 1028 + } 1029 + (StatusCode::OK, Json(json!({}))).into_response() 1030 + } 1031 + 1032 + pub async fn revoke_all_sessions( 1033 + State(state): State<AppState>, 1034 + headers: HeaderMap, 1035 + auth: BearerAuth, 1036 + ) -> Response { 1037 + let current_jti = headers 1038 + .get("authorization") 1039 + .and_then(|v| v.to_str().ok()) 1040 + .and_then(|v| v.strip_prefix("Bearer ")) 1041 + .and_then(|token| crate::auth::get_jti_from_token(token).ok()); 1042 + 1043 + if let Some(ref jti) = current_jti { 1044 + if auth.0.is_oauth { 1045 + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1") 1046 + .bind(&auth.0.did) 1047 + .execute(&state.db) 1048 + .await 1049 + { 1050 + error!("DB error revoking JWT sessions: {:?}", e); 1051 + return ( 1052 + StatusCode::INTERNAL_SERVER_ERROR, 1053 + Json(json!({"error": "InternalError"})), 1054 + ) 1055 + .into_response(); 1056 + } 1057 + if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2") 1058 + .bind(&auth.0.did) 1059 + .bind(jti) 1060 + .execute(&state.db) 1061 + .await 1062 + { 1063 + error!("DB error revoking OAuth sessions: {:?}", e); 1064 + return ( 1065 + StatusCode::INTERNAL_SERVER_ERROR, 1066 + Json(json!({"error": "InternalError"})), 1067 + ) 1068 + .into_response(); 1069 + } 1070 + } else { 1071 + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") 1072 + .bind(&auth.0.did) 1073 + .bind(jti) 1074 + .execute(&state.db) 1075 + .await 1076 + { 1077 + error!("DB error revoking JWT sessions: {:?}", e); 1078 + return ( 1079 + StatusCode::INTERNAL_SERVER_ERROR, 1080 + Json(json!({"error": "InternalError"})), 1081 + ) 1082 + .into_response(); 1083 + } 1084 + if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1") 1085 + .bind(&auth.0.did) 1086 + .execute(&state.db) 1087 + .await 1088 + { 1089 + error!("DB error revoking OAuth sessions: {:?}", e); 1090 + return ( 1091 + StatusCode::INTERNAL_SERVER_ERROR, 1092 + Json(json!({"error": "InternalError"})), 1093 + ) 1094 + .into_response(); 1095 + } 1096 + } 1097 + } else { 1098 + return ( 1099 + StatusCode::BAD_REQUEST, 1100 + Json(json!({"error": "InvalidToken", "message": "Could not identify current session"})), 1101 + ) 1102 + .into_response(); 1103 + } 1104 + 1105 + info!(did = %auth.0.did, "All other sessions revoked"); 1106 + (StatusCode::OK, Json(json!({"success": true}))).into_response() 1107 + } 1108 + 1109 + #[derive(Serialize)] 1110 + #[serde(rename_all = "camelCase")] 1111 + pub struct LegacyLoginPreferenceOutput { 1112 + pub allow_legacy_login: bool, 1113 + pub has_mfa: bool, 1114 + } 1115 + 1116 + pub async fn get_legacy_login_preference( 1117 + State(state): State<AppState>, 1118 + auth: BearerAuth, 1119 + ) -> Response { 1120 + let result = sqlx::query!( 1121 + r#"SELECT 1122 + u.allow_legacy_login, 1123 + (EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR 1124 + EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as "has_mfa!" 1125 + FROM users u WHERE u.did = $1"#, 1126 + auth.0.did 1127 ) 1128 .fetch_optional(&state.db) 1129 .await; 1130 + 1131 + match result { 1132 + Ok(Some(row)) => Json(LegacyLoginPreferenceOutput { 1133 + allow_legacy_login: row.allow_legacy_login, 1134 + has_mfa: row.has_mfa, 1135 + }) 1136 + .into_response(), 1137 + Ok(None) => ( 1138 + StatusCode::NOT_FOUND, 1139 + Json(json!({"error": "AccountNotFound"})), 1140 + ) 1141 + .into_response(), 1142 + Err(e) => { 1143 + error!("DB error: {:?}", e); 1144 + ( 1145 + StatusCode::INTERNAL_SERVER_ERROR, 1146 + Json(json!({"error": "InternalError"})), 1147 ) 1148 + .into_response() 1149 } 1150 + } 1151 + } 1152 + 1153 + #[derive(Deserialize)] 1154 + #[serde(rename_all = "camelCase")] 1155 + pub struct UpdateLegacyLoginInput { 1156 + pub allow_legacy_login: bool, 1157 + } 1158 + 1159 + pub async fn update_legacy_login_preference( 1160 + State(state): State<AppState>, 1161 + auth: BearerAuth, 1162 + Json(input): Json<UpdateLegacyLoginInput>, 1163 + ) -> Response { 1164 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 1165 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 1166 + .await; 1167 + } 1168 + 1169 + if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { 1170 + return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 1171 + } 1172 + 1173 + let result = sqlx::query!( 1174 + "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did", 1175 + input.allow_legacy_login, 1176 + auth.0.did 1177 + ) 1178 + .fetch_optional(&state.db) 1179 + .await; 1180 + 1181 + match result { 1182 + Ok(Some(_)) => { 1183 + info!( 1184 + did = %auth.0.did, 1185 + allow_legacy_login = input.allow_legacy_login, 1186 + "Legacy login preference updated" 1187 + ); 1188 + Json(json!({ 1189 + "allowLegacyLogin": input.allow_legacy_login 1190 + })) 1191 + .into_response() 1192 + } 1193 + Ok(None) => ( 1194 + StatusCode::NOT_FOUND, 1195 + Json(json!({"error": "AccountNotFound"})), 1196 + ) 1197 + .into_response(), 1198 Err(e) => { 1199 + error!("DB error: {:?}", e); 1200 + ( 1201 StatusCode::INTERNAL_SERVER_ERROR, 1202 Json(json!({"error": "InternalError"})), 1203 ) 1204 + .into_response() 1205 } 1206 } 1207 }
+51 -1
src/api/server/totp.rs
··· 4 generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format, 5 verify_backup_code, verify_totp_code, 6 }; 7 - use crate::state::AppState; 8 use axum::{ 9 Json, 10 extract::State, ··· 149 auth: BearerAuth, 150 Json(input): Json<EnableTotpInput>, 151 ) -> Response { 152 let totp_row = sqlx::query!( 153 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", 154 auth.0.did ··· 309 auth: BearerAuth, 310 Json(input): Json<DisableTotpInput>, 311 ) -> Response { 312 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 313 .fetch_optional(&state.db) 314 .await; ··· 516 auth: BearerAuth, 517 Json(input): Json<RegenerateBackupCodesInput>, 518 ) -> Response { 519 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 520 .fetch_optional(&state.db) 521 .await;
··· 4 generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format, 5 verify_backup_code, verify_totp_code, 6 }; 7 + use crate::state::{AppState, RateLimitKind}; 8 use axum::{ 9 Json, 10 extract::State, ··· 149 auth: BearerAuth, 150 Json(input): Json<EnableTotpInput>, 151 ) -> Response { 152 + if !state 153 + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) 154 + .await 155 + { 156 + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); 157 + return ( 158 + StatusCode::TOO_MANY_REQUESTS, 159 + Json(json!({ 160 + "error": "RateLimitExceeded", 161 + "message": "Too many verification attempts. Please try again in a few minutes." 162 + })), 163 + ) 164 + .into_response(); 165 + } 166 + 167 let totp_row = sqlx::query!( 168 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", 169 auth.0.did ··· 324 auth: BearerAuth, 325 Json(input): Json<DisableTotpInput>, 326 ) -> Response { 327 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 328 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 329 + .await; 330 + } 331 + 332 + if !state 333 + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) 334 + .await 335 + { 336 + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); 337 + return ( 338 + StatusCode::TOO_MANY_REQUESTS, 339 + Json(json!({ 340 + "error": "RateLimitExceeded", 341 + "message": "Too many verification attempts. Please try again in a few minutes." 342 + })), 343 + ) 344 + .into_response(); 345 + } 346 + 347 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 348 .fetch_optional(&state.db) 349 .await; ··· 551 auth: BearerAuth, 552 Json(input): Json<RegenerateBackupCodesInput>, 553 ) -> Response { 554 + if !state 555 + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) 556 + .await 557 + { 558 + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); 559 + return ( 560 + StatusCode::TOO_MANY_REQUESTS, 561 + Json(json!({ 562 + "error": "RateLimitExceeded", 563 + "message": "Too many verification attempts. Please try again in a few minutes." 564 + })), 565 + ) 566 + .into_response(); 567 + } 568 + 569 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 570 .fetch_optional(&state.db) 571 .await;
+8 -6
src/api/validation.rs
··· 64 return Err(HandleValidationError::TooLong); 65 } 66 67 - let first_char = handle.chars().next().unwrap(); 68 - if first_char == '-' || first_char == '_' { 69 - return Err(HandleValidationError::StartsWithInvalidChar); 70 } 71 72 - let last_char = handle.chars().last().unwrap(); 73 - if last_char == '-' || last_char == '_' { 74 - return Err(HandleValidationError::EndsWithInvalidChar); 75 } 76 77 for c in handle.chars() {
··· 64 return Err(HandleValidationError::TooLong); 65 } 66 67 + if let Some(first_char) = handle.chars().next() { 68 + if first_char == '-' || first_char == '_' { 69 + return Err(HandleValidationError::StartsWithInvalidChar); 70 + } 71 } 72 73 + if let Some(last_char) = handle.chars().last() { 74 + if last_char == '-' || last_char == '_' { 75 + return Err(HandleValidationError::EndsWithInvalidChar); 76 + } 77 } 78 79 for c in handle.chars() {
+17 -2
src/auth/webauthn.rs
··· 341 pool: &PgPool, 342 credential_id: &[u8], 343 new_counter: u32, 344 - ) -> Result<(), sqlx::Error> { 345 sqlx::query!( 346 "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2", 347 new_counter as i32, ··· 349 ) 350 .execute(pool) 351 .await?; 352 - Ok(()) 353 } 354 355 pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result<bool, sqlx::Error> {
··· 341 pool: &PgPool, 342 credential_id: &[u8], 343 new_counter: u32, 344 + ) -> Result<bool, sqlx::Error> { 345 + let stored = get_passkey_by_credential_id(pool, credential_id).await?; 346 + let Some(stored) = stored else { 347 + return Err(sqlx::Error::RowNotFound); 348 + }; 349 + 350 + if new_counter > 0 && new_counter <= stored.sign_count as u32 { 351 + tracing::warn!( 352 + credential_id = ?credential_id, 353 + stored_counter = stored.sign_count, 354 + new_counter = new_counter, 355 + "Passkey counter did not increment - possible cloned key!" 356 + ); 357 + return Ok(false); 358 + } 359 + 360 sqlx::query!( 361 "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2", 362 new_counter as i32, ··· 364 ) 365 .execute(pool) 366 .await?; 367 + Ok(true) 368 } 369 370 pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result<bool, sqlx::Error> {
+1
src/comms/mod.rs
··· 11 CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, 12 enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery, 13 enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, 14 }; 15 16 pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
··· 11 CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, 12 enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery, 13 enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, 14 + queue_legacy_login_notification, 15 }; 16 17 pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+38
src/comms/service.rs
··· 527 ) 528 .await 529 }
··· 527 ) 528 .await 529 } 530 + 531 + pub async fn queue_legacy_login_notification( 532 + db: &PgPool, 533 + user_id: Uuid, 534 + hostname: &str, 535 + client_ip: &str, 536 + channel: CommsChannel, 537 + ) -> Result<Uuid, sqlx::Error> { 538 + let prefs = get_user_comms_prefs(db, user_id).await?; 539 + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); 540 + let body = format!( 541 + "Hello @{},\n\n\ 542 + A login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\n\ 543 + Details:\n\ 544 + - Time: {}\n\ 545 + - IP Address: {}\n\n\ 546 + Your TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\n\ 547 + If this wasn't you, please:\n\ 548 + 1. Change your password immediately\n\ 549 + 2. Review your active sessions\n\ 550 + 3. Consider disabling legacy app logins in your security settings\n\n\ 551 + Stay safe,\n\ 552 + {}", 553 + prefs.handle, timestamp, client_ip, hostname 554 + ); 555 + enqueue_comms( 556 + db, 557 + NewComms::new( 558 + user_id, 559 + channel, 560 + super::types::CommsType::LegacyLoginAlert, 561 + prefs.email.clone().unwrap_or_default(), 562 + Some(format!("Security Alert: Legacy Login Detected - {}", hostname)), 563 + body, 564 + ), 565 + ) 566 + .await 567 + }
+1
src/comms/types.rs
··· 33 PlcOperation, 34 TwoFactorCode, 35 PasskeyRecovery, 36 } 37 38 #[derive(Debug, Clone, FromRow)]
··· 33 PlcOperation, 34 TwoFactorCode, 35 PasskeyRecovery, 36 + LegacyLoginAlert, 37 } 38 39 #[derive(Debug, Clone, FromRow)]
+67
src/config.rs
··· 19 pub signing_key_x: String, 20 pub signing_key_y: String, 21 key_encryption_key: [u8; 32], 22 } 23 24 impl AuthConfig { ··· 112 hk.expand(b"tranquil-pds-user-key-encryption", &mut key_encryption_key) 113 .expect("HKDF expansion failed"); 114 115 AuthConfig { 116 jwt_secret, 117 dpop_secret, ··· 120 signing_key_x, 121 signing_key_y, 122 key_encryption_key, 123 } 124 }) 125 } ··· 136 137 pub fn dpop_secret(&self) -> &str { 138 &self.dpop_secret 139 } 140 141 pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
··· 19 pub signing_key_x: String, 20 pub signing_key_y: String, 21 key_encryption_key: [u8; 32], 22 + device_cookie_key: [u8; 32], 23 } 24 25 impl AuthConfig { ··· 113 hk.expand(b"tranquil-pds-user-key-encryption", &mut key_encryption_key) 114 .expect("HKDF expansion failed"); 115 116 + let mut device_cookie_key = [0u8; 32]; 117 + hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key) 118 + .expect("HKDF expansion failed"); 119 + 120 AuthConfig { 121 jwt_secret, 122 dpop_secret, ··· 125 signing_key_x, 126 signing_key_y, 127 key_encryption_key, 128 + device_cookie_key, 129 } 130 }) 131 } ··· 142 143 pub fn dpop_secret(&self) -> &str { 144 &self.dpop_secret 145 + } 146 + 147 + pub fn sign_device_cookie(&self, device_id: &str) -> String { 148 + use hmac::Mac; 149 + type HmacSha256 = hmac::Hmac<Sha256>; 150 + 151 + let timestamp = std::time::SystemTime::now() 152 + .duration_since(std::time::UNIX_EPOCH) 153 + .unwrap_or_default() 154 + .as_secs(); 155 + 156 + let message = format!("{}:{}", device_id, timestamp); 157 + let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.device_cookie_key) 158 + .expect("HMAC key size is valid"); 159 + mac.update(message.as_bytes()); 160 + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); 161 + 162 + format!("{}.{}.{}", device_id, timestamp, signature) 163 + } 164 + 165 + pub fn verify_device_cookie(&self, cookie_value: &str) -> Option<String> { 166 + use hmac::Mac; 167 + type HmacSha256 = hmac::Hmac<Sha256>; 168 + 169 + let parts: Vec<&str> = cookie_value.splitn(3, '.').collect(); 170 + if parts.len() != 3 { 171 + return None; 172 + } 173 + 174 + let device_id = parts[0]; 175 + let timestamp_str = parts[1]; 176 + let provided_signature = parts[2]; 177 + 178 + let timestamp: u64 = timestamp_str.parse().ok()?; 179 + 180 + let now = std::time::SystemTime::now() 181 + .duration_since(std::time::UNIX_EPOCH) 182 + .unwrap_or_default() 183 + .as_secs(); 184 + 185 + let max_age_days = 400; 186 + if now.saturating_sub(timestamp) > max_age_days * 24 * 60 * 60 { 187 + return None; 188 + } 189 + 190 + let message = format!("{}:{}", device_id, timestamp); 191 + let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.device_cookie_key) 192 + .expect("HMAC key size is valid"); 193 + mac.update(message.as_bytes()); 194 + let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); 195 + 196 + use subtle::ConstantTimeEq; 197 + if provided_signature 198 + .as_bytes() 199 + .ct_eq(expected_signature.as_bytes()) 200 + .into() 201 + { 202 + Some(device_id.to_string()) 203 + } else { 204 + None 205 + } 206 } 207 208 pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
+12
src/lib.rs
··· 60 post(api::server::revoke_session), 61 ) 62 .route( 63 "/xrpc/com.atproto.server.deleteSession", 64 post(api::server::delete_session), 65 ) ··· 229 .route( 230 "/xrpc/com.tranquil.account.reauthPasskeyFinish", 231 post(api::server::reauth_passkey_finish), 232 ) 233 .route( 234 "/xrpc/com.tranquil.account.listTrustedDevices",
··· 60 post(api::server::revoke_session), 61 ) 62 .route( 63 + "/xrpc/com.tranquil.account.revokeAllSessions", 64 + post(api::server::revoke_all_sessions), 65 + ) 66 + .route( 67 "/xrpc/com.atproto.server.deleteSession", 68 post(api::server::delete_session), 69 ) ··· 233 .route( 234 "/xrpc/com.tranquil.account.reauthPasskeyFinish", 235 post(api::server::reauth_passkey_finish), 236 + ) 237 + .route( 238 + "/xrpc/com.tranquil.account.getLegacyLoginPreference", 239 + get(api::server::get_legacy_login_preference), 240 + ) 241 + .route( 242 + "/xrpc/com.tranquil.account.updateLegacyLoginPreference", 243 + post(api::server::update_legacy_login_preference), 244 ) 245 .route( 246 "/xrpc/com.tranquil.account.listTrustedDevices",
+49 -8
src/oauth/endpoints/authorize.rs
··· 39 for cookie in cookie_str.split(';') { 40 let cookie = cookie.trim(); 41 if let Some(value) = cookie.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) { 42 - return Some(value.to_string()); 43 } 44 } 45 None ··· 69 } 70 71 fn make_device_cookie(device_id: &str) -> String { 72 format!( 73 "{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000", 74 - DEVICE_COOKIE_NAME, device_id 75 ) 76 } 77 ··· 1511 "No 2FA challenge found. Please start over.", 1512 ); 1513 } 1514 let totp_valid = 1515 crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await; 1516 if !totp_valid { ··· 2067 tracing::warn!(error = %e, "Failed to delete authentication state"); 2068 } 2069 2070 - if auth_result.needs_update() 2071 - && let Err(e) = crate::auth::webauthn::update_passkey_counter( 2072 &state.db, 2073 auth_result.cred_id(), 2074 auth_result.counter(), 2075 ) 2076 .await 2077 - { 2078 - tracing::warn!(error = %e, "Failed to update passkey counter"); 2079 } 2080 2081 tracing::info!(did = %did, "Passkey authentication successful"); ··· 2469 2470 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await; 2471 2472 - if let Err(e) = crate::auth::webauthn::update_passkey_counter( 2473 &state.db, 2474 credential.id.as_ref(), 2475 auth_result.counter(), 2476 ) 2477 .await 2478 { 2479 - tracing::warn!("Failed to update passkey counter: {:?}", e); 2480 } 2481 2482 let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await;
··· 39 for cookie in cookie_str.split(';') { 40 let cookie = cookie.trim(); 41 if let Some(value) = cookie.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) { 42 + return crate::config::AuthConfig::get().verify_device_cookie(value); 43 } 44 } 45 None ··· 69 } 70 71 fn make_device_cookie(device_id: &str) -> String { 72 + let signed_value = crate::config::AuthConfig::get().sign_device_cookie(device_id); 73 format!( 74 "{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000", 75 + DEVICE_COOKIE_NAME, signed_value 76 ) 77 } 78 ··· 1512 "No 2FA challenge found. Please start over.", 1513 ); 1514 } 1515 + if !state 1516 + .check_rate_limit(RateLimitKind::TotpVerify, &did) 1517 + .await 1518 + { 1519 + tracing::warn!(did = %did, "TOTP verification rate limit exceeded"); 1520 + return json_error( 1521 + StatusCode::TOO_MANY_REQUESTS, 1522 + "RateLimitExceeded", 1523 + "Too many verification attempts. Please try again in a few minutes.", 1524 + ); 1525 + } 1526 let totp_valid = 1527 crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await; 1528 if !totp_valid { ··· 2079 tracing::warn!(error = %e, "Failed to delete authentication state"); 2080 } 2081 2082 + if auth_result.needs_update() { 2083 + match crate::auth::webauthn::update_passkey_counter( 2084 &state.db, 2085 auth_result.cred_id(), 2086 auth_result.counter(), 2087 ) 2088 .await 2089 + { 2090 + Ok(false) => { 2091 + tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key"); 2092 + return ( 2093 + StatusCode::FORBIDDEN, 2094 + Json(serde_json::json!({ 2095 + "error": "access_denied", 2096 + "error_description": "Security key counter anomaly detected. This may indicate a cloned key." 2097 + })), 2098 + ) 2099 + .into_response(); 2100 + } 2101 + Err(e) => { 2102 + tracing::warn!(error = %e, "Failed to update passkey counter"); 2103 + } 2104 + Ok(true) => {} 2105 + } 2106 } 2107 2108 tracing::info!(did = %did, "Passkey authentication successful"); ··· 2496 2497 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await; 2498 2499 + match crate::auth::webauthn::update_passkey_counter( 2500 &state.db, 2501 credential.id.as_ref(), 2502 auth_result.counter(), 2503 ) 2504 .await 2505 { 2506 + Ok(false) => { 2507 + tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key"); 2508 + return ( 2509 + StatusCode::FORBIDDEN, 2510 + Json(serde_json::json!({ 2511 + "error": "access_denied", 2512 + "error_description": "Security key counter anomaly detected. This may indicate a cloned key." 2513 + })), 2514 + ) 2515 + .into_response(); 2516 + } 2517 + Err(e) => { 2518 + tracing::warn!("Failed to update passkey counter: {:?}", e); 2519 + } 2520 + Ok(true) => {} 2521 } 2522 2523 let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await;
+5
src/rate_limit.rs
··· 29 pub oauth_introspect: Arc<KeyedRateLimiter>, 30 pub app_password: Arc<KeyedRateLimiter>, 31 pub email_update: Arc<KeyedRateLimiter>, 32 } 33 34 impl Default for RateLimiters { ··· 73 email_update: Arc::new(RateLimiter::keyed(Quota::per_hour( 74 NonZeroU32::new(5).unwrap(), 75 ))), 76 } 77 } 78
··· 29 pub oauth_introspect: Arc<KeyedRateLimiter>, 30 pub app_password: Arc<KeyedRateLimiter>, 31 pub email_update: Arc<KeyedRateLimiter>, 32 + pub totp_verify: Arc<KeyedRateLimiter>, 33 } 34 35 impl Default for RateLimiters { ··· 74 email_update: Arc::new(RateLimiter::keyed(Quota::per_hour( 75 NonZeroU32::new(5).unwrap(), 76 ))), 77 + totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60)) 78 + .unwrap() 79 + .allow_burst(NonZeroU32::new(5).unwrap()), 80 + )), 81 } 82 } 83
+4
src/state.rs
··· 35 OAuthIntrospect, 36 AppPassword, 37 EmailUpdate, 38 } 39 40 impl RateLimitKind { ··· 51 Self::OAuthIntrospect => "oauth_introspect", 52 Self::AppPassword => "app_password", 53 Self::EmailUpdate => "email_update", 54 } 55 } 56 ··· 67 Self::OAuthIntrospect => (30, 60_000), 68 Self::AppPassword => (10, 60_000), 69 Self::EmailUpdate => (5, 3_600_000), 70 } 71 } 72 } ··· 142 RateLimitKind::OAuthIntrospect => &self.rate_limiters.oauth_introspect, 143 RateLimitKind::AppPassword => &self.rate_limiters.app_password, 144 RateLimitKind::EmailUpdate => &self.rate_limiters.email_update, 145 }; 146 147 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
··· 35 OAuthIntrospect, 36 AppPassword, 37 EmailUpdate, 38 + TotpVerify, 39 } 40 41 impl RateLimitKind { ··· 52 Self::OAuthIntrospect => "oauth_introspect", 53 Self::AppPassword => "app_password", 54 Self::EmailUpdate => "email_update", 55 + Self::TotpVerify => "totp_verify", 56 } 57 } 58 ··· 69 Self::OAuthIntrospect => (30, 60_000), 70 Self::AppPassword => (10, 60_000), 71 Self::EmailUpdate => (5, 3_600_000), 72 + Self::TotpVerify => (5, 300_000), 73 } 74 } 75 } ··· 145 RateLimitKind::OAuthIntrospect => &self.rate_limiters.oauth_introspect, 146 RateLimitKind::AppPassword => &self.rate_limiters.app_password, 147 RateLimitKind::EmailUpdate => &self.rate_limiters.email_update, 148 + RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, 149 }; 150 151 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+68
src/validation/mod.rs
··· 409 Ok(()) 410 } 411 412 #[cfg(test)] 413 mod tests { 414 use super::*;
··· 409 Ok(()) 410 } 411 412 + #[derive(Debug)] 413 + pub struct PasswordValidationError { 414 + pub errors: Vec<String>, 415 + } 416 + 417 + impl std::fmt::Display for PasswordValidationError { 418 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 419 + write!(f, "{}", self.errors.join("; ")) 420 + } 421 + } 422 + 423 + impl std::error::Error for PasswordValidationError {} 424 + 425 + pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> { 426 + let mut errors = Vec::new(); 427 + 428 + if password.len() < 8 { 429 + errors.push("Password must be at least 8 characters".to_string()); 430 + } 431 + 432 + if password.len() > 256 { 433 + errors.push("Password must be at most 256 characters".to_string()); 434 + } 435 + 436 + if !password.chars().any(|c| c.is_ascii_lowercase()) { 437 + errors.push("Password must contain at least one lowercase letter".to_string()); 438 + } 439 + 440 + if !password.chars().any(|c| c.is_ascii_uppercase()) { 441 + errors.push("Password must contain at least one uppercase letter".to_string()); 442 + } 443 + 444 + if !password.chars().any(|c| c.is_ascii_digit()) { 445 + errors.push("Password must contain at least one number".to_string()); 446 + } 447 + 448 + if is_common_password(password) { 449 + errors.push("Password is too common, please choose a different one".to_string()); 450 + } 451 + 452 + if errors.is_empty() { 453 + Ok(()) 454 + } else { 455 + Err(PasswordValidationError { errors }) 456 + } 457 + } 458 + 459 + fn is_common_password(password: &str) -> bool { 460 + const COMMON_PASSWORDS: &[&str] = &[ 461 + "password", "Password1", "Password123", "Passw0rd", "Passw0rd!", 462 + "12345678", "123456789", "1234567890", 463 + "qwerty123", "Qwerty123", "qwertyui", "Qwertyui", 464 + "letmein1", "Letmein1", "welcome1", "Welcome1", 465 + "admin123", "Admin123", "password1", "Password1!", 466 + "iloveyou", "Iloveyou1", "monkey123", "Monkey123", 467 + "dragon12", "Dragon123", "master12", "Master123", 468 + "login123", "Login123", "abc12345", "Abc12345", 469 + "football", "Football1", "baseball", "Baseball1", 470 + "trustno1", "Trustno1", "sunshine", "Sunshine1", 471 + "princess", "Princess1", "computer", "Computer1", 472 + "whatever", "Whatever1", "nintendo", "Nintendo1", 473 + "bluesky1", "Bluesky1", "Bluesky123", 474 + ]; 475 + 476 + let lower = password.to_lowercase(); 477 + COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower) 478 + } 479 + 480 #[cfg(test)] 481 mod tests { 482 use super::*;
+1 -1
tests/admin_search.rs
··· 63 let create_payload = serde_json::json!({ 64 "handle": unique_handle, 65 "email": format!("unique-{}@searchtest.com", ts), 66 - "password": "test-password-123" 67 }); 68 let create_res = client 69 .post(format!(
··· 63 let create_payload = serde_json::json!({ 64 "handle": unique_handle, 65 "email": format!("unique-{}@searchtest.com", ts), 66 + "password": "Testpass123!" 67 }); 68 let create_res = client 69 .post(format!(
+9 -9
tests/change_password.rs
··· 11 let ts = chrono::Utc::now().timestamp_millis(); 12 let handle = format!("change-pw-{}.test", ts); 13 let email = format!("change-pw-{}@test.com", ts); 14 - let old_password = "old-password-123"; 15 - let new_password = "new-password-456"; 16 let create_payload = json!({ 17 "handle": handle, 18 "email": email, ··· 92 )) 93 .bearer_auth(&jwt) 94 .json(&json!({ 95 - "currentPassword": "wrong-password", 96 - "newPassword": "new-password-123" 97 })) 98 .send() 99 .await ··· 109 let ts = chrono::Utc::now().timestamp_millis(); 110 let handle = format!("change-pw-short-{}.test", ts); 111 let email = format!("change-pw-short-{}@test.com", ts); 112 - let password = "correct-password"; 113 let create_payload = json!({ 114 "handle": handle, 115 "email": email, ··· 158 .bearer_auth(&jwt) 159 .json(&json!({ 160 "currentPassword": "", 161 - "newPassword": "new-password-123" 162 })) 163 .send() 164 .await ··· 177 )) 178 .bearer_auth(&jwt) 179 .json(&json!({ 180 - "currentPassword": "e2e-password-123", 181 "newPassword": "" 182 })) 183 .send() ··· 195 base_url().await 196 )) 197 .json(&json!({ 198 - "currentPassword": "old", 199 - "newPassword": "new-password-123" 200 })) 201 .send() 202 .await
··· 11 let ts = chrono::Utc::now().timestamp_millis(); 12 let handle = format!("change-pw-{}.test", ts); 13 let email = format!("change-pw-{}@test.com", ts); 14 + let old_password = "Oldpass123!"; 15 + let new_password = "Newpass456!"; 16 let create_payload = json!({ 17 "handle": handle, 18 "email": email, ··· 92 )) 93 .bearer_auth(&jwt) 94 .json(&json!({ 95 + "currentPassword": "Wrongpass999!", 96 + "newPassword": "Newpass123!" 97 })) 98 .send() 99 .await ··· 109 let ts = chrono::Utc::now().timestamp_millis(); 110 let handle = format!("change-pw-short-{}.test", ts); 111 let email = format!("change-pw-short-{}@test.com", ts); 112 + let password = "Correct123!"; 113 let create_payload = json!({ 114 "handle": handle, 115 "email": email, ··· 158 .bearer_auth(&jwt) 159 .json(&json!({ 160 "currentPassword": "", 161 + "newPassword": "Newpass123!" 162 })) 163 .send() 164 .await ··· 177 )) 178 .bearer_auth(&jwt) 179 .json(&json!({ 180 + "currentPassword": "E2epass123!", 181 "newPassword": "" 182 })) 183 .send() ··· 195 base_url().await 196 )) 197 .json(&json!({ 198 + "currentPassword": "Oldpass123!", 199 + "newPassword": "Newpass123!" 200 })) 201 .send() 202 .await
+1 -1
tests/common/mod.rs
··· 418 let payload = json!({ 419 "handle": handle, 420 "email": format!("{}@example.com", handle), 421 - "password": "password" 422 }); 423 let res = match client 424 .post(format!(
··· 418 let payload = json!({ 419 "handle": handle, 420 "email": format!("{}@example.com", handle), 421 + "password": "Testpass123!" 422 }); 423 let res = match client 424 .post(format!(
+7 -7
tests/delete_account.rs
··· 49 let ts = Utc::now().timestamp_millis(); 50 let handle = format!("delete-test-{}.test", ts); 51 let email = format!("delete-test-{}@test.com", ts); 52 - let password = "delete-password-123"; 53 let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; 54 let request_delete_res = client 55 .post(format!( ··· 106 let ts = Utc::now().timestamp_millis(); 107 let handle = format!("delete-wrongpw-{}.test", ts); 108 let email = format!("delete-wrongpw-{}@test.com", ts); 109 - let password = "correct-password"; 110 let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; 111 let request_delete_res = client 112 .post(format!( ··· 153 let ts = Utc::now().timestamp_millis(); 154 let handle = format!("delete-badtoken-{}.test", ts); 155 let email = format!("delete-badtoken-{}@test.com", ts); 156 - let password = "delete-password"; 157 let create_res = client 158 .post(format!( 159 "{}/xrpc/com.atproto.server.createAccount", ··· 196 let ts = Utc::now().timestamp_millis(); 197 let handle = format!("delete-expired-{}.test", ts); 198 let email = format!("delete-expired-{}@test.com", ts); 199 - let password = "delete-password"; 200 let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; 201 let request_delete_res = client 202 .post(format!( ··· 250 let ts = Utc::now().timestamp_millis(); 251 let handle1 = format!("delete-user1-{}.test", ts); 252 let email1 = format!("delete-user1-{}@test.com", ts); 253 - let password1 = "user1-password"; 254 let (did1, jwt1) = 255 create_verified_account(&client, &base_url, &handle1, &email1, password1).await; 256 let handle2 = format!("delete-user2-{}.test", ts); 257 let email2 = format!("delete-user2-{}@test.com", ts); 258 - let password2 = "user2-password"; 259 let (did2, _) = create_verified_account(&client, &base_url, &handle2, &email2, password2).await; 260 let request_delete_res = client 261 .post(format!( ··· 302 let ts = Utc::now().timestamp_millis(); 303 let handle = format!("delete-apppw-{}.test", ts); 304 let email = format!("delete-apppw-{}@test.com", ts); 305 - let main_password = "main-password-123"; 306 let (did, jwt) = 307 create_verified_account(&client, &base_url, &handle, &email, main_password).await; 308 let app_password_res = client
··· 49 let ts = Utc::now().timestamp_millis(); 50 let handle = format!("delete-test-{}.test", ts); 51 let email = format!("delete-test-{}@test.com", ts); 52 + let password = "Delete123pass!"; 53 let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; 54 let request_delete_res = client 55 .post(format!( ··· 106 let ts = Utc::now().timestamp_millis(); 107 let handle = format!("delete-wrongpw-{}.test", ts); 108 let email = format!("delete-wrongpw-{}@test.com", ts); 109 + let password = "Correct123!"; 110 let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; 111 let request_delete_res = client 112 .post(format!( ··· 153 let ts = Utc::now().timestamp_millis(); 154 let handle = format!("delete-badtoken-{}.test", ts); 155 let email = format!("delete-badtoken-{}@test.com", ts); 156 + let password = "Delete123!"; 157 let create_res = client 158 .post(format!( 159 "{}/xrpc/com.atproto.server.createAccount", ··· 196 let ts = Utc::now().timestamp_millis(); 197 let handle = format!("delete-expired-{}.test", ts); 198 let email = format!("delete-expired-{}@test.com", ts); 199 + let password = "Delete123!"; 200 let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; 201 let request_delete_res = client 202 .post(format!( ··· 250 let ts = Utc::now().timestamp_millis(); 251 let handle1 = format!("delete-user1-{}.test", ts); 252 let email1 = format!("delete-user1-{}@test.com", ts); 253 + let password1 = "User1pass123!"; 254 let (did1, jwt1) = 255 create_verified_account(&client, &base_url, &handle1, &email1, password1).await; 256 let handle2 = format!("delete-user2-{}.test", ts); 257 let email2 = format!("delete-user2-{}@test.com", ts); 258 + let password2 = "User2pass123!"; 259 let (did2, _) = create_verified_account(&client, &base_url, &handle2, &email2, password2).await; 260 let request_delete_res = client 261 .post(format!( ··· 302 let ts = Utc::now().timestamp_millis(); 303 let handle = format!("delete-apppw-{}.test", ts); 304 let email = format!("delete-apppw-{}@test.com", ts); 305 + let main_password = "Mainpass123!"; 306 let (did, jwt) = 307 create_verified_account(&client, &base_url, &handle, &email, main_password).await; 308 let app_password_res = client
+6 -6
tests/did_web.rs
··· 12 let payload = json!({ 13 "handle": handle, 14 "email": format!("{}@example.com", handle), 15 - "password": "password", 16 "didType": "web" 17 }); 18 let res = client ··· 139 let payload = json!({ 140 "handle": handle, 141 "email": format!("{}@example.com", handle), 142 - "password": "password", 143 "didType": "web-external", 144 "did": did, 145 "signingKey": signing_key ··· 181 let payload = json!({ 182 "handle": handle, 183 "email": format!("{}@example.com", handle), 184 - "password": "password", 185 "didType": "web" 186 }); 187 let res = client ··· 246 let payload = json!({ 247 "handle": handle, 248 "email": format!("{}@example.com", handle), 249 - "password": "password", 250 "didType": "web" 251 }); 252 let res = client ··· 295 let payload = json!({ 296 "handle": handle, 297 "email": format!("{}@example.com", handle), 298 - "password": "password", 299 "didType": "plc" 300 }); 301 let res = client ··· 324 let payload = json!({ 325 "handle": handle, 326 "email": format!("{}@example.com", handle), 327 - "password": "password", 328 "didType": "web-external" 329 }); 330 let res = client
··· 12 let payload = json!({ 13 "handle": handle, 14 "email": format!("{}@example.com", handle), 15 + "password": "Testpass123!", 16 "didType": "web" 17 }); 18 let res = client ··· 139 let payload = json!({ 140 "handle": handle, 141 "email": format!("{}@example.com", handle), 142 + "password": "Testpass123!", 143 "didType": "web-external", 144 "did": did, 145 "signingKey": signing_key ··· 181 let payload = json!({ 182 "handle": handle, 183 "email": format!("{}@example.com", handle), 184 + "password": "Testpass123!", 185 "didType": "web" 186 }); 187 let res = client ··· 246 let payload = json!({ 247 "handle": handle, 248 "email": format!("{}@example.com", handle), 249 + "password": "Testpass123!", 250 "didType": "web" 251 }); 252 let res = client ··· 295 let payload = json!({ 296 "handle": handle, 297 "email": format!("{}@example.com", handle), 298 + "password": "Testpass123!", 299 "didType": "plc" 300 }); 301 let res = client ··· 324 let payload = json!({ 325 "handle": handle, 326 "email": format!("{}@example.com", handle), 327 + "password": "Testpass123!", 328 "didType": "web-external" 329 }); 330 let res = client
+1 -1
tests/email_update.rs
··· 26 .json(&json!({ 27 "handle": handle, 28 "email": email, 29 - "password": "password" 30 })) 31 .send() 32 .await
··· 26 .json(&json!({ 27 "handle": handle, 28 "email": email, 29 + "password": "Testpass123!" 30 })) 31 .send() 32 .await
+1 -1
tests/helpers/mod.rs
··· 10 let ts = Utc::now().timestamp_millis(); 11 let handle = format!("{}-{}.test", handle_prefix, ts); 12 let email = format!("{}-{}@test.com", handle_prefix, ts); 13 - let password = "e2e-password-123"; 14 let create_account_payload = json!({ 15 "handle": handle, 16 "email": email,
··· 10 let ts = Utc::now().timestamp_millis(); 11 let handle = format!("{}-{}.test", handle_prefix, ts); 12 let email = format!("{}-{}@test.com", handle_prefix, ts); 13 + let password = "E2epass123!"; 14 let create_account_payload = json!({ 15 "handle": handle, 16 "email": email,
+4 -4
tests/identity.rs
··· 12 let payload = json!({ 13 "handle": short_handle, 14 "email": format!("{}@example.com", short_handle), 15 - "password": "password" 16 }); 17 let res = client 18 .post(format!( ··· 142 let payload = json!({ 143 "handle": handle, 144 "email": format!("{}@example.com", handle), 145 - "password": "password", 146 "did": did, 147 "signingKey": signing_key 148 }); ··· 188 let payload = json!({ 189 "handle": handle, 190 "email": email, 191 - "password": "password" 192 }); 193 let res = client 194 .post(format!( ··· 266 let create_payload = json!({ 267 "handle": handle, 268 "email": email, 269 - "password": "password", 270 "did": did, 271 "signingKey": signing_key 272 });
··· 12 let payload = json!({ 13 "handle": short_handle, 14 "email": format!("{}@example.com", short_handle), 15 + "password": "Testpass123!" 16 }); 17 let res = client 18 .post(format!( ··· 142 let payload = json!({ 143 "handle": handle, 144 "email": format!("{}@example.com", handle), 145 + "password": "Testpass123!", 146 "did": did, 147 "signingKey": signing_key 148 }); ··· 188 let payload = json!({ 189 "handle": handle, 190 "email": email, 191 + "password": "Testpass123!" 192 }); 193 let res = client 194 .post(format!( ··· 266 let create_payload = json!({ 267 "handle": handle, 268 "email": email, 269 + "password": "Testpass123!", 270 "did": did, 271 "signingKey": signing_key 272 });
+1 -1
tests/jwt_security.rs
··· 675 676 let create_res = http_client 677 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 678 - .json(&json!({ "handle": handle, "email": email, "password": "test-password-123" })) 679 .send() 680 .await 681 .unwrap();
··· 675 676 let create_res = http_client 677 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 678 + .json(&json!({ "handle": handle, "email": email, "password": "Testpass123!" })) 679 .send() 680 .await 681 .unwrap();
+4 -4
tests/lifecycle_session.rs
··· 36 let ts = Utc::now().timestamp_millis(); 37 let handle = format!("multi-session-{}.test", ts); 38 let email = format!("multi-session-{}@test.com", ts); 39 - let password = "multi-session-pw"; 40 let create_payload = json!({ 41 "handle": handle, 42 "email": email, ··· 112 let ts = Utc::now().timestamp_millis(); 113 let handle = format!("refresh-inv-{}.test", ts); 114 let email = format!("refresh-inv-{}@test.com", ts); 115 - let password = "refresh-inv-pw"; 116 let create_payload = json!({ 117 "handle": handle, 118 "email": email, ··· 180 let ts = Utc::now().timestamp_millis(); 181 let handle = format!("apppass-{}.test", ts); 182 let email = format!("apppass-{}@test.com", ts); 183 - let password = "apppass-password"; 184 let create_res = client 185 .post(format!( 186 "{}/xrpc/com.atproto.server.createAccount", ··· 291 let ts = Utc::now().timestamp_millis(); 292 let handle = format!("deactivate-{}.test", ts); 293 let email = format!("deactivate-{}@test.com", ts); 294 - let password = "deactivate-password"; 295 let create_res = client 296 .post(format!( 297 "{}/xrpc/com.atproto.server.createAccount",
··· 36 let ts = Utc::now().timestamp_millis(); 37 let handle = format!("multi-session-{}.test", ts); 38 let email = format!("multi-session-{}@test.com", ts); 39 + let password = "Multisession123!"; 40 let create_payload = json!({ 41 "handle": handle, 42 "email": email, ··· 112 let ts = Utc::now().timestamp_millis(); 113 let handle = format!("refresh-inv-{}.test", ts); 114 let email = format!("refresh-inv-{}@test.com", ts); 115 + let password = "Refresh123inv!"; 116 let create_payload = json!({ 117 "handle": handle, 118 "email": email, ··· 180 let ts = Utc::now().timestamp_millis(); 181 let handle = format!("apppass-{}.test", ts); 182 let email = format!("apppass-{}@test.com", ts); 183 + let password = "Apppass123!"; 184 let create_res = client 185 .post(format!( 186 "{}/xrpc/com.atproto.server.createAccount", ··· 291 let ts = Utc::now().timestamp_millis(); 292 let handle = format!("deactivate-{}.test", ts); 293 let email = format!("deactivate-{}@test.com", ts); 294 + let password = "Deactivate123!"; 295 let create_res = client 296 .post(format!( 297 "{}/xrpc/com.atproto.server.createAccount",
+1 -1
tests/lifecycle_social.rs
··· 176 let ts = Utc::now().timestamp_millis(); 177 let handle = format!("fullcycle-{}.test", ts); 178 let email = format!("fullcycle-{}@test.com", ts); 179 - let password = "fullcycle-password"; 180 let create_account_res = client 181 .post(format!( 182 "{}/xrpc/com.atproto.server.createAccount",
··· 176 let ts = Utc::now().timestamp_millis(); 177 let handle = format!("fullcycle-{}.test", ts); 178 let email = format!("fullcycle-{}@test.com", ts); 179 + let password = "Fullcycle123!"; 180 let create_account_res = client 181 .post(format!( 182 "{}/xrpc/com.atproto.server.createAccount",
+7 -7
tests/oauth.rs
··· 194 let ts = Utc::now().timestamp_millis(); 195 let handle = format!("oauth-test-{}", ts); 196 let email = format!("oauth-test-{}@example.com", ts); 197 - let password = "oauth-test-password"; 198 let create_res = http_client 199 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 200 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 354 let email = format!("wrong-creds-{}@example.com", ts); 355 http_client 356 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 357 - .json(&json!({ "handle": handle, "email": email, "password": "correct-password" })) 358 .send() 359 .await 360 .unwrap(); ··· 438 let ts = Utc::now().timestamp_millis(); 439 let handle = format!("2fa-test-{}", ts); 440 let email = format!("2fa-test-{}@example.com", ts); 441 - let password = "2fa-test-password"; 442 let create_res = http_client 443 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 444 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 565 let ts = Utc::now().timestamp_millis(); 566 let handle = format!("2fa-lockout-{}", ts); 567 let email = format!("2fa-lockout-{}@example.com", ts); 568 - let password = "2fa-test-password"; 569 let create_res = http_client 570 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 571 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 662 let ts = Utc::now().timestamp_millis(); 663 let handle = format!("selector-2fa-{}", ts); 664 let email = format!("selector-2fa-{}@example.com", ts); 665 - let password = "selector-2fa-password"; 666 let create_res = http_client 667 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 668 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 853 let ts = Utc::now().timestamp_millis(); 854 let handle = format!("state-special-{}", ts); 855 let email = format!("state-special-{}@example.com", ts); 856 - let password = "state-special-password"; 857 let create_res = http_client 858 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 859 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 932 let ts = Utc::now().timestamp_millis(); 933 let handle = format!("scope-test-{}", ts); 934 let email = format!("scope-test-{}@example.com", ts); 935 - let password = "scope-test-password"; 936 let create_res = http_client 937 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 938 .json(&json!({ "handle": handle, "email": email, "password": password }))
··· 194 let ts = Utc::now().timestamp_millis(); 195 let handle = format!("oauth-test-{}", ts); 196 let email = format!("oauth-test-{}@example.com", ts); 197 + let password = "Oauthtest123!"; 198 let create_res = http_client 199 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 200 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 354 let email = format!("wrong-creds-{}@example.com", ts); 355 http_client 356 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 357 + .json(&json!({ "handle": handle, "email": email, "password": "Correct123!" })) 358 .send() 359 .await 360 .unwrap(); ··· 438 let ts = Utc::now().timestamp_millis(); 439 let handle = format!("2fa-test-{}", ts); 440 let email = format!("2fa-test-{}@example.com", ts); 441 + let password = "Twofa123test!"; 442 let create_res = http_client 443 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 444 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 565 let ts = Utc::now().timestamp_millis(); 566 let handle = format!("2fa-lockout-{}", ts); 567 let email = format!("2fa-lockout-{}@example.com", ts); 568 + let password = "Twofa123test!"; 569 let create_res = http_client 570 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 571 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 662 let ts = Utc::now().timestamp_millis(); 663 let handle = format!("selector-2fa-{}", ts); 664 let email = format!("selector-2fa-{}@example.com", ts); 665 + let password = "Selector2fa123!"; 666 let create_res = http_client 667 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 668 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 853 let ts = Utc::now().timestamp_millis(); 854 let handle = format!("state-special-{}", ts); 855 let email = format!("state-special-{}@example.com", ts); 856 + let password = "State123special!"; 857 let create_res = http_client 858 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 859 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 932 let ts = Utc::now().timestamp_millis(); 933 let handle = format!("scope-test-{}", ts); 934 let email = format!("scope-test-{}@example.com", ts); 935 + let password = "Scopetest123!"; 936 let create_res = http_client 937 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 938 .json(&json!({ "handle": handle, "email": email, "password": password }))
+2 -2
tests/oauth_lifecycle.rs
··· 57 let ts = Utc::now().timestamp_millis(); 58 let handle = format!("{}-{}", handle_prefix, ts); 59 let email = format!("{}-{}@example.com", handle_prefix, ts); 60 - let password = format!("{}-password", handle_prefix); 61 let create_res = http_client 62 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 63 .json(&json!({ ··· 577 let ts = Utc::now().timestamp_millis(); 578 let handle = format!("multi-client-{}", ts); 579 let email = format!("multi-client-{}@example.com", ts); 580 - let password = "multi-client-password"; 581 let create_res = http_client 582 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 583 .json(&json!({
··· 57 let ts = Utc::now().timestamp_millis(); 58 let handle = format!("{}-{}", handle_prefix, ts); 59 let email = format!("{}-{}@example.com", handle_prefix, ts); 60 + let password = format!("{}Pass123!", handle_prefix); 61 let create_res = http_client 62 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 63 .json(&json!({ ··· 577 let ts = Utc::now().timestamp_millis(); 578 let handle = format!("multi-client-{}", ts); 579 let email = format!("multi-client-{}@example.com", ts); 580 + let password = "MultiClient123!"; 581 let create_res = http_client 582 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 583 .json(&json!({
+4 -4
tests/oauth_scopes.rs
··· 61 let ts = Utc::now().timestamp_millis(); 62 let handle = format!("{}-{}", handle_prefix, ts); 63 let email = format!("{}-{}@example.com", handle_prefix, ts); 64 - let password = format!("{}-password", handle_prefix); 65 66 let create_res = http_client 67 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) ··· 383 let ts = Utc::now().timestamp_millis(); 384 let handle = format!("consent-test-{}", ts); 385 let email = format!("consent-{}@example.com", ts); 386 - let password = "consent-password"; 387 let redirect_uri = "https://consent-test.example.com/callback"; 388 389 let create_res = http_client ··· 479 let ts = Utc::now().timestamp_millis(); 480 let handle = format!("consent-post-{}", ts); 481 let email = format!("consent-post-{}@example.com", ts); 482 - let password = "consent-post-password"; 483 let redirect_uri = "https://consent-post.example.com/callback"; 484 485 let create_res = http_client ··· 593 let ts = Utc::now().timestamp_millis(); 594 let handle = format!("consent-req-{}", ts); 595 let email = format!("consent-req-{}@example.com", ts); 596 - let password = "consent-req-password"; 597 let redirect_uri = "https://consent-req.example.com/callback"; 598 599 let create_res = http_client
··· 61 let ts = Utc::now().timestamp_millis(); 62 let handle = format!("{}-{}", handle_prefix, ts); 63 let email = format!("{}-{}@example.com", handle_prefix, ts); 64 + let password = format!("{}Pass123!", handle_prefix); 65 66 let create_res = http_client 67 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) ··· 383 let ts = Utc::now().timestamp_millis(); 384 let handle = format!("consent-test-{}", ts); 385 let email = format!("consent-{}@example.com", ts); 386 + let password = "Consent123!"; 387 let redirect_uri = "https://consent-test.example.com/callback"; 388 389 let create_res = http_client ··· 479 let ts = Utc::now().timestamp_millis(); 480 let handle = format!("consent-post-{}", ts); 481 let email = format!("consent-post-{}@example.com", ts); 482 + let password = "ConsentPost123!"; 483 let redirect_uri = "https://consent-post.example.com/callback"; 484 485 let create_res = http_client ··· 593 let ts = Utc::now().timestamp_millis(); 594 let handle = format!("consent-req-{}", ts); 595 let email = format!("consent-req-{}@example.com", ts); 596 + let password = "ConsentReq123!"; 597 let redirect_uri = "https://consent-req.example.com/callback"; 598 599 let create_res = http_client
+10 -10
tests/oauth_security.rs
··· 44 let ts = Utc::now().timestamp_millis(); 45 let handle = format!("sec-test-{}", ts); 46 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 47 - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" })) 48 .send().await.unwrap(); 49 let account: Value = create_res.json().await.unwrap(); 50 let did = account["did"].as_str().unwrap(); ··· 72 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 73 .header("Content-Type", "application/json") 74 .header("Accept", "application/json") 75 - .json(&json!({"request_uri": request_uri, "username": &handle, "password": "security-test-password", "remember_device": false})) 76 .send().await.unwrap(); 77 let auth_body: Value = auth_res.json().await.unwrap(); 78 let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); ··· 258 let ts = Utc::now().timestamp_millis(); 259 let handle = format!("pkce-attack-{}", ts); 260 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 261 - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" })) 262 .send().await.unwrap(); 263 let account: Value = create_res.json().await.unwrap(); 264 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; ··· 283 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 284 .header("Content-Type", "application/json") 285 .header("Accept", "application/json") 286 - .json(&json!({"request_uri": request_uri, "username": &handle, "password": "pkce-password", "remember_device": false})) 287 .send().await.unwrap(); 288 assert_eq!(auth_res.status(), StatusCode::OK); 289 let auth_body: Value = auth_res.json().await.unwrap(); ··· 329 let ts = Utc::now().timestamp_millis(); 330 let handle = format!("replay-{}", ts); 331 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 332 - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" })) 333 .send().await.unwrap(); 334 let account: Value = create_res.json().await.unwrap(); 335 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; ··· 356 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 357 .header("Content-Type", "application/json") 358 .header("Accept", "application/json") 359 - .json(&json!({"request_uri": request_uri, "username": &handle, "password": "replay-password", "remember_device": false})) 360 .send().await.unwrap(); 361 assert_eq!(auth_res.status(), StatusCode::OK); 362 let auth_body: Value = auth_res.json().await.unwrap(); ··· 495 let ts = Utc::now().timestamp_millis(); 496 let handle = format!("deact-{}", ts); 497 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 498 - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "deact-password" })) 499 .send().await.unwrap(); 500 let account: Value = create_res.json().await.unwrap(); 501 let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await; ··· 524 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 525 .header("Content-Type", "application/json") 526 .header("Accept", "application/json") 527 - .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "deact-password", "remember_device": false})) 528 .send().await.unwrap(); 529 assert_eq!( 530 auth_res.status(), ··· 539 let ts2 = Utc::now().timestamp_millis(); 540 let handle2 = format!("cross-{}", ts2); 541 let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 542 - .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" })) 543 .send().await.unwrap(); 544 let account2: Value = create_res2.json().await.unwrap(); 545 verify_new_account(&http_client, account2["did"].as_str().unwrap()).await; ··· 563 let auth_a = http_client.post(format!("{}/oauth/authorize", url)) 564 .header("Content-Type", "application/json") 565 .header("Accept", "application/json") 566 - .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "cross-password", "remember_device": false})) 567 .send().await.unwrap(); 568 assert_eq!(auth_a.status(), StatusCode::OK); 569 let auth_body_a: Value = auth_a.json().await.unwrap();
··· 44 let ts = Utc::now().timestamp_millis(); 45 let handle = format!("sec-test-{}", ts); 46 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 47 + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Security123!" })) 48 .send().await.unwrap(); 49 let account: Value = create_res.json().await.unwrap(); 50 let did = account["did"].as_str().unwrap(); ··· 72 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 73 .header("Content-Type", "application/json") 74 .header("Accept", "application/json") 75 + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Security123!", "remember_device": false})) 76 .send().await.unwrap(); 77 let auth_body: Value = auth_res.json().await.unwrap(); 78 let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); ··· 258 let ts = Utc::now().timestamp_millis(); 259 let handle = format!("pkce-attack-{}", ts); 260 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 261 + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Pkce123pass!" })) 262 .send().await.unwrap(); 263 let account: Value = create_res.json().await.unwrap(); 264 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; ··· 283 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 284 .header("Content-Type", "application/json") 285 .header("Accept", "application/json") 286 + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Pkce123pass!", "remember_device": false})) 287 .send().await.unwrap(); 288 assert_eq!(auth_res.status(), StatusCode::OK); 289 let auth_body: Value = auth_res.json().await.unwrap(); ··· 329 let ts = Utc::now().timestamp_millis(); 330 let handle = format!("replay-{}", ts); 331 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 332 + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Replay123pass!" })) 333 .send().await.unwrap(); 334 let account: Value = create_res.json().await.unwrap(); 335 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; ··· 356 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 357 .header("Content-Type", "application/json") 358 .header("Accept", "application/json") 359 + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Replay123pass!", "remember_device": false})) 360 .send().await.unwrap(); 361 assert_eq!(auth_res.status(), StatusCode::OK); 362 let auth_body: Value = auth_res.json().await.unwrap(); ··· 495 let ts = Utc::now().timestamp_millis(); 496 let handle = format!("deact-{}", ts); 497 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 498 + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Deact123pass!" })) 499 .send().await.unwrap(); 500 let account: Value = create_res.json().await.unwrap(); 501 let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await; ··· 524 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 525 .header("Content-Type", "application/json") 526 .header("Accept", "application/json") 527 + .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "Deact123pass!", "remember_device": false})) 528 .send().await.unwrap(); 529 assert_eq!( 530 auth_res.status(), ··· 539 let ts2 = Utc::now().timestamp_millis(); 540 let handle2 = format!("cross-{}", ts2); 541 let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 542 + .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "Cross123pass!" })) 543 .send().await.unwrap(); 544 let account2: Value = create_res2.json().await.unwrap(); 545 verify_new_account(&http_client, account2["did"].as_str().unwrap()).await; ··· 563 let auth_a = http_client.post(format!("{}/oauth/authorize", url)) 564 .header("Content-Type", "application/json") 565 .header("Accept", "application/json") 566 + .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "Cross123pass!", "remember_device": false})) 567 .send().await.unwrap(); 568 assert_eq!(auth_a.status(), StatusCode::OK); 569 let auth_body_a: Value = auth_a.json().await.unwrap();
+9 -9
tests/password_reset.rs
··· 24 let payload = json!({ 25 "handle": handle, 26 "email": email, 27 - "password": "oldpassword" 28 }); 29 let res = client 30 .post(format!( ··· 83 let pool = get_pool().await; 84 let handle = format!("pwreset2_{}", uuid::Uuid::new_v4()); 85 let email = format!("{}@example.com", handle); 86 - let old_password = "oldpassword"; 87 - let new_password = "newpassword123"; 88 let payload = json!({ 89 "handle": handle, 90 "email": email, ··· 182 )) 183 .json(&json!({ 184 "token": "invalid-token", 185 - "password": "newpassword" 186 })) 187 .send() 188 .await ··· 202 let payload = json!({ 203 "handle": handle, 204 "email": email, 205 - "password": "oldpassword" 206 }); 207 let res = client 208 .post(format!( ··· 246 )) 247 .json(&json!({ 248 "token": token, 249 - "password": "newpassword" 250 })) 251 .send() 252 .await ··· 266 let payload = json!({ 267 "handle": handle, 268 "email": email, 269 - "password": "oldpassword" 270 }); 271 let res = client 272 .post(format!( ··· 313 )) 314 .json(&json!({ 315 "token": token, 316 - "password": "newpassword123" 317 })) 318 .send() 319 .await ··· 356 let payload = json!({ 357 "handle": handle, 358 "email": email, 359 - "password": "oldpassword" 360 }); 361 let res = client 362 .post(format!(
··· 24 let payload = json!({ 25 "handle": handle, 26 "email": email, 27 + "password": "Oldpass123!" 28 }); 29 let res = client 30 .post(format!( ··· 83 let pool = get_pool().await; 84 let handle = format!("pwreset2_{}", uuid::Uuid::new_v4()); 85 let email = format!("{}@example.com", handle); 86 + let old_password = "Oldpass123!"; 87 + let new_password = "Newpass456!"; 88 let payload = json!({ 89 "handle": handle, 90 "email": email, ··· 182 )) 183 .json(&json!({ 184 "token": "invalid-token", 185 + "password": "Newpass123!" 186 })) 187 .send() 188 .await ··· 202 let payload = json!({ 203 "handle": handle, 204 "email": email, 205 + "password": "Oldpass123!" 206 }); 207 let res = client 208 .post(format!( ··· 246 )) 247 .json(&json!({ 248 "token": token, 249 + "password": "Newpass123!" 250 })) 251 .send() 252 .await ··· 266 let payload = json!({ 267 "handle": handle, 268 "email": email, 269 + "password": "Oldpass123!" 270 }); 271 let res = client 272 .post(format!( ··· 313 )) 314 .json(&json!({ 315 "token": token, 316 + "password": "Newpass123!" 317 })) 318 .send() 319 .await ··· 356 let payload = json!({ 357 "handle": handle, 358 "email": email, 359 + "password": "Oldpass123!" 360 }); 361 let res = client 362 .post(format!(
+1 -1
tests/rate_limit.rs
··· 93 let payload = json!({ 94 "handle": format!("ratelimit_{}_{}", i, unique_id), 95 "email": format!("ratelimit_{}_{}@example.com", i, unique_id), 96 - "password": "testpassword123" 97 }); 98 let res = client 99 .post(&url)
··· 93 let payload = json!({ 94 "handle": format!("ratelimit_{}_{}", i, unique_id), 95 "email": format!("ratelimit_{}_{}@example.com", i, unique_id), 96 + "password": "Testpass123!" 97 }); 98 let res = client 99 .post(&url)
+4 -4
tests/server.rs
··· 27 let client = client(); 28 let base = base_url().await; 29 let handle = format!("user_{}", uuid::Uuid::new_v4()); 30 - let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "password" }); 31 let create_res = client 32 .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 33 .json(&payload) ··· 40 let _ = verify_new_account(&client, did).await; 41 let login = client 42 .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 43 - .json(&json!({ "identifier": handle, "password": "password" })) 44 .send() 45 .await 46 .unwrap(); ··· 61 assert_ne!(refresh_body["refreshJwt"].as_str().unwrap(), refresh_jwt); 62 let missing_id = client 63 .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 64 - .json(&json!({ "password": "password" })) 65 .send() 66 .await 67 .unwrap(); ··· 70 || missing_id.status() == StatusCode::UNPROCESSABLE_ENTITY 71 ); 72 let invalid_handle = client.post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 73 - .json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "password" })) 74 .send().await.unwrap(); 75 assert_eq!(invalid_handle.status(), StatusCode::BAD_REQUEST); 76 let unauth_session = client
··· 27 let client = client(); 28 let base = base_url().await; 29 let handle = format!("user_{}", uuid::Uuid::new_v4()); 30 + let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Testpass123!" }); 31 let create_res = client 32 .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 33 .json(&payload) ··· 40 let _ = verify_new_account(&client, did).await; 41 let login = client 42 .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 43 + .json(&json!({ "identifier": handle, "password": "Testpass123!" })) 44 .send() 45 .await 46 .unwrap(); ··· 61 assert_ne!(refresh_body["refreshJwt"].as_str().unwrap(), refresh_jwt); 62 let missing_id = client 63 .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 64 + .json(&json!({ "password": "Testpass123!" })) 65 .send() 66 .await 67 .unwrap(); ··· 70 || missing_id.status() == StatusCode::UNPROCESSABLE_ENTITY 71 ); 72 let invalid_handle = client.post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 73 + .json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "Testpass123!" })) 74 .send().await.unwrap(); 75 assert_eq!(invalid_handle.status(), StatusCode::BAD_REQUEST); 76 let unauth_session = client
+2 -2
tests/session_management.rs
··· 47 let ts = chrono::Utc::now().timestamp_millis(); 48 let handle = format!("multi-list-{}.test", ts); 49 let email = format!("multi-list-{}@test.com", ts); 50 - let password = "test-password-123"; 51 let create_payload = json!({ 52 "handle": handle, 53 "email": email, ··· 122 let ts = chrono::Utc::now().timestamp_millis(); 123 let handle = format!("revoke-sess-{}.test", ts); 124 let email = format!("revoke-sess-{}@test.com", ts); 125 - let password = "test-password-123"; 126 let create_payload = json!({ 127 "handle": handle, 128 "email": email,
··· 47 let ts = chrono::Utc::now().timestamp_millis(); 48 let handle = format!("multi-list-{}.test", ts); 49 let email = format!("multi-list-{}@test.com", ts); 50 + let password = "Testpass123!"; 51 let create_payload = json!({ 52 "handle": handle, 53 "email": email, ··· 122 let ts = chrono::Utc::now().timestamp_millis(); 123 let handle = format!("revoke-sess-{}.test", ts); 124 let email = format!("revoke-sess-{}@test.com", ts); 125 + let password = "Testpass123!"; 126 let create_payload = json!({ 127 "handle": handle, 128 "email": email,
+5 -5
tests/signing_key.rs
··· 183 .json(&json!({ 184 "handle": handle, 185 "email": format!("{}@example.com", handle), 186 - "password": "password", 187 "signingKey": signing_key 188 })) 189 .send() ··· 221 .json(&json!({ 222 "handle": handle, 223 "email": format!("{}@example.com", handle), 224 - "password": "password", 225 "signingKey": "did:key:zNonExistentKey12345" 226 })) 227 .send() ··· 257 .json(&json!({ 258 "handle": handle1, 259 "email": format!("{}@example.com", handle1), 260 - "password": "password", 261 "signingKey": signing_key 262 })) 263 .send() ··· 273 .json(&json!({ 274 "handle": handle2, 275 "email": format!("{}@example.com", handle2), 276 - "password": "password", 277 "signingKey": signing_key 278 })) 279 .send() ··· 310 .json(&json!({ 311 "handle": handle, 312 "email": format!("{}@example.com", handle), 313 - "password": "password", 314 "signingKey": signing_key 315 })) 316 .send()
··· 183 .json(&json!({ 184 "handle": handle, 185 "email": format!("{}@example.com", handle), 186 + "password": "Testpass123!", 187 "signingKey": signing_key 188 })) 189 .send() ··· 221 .json(&json!({ 222 "handle": handle, 223 "email": format!("{}@example.com", handle), 224 + "password": "Testpass123!", 225 "signingKey": "did:key:zNonExistentKey12345" 226 })) 227 .send() ··· 257 .json(&json!({ 258 "handle": handle1, 259 "email": format!("{}@example.com", handle1), 260 + "password": "Testpass123!", 261 "signingKey": signing_key 262 })) 263 .send() ··· 273 .json(&json!({ 274 "handle": handle2, 275 "email": format!("{}@example.com", handle2), 276 + "password": "Testpass123!", 277 "signingKey": signing_key 278 })) 279 .send() ··· 310 .json(&json!({ 311 "handle": handle, 312 "email": format!("{}@example.com", handle), 313 + "password": "Testpass123!", 314 "signingKey": signing_key 315 })) 316 .send()