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

fix: ability to send more verifications #28

merged opened by lewis.moe targeting main from fix/sending-verification

This change is that our login page and stuff can send a new verification notification if the user isn't verified yet, rather than being locked out forever. And some touchups to oauth consent/flow in general. And also some leftover toml config changes required to get tests working.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mfkkrxsgjc22
+651 -216
Diff #0
+58
.sqlx/query-85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE 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": "preferred_comms_channel: CommsChannel", 19 + "type_info": { 20 + "Custom": { 21 + "name": "comms_channel", 22 + "kind": { 23 + "Enum": [ 24 + "email", 25 + "discord", 26 + "telegram", 27 + "signal" 28 + ] 29 + } 30 + } 31 + } 32 + }, 33 + { 34 + "ordinal": 3, 35 + "name": "recovery_token", 36 + "type_info": "Text" 37 + }, 38 + { 39 + "ordinal": 4, 40 + "name": "recovery_token_expires_at", 41 + "type_info": "Timestamptz" 42 + } 43 + ], 44 + "parameters": { 45 + "Left": [ 46 + "Text" 47 + ] 48 + }, 49 + "nullable": [ 50 + false, 51 + false, 52 + false, 53 + true, 54 + true 55 + ] 56 + }, 57 + "hash": "85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea" 58 + }
-40
.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE 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": "recovery_token", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "recovery_token_expires_at", 24 - "type_info": "Timestamptz" 25 - } 26 - ], 27 - "parameters": { 28 - "Left": [ 29 - "Text" 30 - ] 31 - }, 32 - "nullable": [ 33 - false, 34 - false, 35 - true, 36 - true 37 - ] 38 - }, 39 - "hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848" 40 - }
+15
.sqlx/query-bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE oauth_authorization_request\n SET expires_at = $2\n WHERE id = $1 AND did IS NOT NULL AND code IS NULL\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Timestamptz" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf" 15 + }
-28
.sqlx/query-e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "password_reset_code_expires_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - true 25 - ] 26 - }, 27 - "hash": "e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6" 28 - }
+52
.sqlx/query-eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $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": "preferred_comms_channel: CommsChannel", 19 + "type_info": { 20 + "Custom": { 21 + "name": "comms_channel", 22 + "kind": { 23 + "Enum": [ 24 + "email", 25 + "discord", 26 + "telegram", 27 + "signal" 28 + ] 29 + } 30 + } 31 + } 32 + }, 33 + { 34 + "ordinal": 3, 35 + "name": "password_reset_code_expires_at", 36 + "type_info": "Timestamptz" 37 + } 38 + ], 39 + "parameters": { 40 + "Left": [ 41 + "Text" 42 + ] 43 + }, 44 + "nullable": [ 45 + false, 46 + false, 47 + false, 48 + true 49 + ] 50 + }, 51 + "hash": "eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b" 52 + }
+14 -7
crates/tranquil-auth/src/types.rs
··· 265 265 266 266 #[test] 267 267 fn token_type_accepts_bluesky_uppercase_jwt() { 268 - let result: Result<Header, _> = 269 - serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#); 268 + let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#); 270 269 let header = result.expect("should parse uppercase JWT from bluesky reference pds"); 271 270 assert_eq!(header.typ, TokenType::Service); 272 271 assert_eq!(header.alg, SigningAlgorithm::ES256K); ··· 274 273 275 274 #[test] 276 275 fn token_type_accepts_lowercase_jwt() { 277 - let result: Result<Header, _> = 278 - serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#); 276 + let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#); 279 277 let header = result.expect("should parse lowercase jwt"); 280 278 assert_eq!(header.typ, TokenType::Service); 281 279 } ··· 294 292 295 293 #[test] 296 294 fn signing_algorithm_case_insensitive() { 297 - assert_eq!(SigningAlgorithm::from_str("ES256K").unwrap(), SigningAlgorithm::ES256K); 298 - assert_eq!(SigningAlgorithm::from_str("es256k").unwrap(), SigningAlgorithm::ES256K); 299 - assert_eq!(SigningAlgorithm::from_str("hs256").unwrap(), SigningAlgorithm::HS256); 295 + assert_eq!( 296 + SigningAlgorithm::from_str("ES256K").unwrap(), 297 + SigningAlgorithm::ES256K 298 + ); 299 + assert_eq!( 300 + SigningAlgorithm::from_str("es256k").unwrap(), 301 + SigningAlgorithm::ES256K 302 + ); 303 + assert_eq!( 304 + SigningAlgorithm::from_str("hs256").unwrap(), 305 + SigningAlgorithm::HS256 306 + ); 300 307 } 301 308 }
+29
crates/tranquil-config/src/lib.rs
··· 44 44 CONFIG.get() 45 45 } 46 46 47 + /// Initialize with minimal defaults for unit tests. 48 + /// Noop if already initialized. 49 + pub fn ensure_test_defaults() { 50 + use std::env; 51 + let _ = CONFIG.get_or_init(|| { 52 + unsafe { 53 + if env::var("PDS_HOSTNAME").is_err() { 54 + env::set_var("PDS_HOSTNAME", "test.local"); 55 + } 56 + if env::var("DATABASE_URL").is_err() { 57 + env::set_var("DATABASE_URL", "postgres://localhost/test"); 58 + } 59 + if env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() { 60 + env::set_var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"); 61 + } 62 + if env::var("INVITE_CODE_REQUIRED").is_err() { 63 + env::set_var("INVITE_CODE_REQUIRED", "false"); 64 + } 65 + if env::var("ENABLE_PDS_HOSTED_DID_WEB").is_err() { 66 + env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true"); 67 + } 68 + } 69 + TranquilConfig::builder() 70 + .env() 71 + .load() 72 + .expect("failed to load test config defaults") 73 + }); 74 + } 75 + 47 76 /// Load configuration from an optional TOML file path, with environment 48 77 /// variable overrides applied on top. Fields annotated with `#[config(env)]` 49 78 /// are read from the corresponding environment variables when the `.env()`
+5
crates/tranquil-db-traits/src/oauth.rs
··· 192 192 ) -> Result<Option<RequestData>, DbError>; 193 193 async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError>; 194 194 async fn delete_expired_authorization_requests(&self) -> Result<u64, DbError>; 195 + async fn extend_authorization_request_expiry( 196 + &self, 197 + request_id: &RequestId, 198 + new_expires_at: DateTime<Utc>, 199 + ) -> Result<bool, DbError>; 195 200 async fn mark_request_authenticated( 196 201 &self, 197 202 request_id: &RequestId,
+3
crates/tranquil-db-traits/src/user.rs
··· 886 886 #[derive(Debug, Clone)] 887 887 pub struct UserResetCodeInfo { 888 888 pub id: Uuid, 889 + pub did: Did, 890 + pub preferred_comms_channel: CommsChannel, 889 891 pub expires_at: Option<DateTime<Utc>>, 890 892 } 891 893 ··· 956 958 pub struct UserForRecovery { 957 959 pub id: Uuid, 958 960 pub did: Did, 961 + pub preferred_comms_channel: CommsChannel, 959 962 pub recovery_token: Option<String>, 960 963 pub recovery_token_expires_at: Option<DateTime<Utc>>, 961 964 }
+20
crates/tranquil-db/src/postgres/oauth.rs
··· 615 615 Ok(result.rows_affected()) 616 616 } 617 617 618 + async fn extend_authorization_request_expiry( 619 + &self, 620 + request_id: &RequestId, 621 + new_expires_at: DateTime<Utc>, 622 + ) -> Result<bool, DbError> { 623 + let result = sqlx::query!( 624 + r#" 625 + UPDATE oauth_authorization_request 626 + SET expires_at = $2 627 + WHERE id = $1 AND did IS NOT NULL AND code IS NULL 628 + "#, 629 + request_id.as_str(), 630 + new_expires_at 631 + ) 632 + .execute(&self.pool) 633 + .await 634 + .map_err(map_sqlx_error)?; 635 + Ok(result.rows_affected() > 0) 636 + } 637 + 618 638 async fn mark_request_authenticated( 619 639 &self, 620 640 request_id: &RequestId,
+5 -2
crates/tranquil-db/src/postgres/user.rs
··· 1715 1715 code: &str, 1716 1716 ) -> Result<Option<UserResetCodeInfo>, DbError> { 1717 1717 sqlx::query!( 1718 - "SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", 1718 + "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1", 1719 1719 code 1720 1720 ) 1721 1721 .fetch_optional(&self.pool) ··· 1724 1724 .map(|opt| { 1725 1725 opt.map(|row| UserResetCodeInfo { 1726 1726 id: row.id, 1727 + did: Did::from(row.did), 1728 + preferred_comms_channel: row.preferred_comms_channel, 1727 1729 expires_at: row.password_reset_code_expires_at, 1728 1730 }) 1729 1731 }) ··· 2202 2204 2203 2205 async fn get_user_for_recovery(&self, did: &Did) -> Result<Option<UserForRecovery>, DbError> { 2204 2206 let row = sqlx::query!( 2205 - "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", 2207 + "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1", 2206 2208 did.as_str() 2207 2209 ) 2208 2210 .fetch_optional(&self.pool) ··· 2212 2214 Ok(row.map(|r| UserForRecovery { 2213 2215 id: r.id, 2214 2216 did: Did::from(r.did), 2217 + preferred_comms_channel: r.preferred_comms_channel, 2215 2218 recovery_token: r.recovery_token, 2216 2219 recovery_token_expires_at: r.recovery_token_expires_at, 2217 2220 }))
+1 -1
crates/tranquil-pds/src/api/proxy_client.rs
··· 63 63 let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?; 64 64 let scheme = parsed.scheme(); 65 65 if scheme != "https" { 66 - let allow_http = tranquil_config::get().server.allow_http_proxy 66 + let allow_http = tranquil_config::try_get().is_some_and(|c| c.server.allow_http_proxy) 67 67 || url.starts_with("http://127.0.0.1") 68 68 || url.starts_with("http://localhost"); 69 69 if !allow_http {
+8 -1
crates/tranquil-pds/src/api/repo/import.rs
··· 100 100 commit_did, did 101 101 ))); 102 102 } 103 - let skip_verification = tranquil_config::get().import.skip_verification; 103 + let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION") 104 + .ok() 105 + .map(|v| v == "true" || v == "1") 106 + .unwrap_or_else(|| { 107 + tranquil_config::try_get() 108 + .map(|c| c.import.skip_verification) 109 + .unwrap_or(false) 110 + }); 104 111 let is_migration = user.deactivated_at.is_some(); 105 112 if skip_verification { 106 113 warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
+3 -3
crates/tranquil-pds/src/api/server/mod.rs
··· 50 50 }; 51 51 pub use service_auth::get_service_auth; 52 52 pub use session::{ 53 - confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session, 54 - list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session, 55 - update_legacy_login_preference, update_locale, 53 + auto_resend_verification, confirm_signup, create_session, delete_session, 54 + get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification, 55 + revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale, 56 56 }; 57 57 pub use signing_key::reserve_signing_key; 58 58 pub use totp::{
+14
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 946 946 if result.passkeys_deleted > 0 { 947 947 info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery"); 948 948 } 949 + if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user.id).await { 950 + let actual_channel = 951 + crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); 952 + if let Err(e) = state 953 + .user_repo 954 + .set_channel_verified(&input.did, actual_channel) 955 + .await 956 + { 957 + warn!( 958 + "Failed to implicitly verify channel on passkey recovery: {:?}", 959 + e 960 + ); 961 + } 962 + } 949 963 info!(did = %input.did, "Passkey-only account recovered with temporary password"); 950 964 SuccessResponse::ok().into_response() 951 965 }
+14
crates/tranquil-pds/src/api/server/password.rs
··· 182 182 } 183 183 })) 184 184 .await; 185 + if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user_id).await { 186 + let actual_channel = 187 + crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); 188 + if let Err(e) = state 189 + .user_repo 190 + .set_channel_verified(&user.did, actual_channel) 191 + .await 192 + { 193 + warn!( 194 + "Failed to implicitly verify channel on password reset: {:?}", 195 + e 196 + ); 197 + } 198 + } 185 199 info!("Password reset completed for user {}", user_id); 186 200 EmptyResponse::ok().into_response() 187 201 }
+86 -2
crates/tranquil-pds/src/api/server/session.rs
··· 149 149 .unwrap_or(false); 150 150 if !is_verified && !is_delegated { 151 151 warn!("Login attempt for unverified account: {}", row.did); 152 + let resend_info = auto_resend_verification(&state, &row.did).await; 153 + let handle = resend_info 154 + .as_ref() 155 + .map(|r| r.handle.to_string()) 156 + .unwrap_or_else(|| row.handle.to_string()); 157 + let channel = resend_info 158 + .as_ref() 159 + .map(|r| r.channel.as_str()) 160 + .unwrap_or(row.preferred_comms_channel.as_str()); 152 161 return ( 153 162 StatusCode::FORBIDDEN, 154 163 Json(json!({ 155 - "error": "AccountNotVerified", 164 + "error": "account_not_verified", 156 165 "message": "Please verify your account before logging in", 157 - "did": row.did 166 + "did": row.did, 167 + "handle": handle, 168 + "channel": channel 158 169 })), 159 170 ) 160 171 .into_response(); ··· 730 741 .into_response() 731 742 } 732 743 744 + const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120); 745 + 746 + pub struct AutoResendResult { 747 + pub handle: tranquil_types::Handle, 748 + pub channel: tranquil_db_traits::CommsChannel, 749 + } 750 + 751 + pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<AutoResendResult> { 752 + let debounce_key = crate::cache_keys::auto_verify_sent_key(did.as_str()); 753 + let debounced = state.cache.get(&debounce_key).await.is_some(); 754 + let row = match state.user_repo.get_resend_verification_by_did(did).await { 755 + Ok(Some(row)) => row, 756 + Ok(None) => return None, 757 + Err(e) => { 758 + warn!( 759 + "Failed to fetch resend verification info for {}: {:?}", 760 + did, e 761 + ); 762 + return None; 763 + } 764 + }; 765 + if row.channel_verification.has_any_verified() { 766 + return None; 767 + } 768 + let result = AutoResendResult { 769 + handle: row.handle.clone(), 770 + channel: row.channel, 771 + }; 772 + let is_bot_channel = matches!( 773 + row.channel, 774 + tranquil_db_traits::CommsChannel::Telegram | tranquil_db_traits::CommsChannel::Discord 775 + ); 776 + if is_bot_channel || debounced { 777 + return Some(result); 778 + } 779 + let recipient = match row.channel { 780 + tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(), 781 + tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(), 782 + _ => return Some(result), 783 + }; 784 + if recipient.is_empty() { 785 + warn!( 786 + "No recipient configured for auto-resend verification: {}", 787 + did 788 + ); 789 + return Some(result); 790 + } 791 + let verification_token = 792 + crate::auth::verification_token::generate_signup_token(did, row.channel, &recipient); 793 + let formatted_token = 794 + crate::auth::verification_token::format_token_for_display(&verification_token); 795 + let hostname = &tranquil_config::get().server.hostname; 796 + if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( 797 + state.user_repo.as_ref(), 798 + state.infra_repo.as_ref(), 799 + row.id, 800 + row.channel, 801 + &recipient, 802 + &formatted_token, 803 + hostname, 804 + ) 805 + .await 806 + { 807 + warn!("Failed to auto-resend verification for {}: {:?}", did, e); 808 + return Some(result); 809 + } 810 + let _ = state 811 + .cache 812 + .set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE) 813 + .await; 814 + Some(result) 815 + } 816 + 733 817 #[derive(Deserialize)] 734 818 #[serde(rename_all = "camelCase")] 735 819 pub struct ResendVerificationInput {
+13
crates/tranquil-pds/src/auth/verification_token.rs
··· 321 321 mod tests { 322 322 use super::*; 323 323 324 + fn init() { 325 + tranquil_config::ensure_test_defaults(); 326 + } 327 + 324 328 #[test] 325 329 fn test_signup_token() { 330 + init(); 326 331 let did: Did = "did:plc:test123".parse().unwrap(); 327 332 let channel = CommsChannel::Email; 328 333 let identifier = "test@example.com"; ··· 337 342 338 343 #[test] 339 344 fn test_migration_token() { 345 + init(); 340 346 let did: Did = "did:plc:test123".parse().unwrap(); 341 347 let email = "test@example.com"; 342 348 let token = generate_migration_token(&did, email); ··· 349 355 350 356 #[test] 351 357 fn test_token_case_insensitive() { 358 + init(); 352 359 let did: Did = "did:plc:test123".parse().unwrap(); 353 360 let token = generate_signup_token(&did, CommsChannel::Email, "Test@Example.COM"); 354 361 let result = verify_signup_token(&token, CommsChannel::Email, "test@example.com"); ··· 357 364 358 365 #[test] 359 366 fn test_token_wrong_identifier() { 367 + init(); 360 368 let did: Did = "did:plc:test123".parse().unwrap(); 361 369 let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com"); 362 370 let result = verify_signup_token(&token, CommsChannel::Email, "other@example.com"); ··· 365 373 366 374 #[test] 367 375 fn test_token_wrong_channel() { 376 + init(); 368 377 let did: Did = "did:plc:test123".parse().unwrap(); 369 378 let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com"); 370 379 let result = verify_signup_token(&token, CommsChannel::Discord, "test@example.com"); ··· 373 382 374 383 #[test] 375 384 fn test_expired_token() { 385 + init(); 376 386 let did: Did = "did:plc:test123".parse().unwrap(); 377 387 let token = generate_token_with_expiry( 378 388 &did, ··· 388 398 389 399 #[test] 390 400 fn test_invalid_token() { 401 + init(); 391 402 let result = verify_signup_token("invalid-token", CommsChannel::Email, "test@example.com"); 392 403 assert!(matches!(result, Err(VerifyError::InvalidFormat))); 393 404 } 394 405 395 406 #[test] 396 407 fn test_purpose_mismatch() { 408 + init(); 397 409 let did: Did = "did:plc:test123".parse().unwrap(); 398 410 let email = "test@example.com"; 399 411 let signup_token = generate_signup_token(&did, CommsChannel::Email, email); ··· 403 415 404 416 #[test] 405 417 fn test_discord_channel() { 418 + init(); 406 419 let did: Did = "did:plc:test123".parse().unwrap(); 407 420 let discord_id = "123456789012345678"; 408 421 let token = generate_signup_token(&did, CommsChannel::Discord, discord_id);
+4
crates/tranquil-pds/src/cache_keys.rs
··· 33 33 pub fn scope_ref_key(cid: &str) -> String { 34 34 format!("scope_ref:{}", cid) 35 35 } 36 + 37 + pub fn auto_verify_sent_key(did: &str) -> String { 38 + format!("auto_verify_sent:{}", did) 39 + }
+1 -1
crates/tranquil-pds/src/comms/mod.rs
··· 7 7 mime_encode_header, sanitize_header_value, validate_locale, 8 8 }; 9 9 10 - pub use service::{CommsService, repo as comms_repo}; 10 + pub use service::{CommsService, repo as comms_repo, resolve_delivery_channel};
+7
crates/tranquil-pds/src/comms/service.rs
··· 169 169 recipient: String, 170 170 } 171 171 172 + pub fn resolve_delivery_channel( 173 + prefs: &UserCommsPrefs, 174 + channel: tranquil_db_traits::CommsChannel, 175 + ) -> tranquil_db_traits::CommsChannel { 176 + resolve_recipient(prefs, channel).channel 177 + } 178 + 172 179 fn resolve_recipient( 173 180 prefs: &UserCommsPrefs, 174 181 channel: tranquil_db_traits::CommsChannel,
+4 -2
crates/tranquil-pds/src/handle/mod.rs
··· 87 87 } 88 88 } 89 89 90 - pub fn is_service_domain_handle(handle: &str, _hostname: &str) -> bool { 90 + pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool { 91 91 if !handle.contains('.') { 92 92 return true; 93 93 } 94 - let service_domains = tranquil_config::get().server.user_handle_domain_list(); 94 + let service_domains = tranquil_config::try_get() 95 + .map(|c| c.server.user_handle_domain_list()) 96 + .unwrap_or_else(|| vec![hostname.to_string()]); 95 97 service_domains 96 98 .iter() 97 99 .any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain)
+1
crates/tranquil-pds/src/lib.rs
··· 589 589 ) 590 590 .route("/authorize/consent", get(oauth::endpoints::consent_get)) 591 591 .route("/authorize/consent", post(oauth::endpoints::consent_post)) 592 + .route("/authorize/renew", post(oauth::endpoints::authorize_renew)) 592 593 .route( 593 594 "/authorize/redirect", 594 595 get(oauth::endpoints::authorize_redirect),
+5 -1
crates/tranquil-pds/src/moderation/mod.rs
··· 34 34 } 35 35 36 36 fn get_extra_banned_words() -> &'static Vec<String> { 37 - EXTRA_BANNED_WORDS.get_or_init(|| tranquil_config::get().server.banned_word_list()) 37 + EXTRA_BANNED_WORDS.get_or_init(|| { 38 + tranquil_config::try_get() 39 + .map(|c| c.server.banned_word_list()) 40 + .unwrap_or_default() 41 + }) 38 42 } 39 43 40 44 fn strip_trailing_digits(s: &str) -> &str {
+151 -86
crates/tranquil-pds/src/oauth/endpoints/authorize.rs
··· 28 28 use urlencoding::encode as url_encode; 29 29 30 30 const DEVICE_COOKIE_NAME: &str = "oauth_device_id"; 31 + const RENEW_EXPIRY_SECONDS: i64 = 600; 32 + const MAX_RENEWAL_STALENESS_SECONDS: i64 = 3600; 31 33 32 34 fn redirect_see_other(uri: &str) -> Response { 33 35 ( ··· 556 558 if user.takedown_ref.is_some() { 557 559 return show_login_error("This account has been taken down.", json_response); 558 560 } 559 - let is_verified = user.channel_verification.has_any_verified(); 560 - if !is_verified { 561 - return show_login_error( 562 - "Please verify your account before logging in.", 563 - json_response, 564 - ); 565 - } 566 - 567 561 if user.account_type.is_delegated() { 568 562 if state 569 563 .oauth_repo ··· 630 624 if !password_valid { 631 625 return show_login_error("Invalid handle/email or password.", json_response); 632 626 } 627 + let is_verified = user.channel_verification.has_any_verified(); 628 + if !is_verified { 629 + let resend_info = crate::api::server::auto_resend_verification(&state, &user.did).await; 630 + let handle = resend_info 631 + .as_ref() 632 + .map(|r| r.handle.to_string()) 633 + .unwrap_or_else(|| form.username.clone()); 634 + let channel = resend_info 635 + .map(|r| r.channel.as_str().to_owned()) 636 + .unwrap_or_else(|| user.preferred_comms_channel.as_str().to_owned()); 637 + if json_response { 638 + return ( 639 + axum::http::StatusCode::FORBIDDEN, 640 + Json(serde_json::json!({ 641 + "error": "account_not_verified", 642 + "error_description": "Please verify your account before logging in.", 643 + "did": user.did, 644 + "handle": handle, 645 + "channel": channel 646 + })), 647 + ) 648 + .into_response(); 649 + } 650 + return redirect_see_other(&format!( 651 + "/app/oauth/login?request_uri={}&error={}", 652 + url_encode(&form.request_uri), 653 + url_encode("account_not_verified") 654 + )); 655 + } 633 656 let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await; 634 657 if has_totp { 635 658 let device_cookie = extract_device_cookie(&headers); ··· 955 978 }; 956 979 let is_verified = user.channel_verification.has_any_verified(); 957 980 if !is_verified { 958 - return json_error( 981 + let resend_info = crate::api::server::auto_resend_verification(&state, &did).await; 982 + return ( 959 983 StatusCode::FORBIDDEN, 960 - "access_denied", 961 - "Please verify your account before logging in.", 962 - ); 984 + Json(serde_json::json!({ 985 + "error": "account_not_verified", 986 + "error_description": "Please verify your account before logging in.", 987 + "did": did, 988 + "handle": resend_info.as_ref().map(|r| r.handle.to_string()), 989 + "channel": resend_info.as_ref().map(|r| r.channel.as_str()) 990 + })), 991 + ) 992 + .into_response(); 963 993 } 964 994 let has_totp = crate::api::server::has_totp_enabled(&state, &did).await; 965 995 let select_early_device_typed = device_id.clone(); ··· 970 1000 if !device_is_trusted { 971 1001 if state 972 1002 .oauth_repo 973 - .set_authorization_did( 974 - &select_request_id, 975 - &did, 976 - Some(&select_early_device_typed), 977 - ) 1003 + .set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed)) 978 1004 .await 979 1005 .is_err() 980 1006 { ··· 989 1015 })) 990 1016 .into_response(); 991 1017 } 992 - let _ = crate::api::server::extend_device_trust(state.oauth_repo.as_ref(), &device_id) 993 - .await; 1018 + let _ = 1019 + crate::api::server::extend_device_trust(state.oauth_repo.as_ref(), &device_id).await; 994 1020 } 995 1021 if user.two_factor_enabled { 996 1022 let _ = state ··· 1041 1067 .upsert_account_device(&did, &select_device_typed) 1042 1068 .await; 1043 1069 1044 - let requested_scope_str = request_data 1045 - .parameters 1046 - .scope 1047 - .as_deref() 1048 - .unwrap_or("atproto"); 1049 - let requested_scopes: Vec<String> = requested_scope_str 1050 - .split_whitespace() 1051 - .map(|s| s.to_string()) 1052 - .collect(); 1053 - let client_id_typed = ClientId::from(request_data.parameters.client_id.clone()); 1054 - let needs_consent = should_show_consent( 1055 - state.oauth_repo.as_ref(), 1056 - &did, 1057 - &client_id_typed, 1058 - &requested_scopes, 1059 - ) 1060 - .await 1061 - .unwrap_or(true); 1062 - 1063 - if needs_consent { 1064 - if state 1065 - .oauth_repo 1066 - .set_authorization_did(&select_request_id, &did, Some(&select_device_typed)) 1067 - .await 1068 - .is_err() 1069 - { 1070 - return json_error( 1071 - StatusCode::INTERNAL_SERVER_ERROR, 1072 - "server_error", 1073 - "An error occurred. Please try again.", 1074 - ); 1075 - } 1076 - let consent_url = format!( 1077 - "/app/oauth/consent?request_uri={}", 1078 - url_encode(&form.request_uri) 1079 - ); 1080 - return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); 1081 - } 1082 - 1083 - let code = Code::generate(); 1084 - let select_code = AuthorizationCode::from(code.0.clone()); 1085 1070 if state 1086 1071 .oauth_repo 1087 - .update_authorization_request( 1088 - &select_request_id, 1089 - &did, 1090 - Some(&select_device_typed), 1091 - &select_code, 1092 - ) 1072 + .set_authorization_did(&select_request_id, &did, Some(&select_device_typed)) 1093 1073 .await 1094 1074 .is_err() 1095 1075 { ··· 1099 1079 "An error occurred. Please try again.", 1100 1080 ); 1101 1081 } 1102 - let redirect_url = build_intermediate_redirect_url( 1103 - &request_data.parameters.redirect_uri, 1104 - &code.0, 1105 - request_data.parameters.state.as_deref(), 1106 - request_data.parameters.response_mode.map(|m| m.as_str()), 1082 + let consent_url = format!( 1083 + "/app/oauth/consent?request_uri={}", 1084 + url_encode(&form.request_uri) 1107 1085 ); 1108 - Json(serde_json::json!({ 1109 - "redirect_uri": redirect_url 1110 - })) 1111 - .into_response() 1086 + Json(serde_json::json!({"redirect_uri": consent_url})).into_response() 1112 1087 } 1113 1088 1114 1089 fn build_success_redirect( ··· 1401 1376 } 1402 1377 }, 1403 1378 Err(_) => { 1404 - let _ = state 1405 - .oauth_repo 1406 - .delete_authorization_request(&consent_request_id) 1407 - .await; 1408 1379 return json_error( 1409 1380 StatusCode::BAD_REQUEST, 1410 - "invalid_request", 1381 + "expired_request", 1411 1382 "Authorization request has expired", 1412 1383 ); 1413 1384 } ··· 1758 1729 Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response() 1759 1730 } 1760 1731 1732 + #[derive(Debug, Deserialize)] 1733 + pub struct RenewRequest { 1734 + pub request_uri: String, 1735 + } 1736 + 1737 + pub async fn authorize_renew( 1738 + State(state): State<AppState>, 1739 + _rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>, 1740 + Json(form): Json<RenewRequest>, 1741 + ) -> Response { 1742 + let request_id = RequestId::from(form.request_uri.clone()); 1743 + let request_data = match state 1744 + .oauth_repo 1745 + .get_authorization_request(&request_id) 1746 + .await 1747 + { 1748 + Ok(Some(data)) => data, 1749 + Ok(None) => { 1750 + return json_error( 1751 + StatusCode::BAD_REQUEST, 1752 + "invalid_request", 1753 + "Unknown authorization request", 1754 + ); 1755 + } 1756 + Err(_) => { 1757 + return json_error( 1758 + StatusCode::INTERNAL_SERVER_ERROR, 1759 + "server_error", 1760 + "Database error", 1761 + ); 1762 + } 1763 + }; 1764 + 1765 + if request_data.did.is_none() { 1766 + return json_error( 1767 + StatusCode::BAD_REQUEST, 1768 + "invalid_request", 1769 + "Authorization request not yet authenticated", 1770 + ); 1771 + } 1772 + 1773 + let now = Utc::now(); 1774 + if request_data.expires_at >= now { 1775 + return Json(serde_json::json!({ 1776 + "request_uri": form.request_uri, 1777 + "renewed": false 1778 + })) 1779 + .into_response(); 1780 + } 1781 + 1782 + let staleness = now - request_data.expires_at; 1783 + if staleness.num_seconds() > MAX_RENEWAL_STALENESS_SECONDS { 1784 + let _ = state 1785 + .oauth_repo 1786 + .delete_authorization_request(&request_id) 1787 + .await; 1788 + return json_error( 1789 + StatusCode::BAD_REQUEST, 1790 + "invalid_request", 1791 + "Authorization request expired too long ago to renew", 1792 + ); 1793 + } 1794 + 1795 + let new_expires_at = now + chrono::Duration::seconds(RENEW_EXPIRY_SECONDS); 1796 + match state 1797 + .oauth_repo 1798 + .extend_authorization_request_expiry(&request_id, new_expires_at) 1799 + .await 1800 + { 1801 + Ok(true) => Json(serde_json::json!({ 1802 + "request_uri": form.request_uri, 1803 + "renewed": true 1804 + })) 1805 + .into_response(), 1806 + Ok(false) => json_error( 1807 + StatusCode::BAD_REQUEST, 1808 + "invalid_request", 1809 + "Authorization request could not be renewed", 1810 + ), 1811 + Err(_) => json_error( 1812 + StatusCode::INTERNAL_SERVER_ERROR, 1813 + "server_error", 1814 + "Database error", 1815 + ), 1816 + } 1817 + } 1818 + 1761 1819 pub async fn authorize_2fa_post( 1762 1820 State(state): State<AppState>, 1763 1821 _rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>, ··· 1953 2011 .oauth_repo 1954 2012 .upsert_account_device(&did, &trust_device_id) 1955 2013 .await; 1956 - let _ = crate::api::server::trust_device(state.oauth_repo.as_ref(), &trust_device_id) 1957 - .await; 2014 + let _ = crate::api::server::trust_device(state.oauth_repo.as_ref(), &trust_device_id).await; 1958 2015 } 1959 2016 let requested_scope_str = request_data 1960 2017 .parameters ··· 2240 2297 let is_verified = user.channel_verification.has_any_verified(); 2241 2298 2242 2299 if !is_verified { 2300 + let resend_info = crate::api::server::auto_resend_verification(&state, &user.did).await; 2243 2301 return ( 2244 2302 StatusCode::FORBIDDEN, 2245 2303 Json(serde_json::json!({ 2246 - "error": "access_denied", 2247 - "error_description": "Please verify your account before logging in." 2304 + "error": "account_not_verified", 2305 + "error_description": "Please verify your account before logging in.", 2306 + "did": user.did, 2307 + "handle": resend_info.as_ref().map(|r| r.handle.to_string()), 2308 + "channel": resend_info.as_ref().map(|r| r.channel.as_str()) 2248 2309 })), 2249 2310 ) 2250 2311 .into_response(); ··· 3389 3450 }; 3390 3451 3391 3452 if !is_verified { 3453 + let resend_info = crate::api::server::auto_resend_verification(&state, &did).await; 3392 3454 return ( 3393 3455 StatusCode::FORBIDDEN, 3394 3456 Json(serde_json::json!({ 3395 - "error": "access_denied", 3396 - "error_description": "Please verify your account before continuing." 3457 + "error": "account_not_verified", 3458 + "error_description": "Please verify your account before continuing.", 3459 + "did": did, 3460 + "handle": resend_info.as_ref().map(|r| r.handle.to_string()), 3461 + "channel": resend_info.as_ref().map(|r| r.channel.as_str()) 3397 3462 })), 3398 3463 ) 3399 3464 .into_response();
+9 -4
crates/tranquil-pds/src/plc/mod.rs
··· 124 124 } 125 125 126 126 pub fn with_cache(base_url: Option<String>, cache: Option<Arc<dyn Cache>>) -> Self { 127 - let cfg = tranquil_config::get(); 128 - let base_url = base_url.unwrap_or_else(|| cfg.plc.directory_url.clone()); 129 - let timeout_secs = cfg.plc.timeout_secs; 130 - let connect_timeout_secs = cfg.plc.connect_timeout_secs; 127 + let cfg = tranquil_config::try_get(); 128 + let base_url = base_url 129 + .or_else(|| std::env::var("PLC_DIRECTORY_URL").ok()) 130 + .unwrap_or_else(|| { 131 + cfg.map(|c| c.plc.directory_url.clone()) 132 + .unwrap_or_else(|| "https://plc.directory".to_string()) 133 + }); 134 + let timeout_secs = cfg.map_or(10, |c| c.plc.timeout_secs); 135 + let connect_timeout_secs = cfg.map_or(5, |c| c.plc.connect_timeout_secs); 131 136 let client = Client::builder() 132 137 .timeout(Duration::from_secs(timeout_secs)) 133 138 .connect_timeout(Duration::from_secs(connect_timeout_secs))
+3 -3
crates/tranquil-pds/src/state.rs
··· 1 1 use crate::appview::DidResolver; 2 2 use crate::auth::webauthn::WebAuthnConfig; 3 - use crate::cache::{create_cache, Cache, DistributedRateLimiter}; 3 + use crate::cache::{Cache, DistributedRateLimiter, create_cache}; 4 4 use crate::circuit_breaker::CircuitBreakers; 5 5 use crate::config::AuthConfig; 6 6 use crate::rate_limit::RateLimiters; 7 7 use crate::repo::PostgresBlockStore; 8 8 use crate::repo_write_lock::RepoWriteLocks; 9 9 use crate::sso::{SsoConfig, SsoManager}; 10 - use crate::storage::{create_backup_storage, create_blob_storage, BackupStorage, BlobStorage}; 10 + use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage}; 11 11 use crate::sync::firehose::SequencedEvent; 12 12 use sqlx::PgPool; 13 13 use std::error::Error; 14 - use std::sync::atomic::{AtomicBool, Ordering}; 15 14 use std::sync::Arc; 15 + use std::sync::atomic::{AtomicBool, Ordering}; 16 16 use tokio::sync::broadcast; 17 17 use tokio_util::sync::CancellationToken; 18 18 use tranquil_db::{
+2 -1
crates/tranquil-pds/src/sync/verify.rs
··· 145 145 } 146 146 147 147 async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> { 148 - let plc_url = tranquil_config::get().plc.directory_url.clone(); 148 + let plc_url = std::env::var("PLC_DIRECTORY_URL") 149 + .unwrap_or_else(|_| tranquil_config::get().plc.directory_url.clone()); 149 150 let url = format!("{}/{}", plc_url, urlencoding::encode(did)); 150 151 let response = self 151 152 .http_client
+1
crates/tranquil-pds/src/util.rs
··· 374 374 #[test] 375 375 fn test_build_full_url_adds_xrpc_prefix_for_atproto_paths() { 376 376 unsafe { std::env::set_var("PDS_HOSTNAME", "example.com") }; 377 + tranquil_config::ensure_test_defaults(); 377 378 assert_eq!( 378 379 build_full_url("/com.atproto.server.getSession"), 379 380 "https://example.com/xrpc/com.atproto.server.getSession"
+1
crates/tranquil-pds/tests/common/mod.rs
··· 548 548 unsafe { 549 549 std::env::set_var("PDS_HOSTNAME", format!("pds.test:{}", addr.port())); 550 550 } 551 + tranquil_config::ensure_test_defaults(); 551 552 let rate_limiters = RateLimiters::new() 552 553 .with_login_limit(10000) 553 554 .with_account_creation_limit(10000)
+5 -5
crates/tranquil-pds/tests/import_with_verification.rs
··· 64 64 let mock_plc = setup_mock_plc_directory(&did, did_doc).await; 65 65 unsafe { 66 66 std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); 67 - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); 67 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); 68 68 } 69 69 let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); 70 70 let import_res = client ··· 108 108 let mock_plc = setup_mock_plc_directory(&did, did_doc).await; 109 109 unsafe { 110 110 std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); 111 - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); 111 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); 112 112 } 113 113 let (car_bytes, _root_cid) = build_car_with_signature(&did, &wrong_signing_key); 114 114 let import_res = client ··· 157 157 let mock_plc = setup_mock_plc_directory(&did, did_doc).await; 158 158 unsafe { 159 159 std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); 160 - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); 160 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); 161 161 } 162 162 let (car_bytes, _root_cid) = build_car_with_signature(wrong_did, &signing_key); 163 163 let import_res = client ··· 202 202 .await; 203 203 unsafe { 204 204 std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); 205 - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); 205 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); 206 206 } 207 207 let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); 208 208 let import_res = client ··· 248 248 let mock_plc = setup_mock_plc_directory(&did, did_doc_without_key).await; 249 249 unsafe { 250 250 std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); 251 - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); 251 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); 252 252 } 253 253 let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); 254 254 let import_res = client
+3 -3
crates/tranquil-pds/tests/plc_migration.rs
··· 698 698 .await; 699 699 unsafe { 700 700 std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri()); 701 - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); 701 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); 702 702 } 703 703 let import_res = client 704 704 .post(format!( ··· 775 775 .await; 776 776 unsafe { 777 777 std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri()); 778 - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); 778 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); 779 779 } 780 780 let import_res = client 781 781 .post(format!( ··· 931 931 .expect("Submit failed"); 932 932 assert_eq!(submit_res.status(), StatusCode::OK); 933 933 unsafe { 934 - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); 934 + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); 935 935 } 936 936 let import_res = client 937 937 .post(format!(
+12 -12
crates/tranquil-storage/src/lib.rs
··· 767 767 _ => { 768 768 let path = cfg.backup.path.clone(); 769 769 FilesystemBackupStorage::new(path).await.map_or_else( 770 - |e| { 771 - tracing::error!( 772 - "Failed to initialize filesystem backup storage: {}. \ 770 + |e| { 771 + tracing::error!( 772 + "Failed to initialize filesystem backup storage: {}. \ 773 773 Set BACKUP_STORAGE_PATH to a valid directory path. \ 774 774 Backups will be disabled.", 775 - e 776 - ); 777 - None 778 - }, 779 - |storage| { 780 - tracing::info!("Initialized filesystem backup storage"); 781 - Some(Arc::new(storage) as Arc<dyn BackupStorage>) 782 - }, 783 - ) 775 + e 776 + ); 777 + None 778 + }, 779 + |storage| { 780 + tracing::info!("Initialized filesystem backup storage"); 781 + Some(Arc::new(storage) as Arc<dyn BackupStorage>) 782 + }, 783 + ) 784 784 } 785 785 } 786 786 }
+7 -1
frontend/src/components/dashboard/SecurityContent.svelte
··· 457 457 showSetPasswordForm = false 458 458 } catch (e) { 459 459 if (e instanceof ApiError) { 460 - toast.error(e.message) 460 + if (e.error === 'ReauthRequired') { 461 + reauthMethods = e.reauthMethods || ['passkey'] 462 + pendingAction = () => handleSetPassword(new Event('submit')) 463 + showReauthModal = true 464 + } else { 465 + toast.error(e.message) 466 + } 461 467 } else { 462 468 toast.error($_('security.failedToSetPassword')) 463 469 }
+2 -1
frontend/src/locales/en.json
··· 542 542 "passkeyHintNotAvailable": "No passkey registered", 543 543 "passwordPlaceholder": "Password", 544 544 "usePasskey": "Use passkey", 545 - "orUseCredentials": "or" 545 + "orUseCredentials": "or", 546 + "verificationResent": "Verification code sent" 546 547 }, 547 548 "sso": { 548 549 "linkedAccounts": "Linked Accounts",
+2 -1
frontend/src/locales/fi.json
··· 542 542 "passkeyHintNotAvailable": "Ei pääsyavainta", 543 543 "passwordPlaceholder": "Salasana", 544 544 "usePasskey": "Käytä pääsyavainta", 545 - "orUseCredentials": "tai" 545 + "orUseCredentials": "tai", 546 + "verificationResent": "Vahvistuskoodi lähetetty" 546 547 }, 547 548 "register": { 548 549 "title": "Luo tili",
+2 -1
frontend/src/locales/ja.json
··· 542 542 "passkeyHintNotAvailable": "パスキーなし", 543 543 "passwordPlaceholder": "パスワード", 544 544 "usePasskey": "パスキーを使用", 545 - "orUseCredentials": "または" 545 + "orUseCredentials": "または", 546 + "verificationResent": "確認コードを送信しました" 546 547 }, 547 548 "register": { 548 549 "title": "アカウント作成",
+2 -1
frontend/src/locales/ko.json
··· 542 542 "passkeyHintNotAvailable": "패스키 없음", 543 543 "passwordPlaceholder": "비밀번호", 544 544 "usePasskey": "패스키 사용", 545 - "orUseCredentials": "또는" 545 + "orUseCredentials": "또는", 546 + "verificationResent": "인증 코드 전송됨" 546 547 }, 547 548 "register": { 548 549 "title": "계정 만들기",
+2 -1
frontend/src/locales/sv.json
··· 542 542 "passkeyHintNotAvailable": "Ingen nyckel registrerad", 543 543 "passwordPlaceholder": "Lösenord", 544 544 "usePasskey": "Använd nyckel", 545 - "orUseCredentials": "eller" 545 + "orUseCredentials": "eller", 546 + "verificationResent": "Verifieringskod skickad" 546 547 }, 547 548 "register": { 548 549 "title": "Skapa konto",
+2 -1
frontend/src/locales/zh.json
··· 542 542 "passkeyHintNotAvailable": "未注册通行密钥", 543 543 "passwordPlaceholder": "密码", 544 544 "usePasskey": "使用通行密钥", 545 - "orUseCredentials": "或" 545 + "orUseCredentials": "或", 546 + "verificationResent": "验证码已发送" 546 547 }, 547 548 "register": { 548 549 "title": "创建账户",
+39 -5
frontend/src/routes/OAuthConsent.svelte
··· 62 62 return params.get('request_uri') 63 63 } 64 64 65 + async function tryRenewRequest(requestUri: string): Promise<boolean> { 66 + try { 67 + const response = await fetch('/oauth/authorize/renew', { 68 + method: 'POST', 69 + headers: { 'Content-Type': 'application/json' }, 70 + body: JSON.stringify({ request_uri: requestUri }), 71 + }) 72 + if (!response.ok) return false 73 + const data = await response.json() 74 + return data.renewed === true 75 + } catch { 76 + return false 77 + } 78 + } 79 + 65 80 async function fetchConsentData() { 66 81 const requestUri = getRequestUri() 67 82 if (!requestUri) { ··· 72 87 } 73 88 74 89 try { 75 - const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) 90 + let response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) 76 91 if (!response.ok) { 77 92 const data = await response.json() 78 - console.error('[OAuthConsent] Consent fetch failed:', data) 79 - error = data.error_description || data.error || $_('oauth.error.genericError') 80 - loading = false 81 - return 93 + if (data.error === 'expired_request') { 94 + const renewed = await tryRenewRequest(requestUri) 95 + if (renewed) { 96 + response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) 97 + if (!response.ok) { 98 + const retryData = await response.json() 99 + console.error('[OAuthConsent] Consent fetch failed after renewal:', retryData) 100 + error = retryData.error_description || retryData.error || $_('oauth.error.genericError') 101 + loading = false 102 + return 103 + } 104 + } else { 105 + console.error('[OAuthConsent] Consent fetch failed:', data) 106 + error = data.error_description || data.error || $_('oauth.error.genericError') 107 + loading = false 108 + return 109 + } 110 + } else { 111 + console.error('[OAuthConsent] Consent fetch failed:', data) 112 + error = data.error_description || data.error || $_('oauth.error.genericError') 113 + loading = false 114 + return 115 + } 82 116 } 83 117 const data: ConsentData = await response.json() 84 118
+44 -2
frontend/src/routes/OAuthLogin.svelte
··· 18 18 icon: string 19 19 } 20 20 21 + const PENDING_VERIFICATION_KEY = 'tranquil_pds_pending_verification' 22 + 23 + function storePendingVerification(data: { did?: string; handle?: string; channel?: string }) { 24 + if (data.did) { 25 + localStorage.setItem(PENDING_VERIFICATION_KEY, JSON.stringify({ 26 + did: data.did, 27 + handle: data.handle ?? '', 28 + channel: data.channel ?? '', 29 + })) 30 + } 31 + } 32 + 21 33 let username = $state('') 22 34 let ssoProviders = $state<SsoProvider[]>([]) 23 35 let ssoLoading = $state<string | null>(null) ··· 25 37 let rememberDevice = $state(false) 26 38 let submitting = $state(false) 27 39 let error = $state<string | null>(null) 40 + let verificationResent = $state(false) 28 41 let hasPasskeys = $state(false) 29 42 let hasTotp = $state(false) 30 43 let hasPassword = $state(true) ··· 52 65 $effect(() => { 53 66 const urlError = getErrorFromUrl() 54 67 if (urlError) { 55 - error = urlError 68 + if (urlError === 'account_not_verified') { 69 + verificationResent = true 70 + } else { 71 + error = urlError 72 + } 56 73 } 57 74 }) 58 75 ··· 200 217 201 218 submitting = true 202 219 error = null 220 + verificationResent = false 203 221 204 222 try { 205 223 const startResponse = await fetch('/oauth/passkey/start', { ··· 216 234 217 235 if (!startResponse.ok) { 218 236 const data = await startResponse.json() 237 + if (data.error === 'account_not_verified') { 238 + verificationResent = true 239 + storePendingVerification(data) 240 + submitting = false 241 + return 242 + } 219 243 error = data.error_description || data.error || 'Failed to start passkey login' 220 244 submitting = false 221 245 return ··· 251 275 const data = await finishResponse.json() 252 276 253 277 if (!finishResponse.ok) { 278 + if (data.error === 'account_not_verified') { 279 + verificationResent = true 280 + storePendingVerification(data) 281 + submitting = false 282 + return 283 + } 254 284 error = data.error_description || data.error || 'Passkey authentication failed' 255 285 submitting = false 256 286 return ··· 294 324 295 325 submitting = true 296 326 error = null 327 + verificationResent = false 297 328 298 329 try { 299 330 const response = await fetch('/oauth/authorize', { ··· 313 344 const data = await response.json() 314 345 315 346 if (!response.ok) { 347 + if (data.error === 'account_not_verified') { 348 + verificationResent = true 349 + storePendingVerification(data) 350 + submitting = false 351 + return 352 + } 316 353 error = data.error_description || data.error || 'Login failed' 317 354 submitting = false 318 355 return ··· 354 391 {/if} 355 392 </header> 356 393 357 - {#if error} 394 + {#if verificationResent} 395 + <div class="message warning"> 396 + <p>{$_('oauth.login.verificationResent')}</p> 397 + <a href={`${getFullUrl(routes.verify)}${getRequestUri() ? `?request_uri=${encodeURIComponent(getRequestUri()!)}` : ''}`}>{$_('verify.tokenTitle')}</a> 398 + </div> 399 + {:else if error} 358 400 <div class="message error">{error}</div> 359 401 {/if} 360 402

History

1 round 0 comments
sign up or login to add to the discussion
lewis.moe submitted #0
1 commit
expand
fix: ability to send more verifications
expand 0 comments
pull request successfully merged