diff --git a/.sqlx/query-85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea.json b/.sqlx/query-85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea.json new file mode 100644 index 0000000..6901faf --- /dev/null +++ b/.sqlx/query-85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "preferred_comms_channel: CommsChannel", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "recovery_token", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "recovery_token_expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea" +} diff --git a/.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json b/.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json deleted file mode 100644 index b9a0999..0000000 --- a/.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "recovery_token", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "recovery_token_expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true, - true - ] - }, - "hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848" -} diff --git a/.sqlx/query-bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf.json b/.sqlx/query-bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf.json new file mode 100644 index 0000000..db08ed9 --- /dev/null +++ b/.sqlx/query-bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "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 ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf" +} diff --git a/.sqlx/query-e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6.json b/.sqlx/query-e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6.json deleted file mode 100644 index aaab41e..0000000 --- a/.sqlx/query-e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "password_reset_code_expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - true - ] - }, - "hash": "e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6" -} diff --git a/.sqlx/query-eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b.json b/.sqlx/query-eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b.json new file mode 100644 index 0000000..f3a278d --- /dev/null +++ b/.sqlx/query-eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "preferred_comms_channel: CommsChannel", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "password_reset_code_expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b" +} diff --git a/crates/tranquil-auth/src/types.rs b/crates/tranquil-auth/src/types.rs index a7c89bd..996fc72 100644 --- a/crates/tranquil-auth/src/types.rs +++ b/crates/tranquil-auth/src/types.rs @@ -265,8 +265,7 @@ mod tests { #[test] fn token_type_accepts_bluesky_uppercase_jwt() { - let result: Result = - serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#); + let result: Result = serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#); let header = result.expect("should parse uppercase JWT from bluesky reference pds"); assert_eq!(header.typ, TokenType::Service); assert_eq!(header.alg, SigningAlgorithm::ES256K); @@ -274,8 +273,7 @@ mod tests { #[test] fn token_type_accepts_lowercase_jwt() { - let result: Result = - serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#); + let result: Result = serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#); let header = result.expect("should parse lowercase jwt"); assert_eq!(header.typ, TokenType::Service); } @@ -294,8 +292,17 @@ mod tests { #[test] fn signing_algorithm_case_insensitive() { - assert_eq!(SigningAlgorithm::from_str("ES256K").unwrap(), SigningAlgorithm::ES256K); - assert_eq!(SigningAlgorithm::from_str("es256k").unwrap(), SigningAlgorithm::ES256K); - assert_eq!(SigningAlgorithm::from_str("hs256").unwrap(), SigningAlgorithm::HS256); + assert_eq!( + SigningAlgorithm::from_str("ES256K").unwrap(), + SigningAlgorithm::ES256K + ); + assert_eq!( + SigningAlgorithm::from_str("es256k").unwrap(), + SigningAlgorithm::ES256K + ); + assert_eq!( + SigningAlgorithm::from_str("hs256").unwrap(), + SigningAlgorithm::HS256 + ); } } diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index 5efcfbe..18a86c1 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -44,6 +44,35 @@ pub fn try_get() -> Option<&'static TranquilConfig> { CONFIG.get() } +/// Initialize with minimal defaults for unit tests. +/// Noop if already initialized. +pub fn ensure_test_defaults() { + use std::env; + let _ = CONFIG.get_or_init(|| { + unsafe { + if env::var("PDS_HOSTNAME").is_err() { + env::set_var("PDS_HOSTNAME", "test.local"); + } + if env::var("DATABASE_URL").is_err() { + env::set_var("DATABASE_URL", "postgres://localhost/test"); + } + if env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() { + env::set_var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"); + } + if env::var("INVITE_CODE_REQUIRED").is_err() { + env::set_var("INVITE_CODE_REQUIRED", "false"); + } + if env::var("ENABLE_PDS_HOSTED_DID_WEB").is_err() { + env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true"); + } + } + TranquilConfig::builder() + .env() + .load() + .expect("failed to load test config defaults") + }); +} + /// Load configuration from an optional TOML file path, with environment /// variable overrides applied on top. Fields annotated with `#[config(env)]` /// are read from the corresponding environment variables when the `.env()` diff --git a/crates/tranquil-db-traits/src/oauth.rs b/crates/tranquil-db-traits/src/oauth.rs index f24f6fc..193de83 100644 --- a/crates/tranquil-db-traits/src/oauth.rs +++ b/crates/tranquil-db-traits/src/oauth.rs @@ -192,6 +192,11 @@ pub trait OAuthRepository: Send + Sync { ) -> Result, DbError>; async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError>; async fn delete_expired_authorization_requests(&self) -> Result; + async fn extend_authorization_request_expiry( + &self, + request_id: &RequestId, + new_expires_at: DateTime, + ) -> Result; async fn mark_request_authenticated( &self, request_id: &RequestId, diff --git a/crates/tranquil-db-traits/src/user.rs b/crates/tranquil-db-traits/src/user.rs index 906f988..43071a2 100644 --- a/crates/tranquil-db-traits/src/user.rs +++ b/crates/tranquil-db-traits/src/user.rs @@ -886,6 +886,8 @@ pub struct UserResendVerification { #[derive(Debug, Clone)] pub struct UserResetCodeInfo { pub id: Uuid, + pub did: Did, + pub preferred_comms_channel: CommsChannel, pub expires_at: Option>, } @@ -956,6 +958,7 @@ pub struct UserForPasskeyRecovery { pub struct UserForRecovery { pub id: Uuid, pub did: Did, + pub preferred_comms_channel: CommsChannel, pub recovery_token: Option, pub recovery_token_expires_at: Option>, } diff --git a/crates/tranquil-db/src/postgres/oauth.rs b/crates/tranquil-db/src/postgres/oauth.rs index 75cbbdd..360e2c0 100644 --- a/crates/tranquil-db/src/postgres/oauth.rs +++ b/crates/tranquil-db/src/postgres/oauth.rs @@ -615,6 +615,26 @@ impl OAuthRepository for PostgresOAuthRepository { Ok(result.rows_affected()) } + async fn extend_authorization_request_expiry( + &self, + request_id: &RequestId, + new_expires_at: DateTime, + ) -> Result { + let result = sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET expires_at = $2 + WHERE id = $1 AND did IS NOT NULL AND code IS NULL + "#, + request_id.as_str(), + new_expires_at + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(result.rows_affected() > 0) + } + async fn mark_request_authenticated( &self, request_id: &RequestId, diff --git a/crates/tranquil-db/src/postgres/user.rs b/crates/tranquil-db/src/postgres/user.rs index 70db765..df2f466 100644 --- a/crates/tranquil-db/src/postgres/user.rs +++ b/crates/tranquil-db/src/postgres/user.rs @@ -1715,7 +1715,7 @@ impl UserRepository for PostgresUserRepository { code: &str, ) -> Result, DbError> { sqlx::query!( - "SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", + "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1", code ) .fetch_optional(&self.pool) @@ -1724,6 +1724,8 @@ impl UserRepository for PostgresUserRepository { .map(|opt| { opt.map(|row| UserResetCodeInfo { id: row.id, + did: Did::from(row.did), + preferred_comms_channel: row.preferred_comms_channel, expires_at: row.password_reset_code_expires_at, }) }) @@ -2202,7 +2204,7 @@ impl UserRepository for PostgresUserRepository { async fn get_user_for_recovery(&self, did: &Did) -> Result, DbError> { let row = sqlx::query!( - "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", + "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1", did.as_str() ) .fetch_optional(&self.pool) @@ -2212,6 +2214,7 @@ impl UserRepository for PostgresUserRepository { Ok(row.map(|r| UserForRecovery { id: r.id, did: Did::from(r.did), + preferred_comms_channel: r.preferred_comms_channel, recovery_token: r.recovery_token, recovery_token_expires_at: r.recovery_token_expires_at, })) diff --git a/crates/tranquil-pds/src/api/proxy_client.rs b/crates/tranquil-pds/src/api/proxy_client.rs index add4538..edec606 100644 --- a/crates/tranquil-pds/src/api/proxy_client.rs +++ b/crates/tranquil-pds/src/api/proxy_client.rs @@ -63,7 +63,7 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> { let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?; let scheme = parsed.scheme(); if scheme != "https" { - let allow_http = tranquil_config::get().server.allow_http_proxy + let allow_http = tranquil_config::try_get().is_some_and(|c| c.server.allow_http_proxy) || url.starts_with("http://127.0.0.1") || url.starts_with("http://localhost"); if !allow_http { diff --git a/crates/tranquil-pds/src/api/repo/import.rs b/crates/tranquil-pds/src/api/repo/import.rs index 66720c4..119d0f4 100644 --- a/crates/tranquil-pds/src/api/repo/import.rs +++ b/crates/tranquil-pds/src/api/repo/import.rs @@ -100,7 +100,14 @@ pub async fn import_repo( commit_did, did ))); } - let skip_verification = tranquil_config::get().import.skip_verification; + let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION") + .ok() + .map(|v| v == "true" || v == "1") + .unwrap_or_else(|| { + tranquil_config::try_get() + .map(|c| c.import.skip_verification) + .unwrap_or(false) + }); let is_migration = user.deactivated_at.is_some(); if skip_verification { warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)"); diff --git a/crates/tranquil-pds/src/api/server/mod.rs b/crates/tranquil-pds/src/api/server/mod.rs index 2d051aa..d3da180 100644 --- a/crates/tranquil-pds/src/api/server/mod.rs +++ b/crates/tranquil-pds/src/api/server/mod.rs @@ -50,9 +50,9 @@ pub use reauth::{ }; pub use service_auth::get_service_auth; pub use session::{ - confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session, - list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session, - update_legacy_login_preference, update_locale, + auto_resend_verification, confirm_signup, create_session, delete_session, + get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification, + revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale, }; pub use signing_key::reserve_signing_key; pub use totp::{ diff --git a/crates/tranquil-pds/src/api/server/passkey_account.rs b/crates/tranquil-pds/src/api/server/passkey_account.rs index d32634e..b06f4be 100644 --- a/crates/tranquil-pds/src/api/server/passkey_account.rs +++ b/crates/tranquil-pds/src/api/server/passkey_account.rs @@ -946,6 +946,20 @@ pub async fn recover_passkey_account( if result.passkeys_deleted > 0 { info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery"); } + if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user.id).await { + let actual_channel = + crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); + if let Err(e) = state + .user_repo + .set_channel_verified(&input.did, actual_channel) + .await + { + warn!( + "Failed to implicitly verify channel on passkey recovery: {:?}", + e + ); + } + } info!(did = %input.did, "Passkey-only account recovered with temporary password"); SuccessResponse::ok().into_response() } diff --git a/crates/tranquil-pds/src/api/server/password.rs b/crates/tranquil-pds/src/api/server/password.rs index db5612d..164de7f 100644 --- a/crates/tranquil-pds/src/api/server/password.rs +++ b/crates/tranquil-pds/src/api/server/password.rs @@ -182,6 +182,20 @@ pub async fn reset_password( } })) .await; + if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user_id).await { + let actual_channel = + crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); + if let Err(e) = state + .user_repo + .set_channel_verified(&user.did, actual_channel) + .await + { + warn!( + "Failed to implicitly verify channel on password reset: {:?}", + e + ); + } + } info!("Password reset completed for user {}", user_id); EmptyResponse::ok().into_response() } diff --git a/crates/tranquil-pds/src/api/server/session.rs b/crates/tranquil-pds/src/api/server/session.rs index 3d823b6..686196c 100644 --- a/crates/tranquil-pds/src/api/server/session.rs +++ b/crates/tranquil-pds/src/api/server/session.rs @@ -149,12 +149,23 @@ pub async fn create_session( .unwrap_or(false); if !is_verified && !is_delegated { warn!("Login attempt for unverified account: {}", row.did); + let resend_info = auto_resend_verification(&state, &row.did).await; + let handle = resend_info + .as_ref() + .map(|r| r.handle.to_string()) + .unwrap_or_else(|| row.handle.to_string()); + let channel = resend_info + .as_ref() + .map(|r| r.channel.as_str()) + .unwrap_or(row.preferred_comms_channel.as_str()); return ( StatusCode::FORBIDDEN, Json(json!({ - "error": "AccountNotVerified", + "error": "account_not_verified", "message": "Please verify your account before logging in", - "did": row.did + "did": row.did, + "handle": handle, + "channel": channel })), ) .into_response(); @@ -730,6 +741,79 @@ pub async fn confirm_signup( .into_response() } +const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120); + +pub struct AutoResendResult { + pub handle: tranquil_types::Handle, + pub channel: tranquil_db_traits::CommsChannel, +} + +pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option { + let debounce_key = crate::cache_keys::auto_verify_sent_key(did.as_str()); + let debounced = state.cache.get(&debounce_key).await.is_some(); + let row = match state.user_repo.get_resend_verification_by_did(did).await { + Ok(Some(row)) => row, + Ok(None) => return None, + Err(e) => { + warn!( + "Failed to fetch resend verification info for {}: {:?}", + did, e + ); + return None; + } + }; + if row.channel_verification.has_any_verified() { + return None; + } + let result = AutoResendResult { + handle: row.handle.clone(), + channel: row.channel, + }; + let is_bot_channel = matches!( + row.channel, + tranquil_db_traits::CommsChannel::Telegram | tranquil_db_traits::CommsChannel::Discord + ); + if is_bot_channel || debounced { + return Some(result); + } + let recipient = match row.channel { + tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(), + tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(), + _ => return Some(result), + }; + if recipient.is_empty() { + warn!( + "No recipient configured for auto-resend verification: {}", + did + ); + return Some(result); + } + let verification_token = + crate::auth::verification_token::generate_signup_token(did, row.channel, &recipient); + let formatted_token = + crate::auth::verification_token::format_token_for_display(&verification_token); + let hostname = &tranquil_config::get().server.hostname; + if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( + state.user_repo.as_ref(), + state.infra_repo.as_ref(), + row.id, + row.channel, + &recipient, + &formatted_token, + hostname, + ) + .await + { + warn!("Failed to auto-resend verification for {}: {:?}", did, e); + return Some(result); + } + let _ = state + .cache + .set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE) + .await; + Some(result) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResendVerificationInput { diff --git a/crates/tranquil-pds/src/auth/verification_token.rs b/crates/tranquil-pds/src/auth/verification_token.rs index 470ad40..ab17813 100644 --- a/crates/tranquil-pds/src/auth/verification_token.rs +++ b/crates/tranquil-pds/src/auth/verification_token.rs @@ -321,8 +321,13 @@ pub fn normalize_token_input(input: &str) -> String { mod tests { use super::*; + fn init() { + tranquil_config::ensure_test_defaults(); + } + #[test] fn test_signup_token() { + init(); let did: Did = "did:plc:test123".parse().unwrap(); let channel = CommsChannel::Email; let identifier = "test@example.com"; @@ -337,6 +342,7 @@ mod tests { #[test] fn test_migration_token() { + init(); let did: Did = "did:plc:test123".parse().unwrap(); let email = "test@example.com"; let token = generate_migration_token(&did, email); @@ -349,6 +355,7 @@ mod tests { #[test] fn test_token_case_insensitive() { + init(); let did: Did = "did:plc:test123".parse().unwrap(); let token = generate_signup_token(&did, CommsChannel::Email, "Test@Example.COM"); let result = verify_signup_token(&token, CommsChannel::Email, "test@example.com"); @@ -357,6 +364,7 @@ mod tests { #[test] fn test_token_wrong_identifier() { + init(); let did: Did = "did:plc:test123".parse().unwrap(); let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com"); let result = verify_signup_token(&token, CommsChannel::Email, "other@example.com"); @@ -365,6 +373,7 @@ mod tests { #[test] fn test_token_wrong_channel() { + init(); let did: Did = "did:plc:test123".parse().unwrap(); let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com"); let result = verify_signup_token(&token, CommsChannel::Discord, "test@example.com"); @@ -373,6 +382,7 @@ mod tests { #[test] fn test_expired_token() { + init(); let did: Did = "did:plc:test123".parse().unwrap(); let token = generate_token_with_expiry( &did, @@ -388,12 +398,14 @@ mod tests { #[test] fn test_invalid_token() { + init(); let result = verify_signup_token("invalid-token", CommsChannel::Email, "test@example.com"); assert!(matches!(result, Err(VerifyError::InvalidFormat))); } #[test] fn test_purpose_mismatch() { + init(); let did: Did = "did:plc:test123".parse().unwrap(); let email = "test@example.com"; let signup_token = generate_signup_token(&did, CommsChannel::Email, email); @@ -403,6 +415,7 @@ mod tests { #[test] fn test_discord_channel() { + init(); let did: Did = "did:plc:test123".parse().unwrap(); let discord_id = "123456789012345678"; let token = generate_signup_token(&did, CommsChannel::Discord, discord_id); diff --git a/crates/tranquil-pds/src/cache_keys.rs b/crates/tranquil-pds/src/cache_keys.rs index a607bdd..cc9aeed 100644 --- a/crates/tranquil-pds/src/cache_keys.rs +++ b/crates/tranquil-pds/src/cache_keys.rs @@ -33,3 +33,7 @@ pub fn email_update_key(did: &str) -> String { pub fn scope_ref_key(cid: &str) -> String { format!("scope_ref:{}", cid) } + +pub fn auto_verify_sent_key(did: &str) -> String { + format!("auto_verify_sent:{}", did) +} diff --git a/crates/tranquil-pds/src/comms/mod.rs b/crates/tranquil-pds/src/comms/mod.rs index ae5c899..981f01f 100644 --- a/crates/tranquil-pds/src/comms/mod.rs +++ b/crates/tranquil-pds/src/comms/mod.rs @@ -7,4 +7,4 @@ pub use tranquil_comms::{ mime_encode_header, sanitize_header_value, validate_locale, }; -pub use service::{CommsService, repo as comms_repo}; +pub use service::{CommsService, repo as comms_repo, resolve_delivery_channel}; diff --git a/crates/tranquil-pds/src/comms/service.rs b/crates/tranquil-pds/src/comms/service.rs index bfe1277..7b39a1e 100644 --- a/crates/tranquil-pds/src/comms/service.rs +++ b/crates/tranquil-pds/src/comms/service.rs @@ -169,6 +169,13 @@ struct ResolvedRecipient { recipient: String, } +pub fn resolve_delivery_channel( + prefs: &UserCommsPrefs, + channel: tranquil_db_traits::CommsChannel, +) -> tranquil_db_traits::CommsChannel { + resolve_recipient(prefs, channel).channel +} + fn resolve_recipient( prefs: &UserCommsPrefs, channel: tranquil_db_traits::CommsChannel, diff --git a/crates/tranquil-pds/src/handle/mod.rs b/crates/tranquil-pds/src/handle/mod.rs index 7b08950..003369e 100644 --- a/crates/tranquil-pds/src/handle/mod.rs +++ b/crates/tranquil-pds/src/handle/mod.rs @@ -87,11 +87,13 @@ pub async fn verify_handle_ownership( } } -pub fn is_service_domain_handle(handle: &str, _hostname: &str) -> bool { +pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool { if !handle.contains('.') { return true; } - let service_domains = tranquil_config::get().server.user_handle_domain_list(); + let service_domains = tranquil_config::try_get() + .map(|c| c.server.user_handle_domain_list()) + .unwrap_or_else(|| vec![hostname.to_string()]); service_domains .iter() .any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain) diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index fb22eaf..ece6d4c 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -589,6 +589,7 @@ pub fn app(state: AppState) -> Router { ) .route("/authorize/consent", get(oauth::endpoints::consent_get)) .route("/authorize/consent", post(oauth::endpoints::consent_post)) + .route("/authorize/renew", post(oauth::endpoints::authorize_renew)) .route( "/authorize/redirect", get(oauth::endpoints::authorize_redirect), diff --git a/crates/tranquil-pds/src/moderation/mod.rs b/crates/tranquil-pds/src/moderation/mod.rs index 5bb864e..5f698b8 100644 --- a/crates/tranquil-pds/src/moderation/mod.rs +++ b/crates/tranquil-pds/src/moderation/mod.rs @@ -34,7 +34,11 @@ fn get_slur_regexes() -> &'static Vec { } fn get_extra_banned_words() -> &'static Vec { - EXTRA_BANNED_WORDS.get_or_init(|| tranquil_config::get().server.banned_word_list()) + EXTRA_BANNED_WORDS.get_or_init(|| { + tranquil_config::try_get() + .map(|c| c.server.banned_word_list()) + .unwrap_or_default() + }) } fn strip_trailing_digits(s: &str) -> &str { diff --git a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs index 49ba371..d0bd81c 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs @@ -28,6 +28,8 @@ use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, Requ use urlencoding::encode as url_encode; const DEVICE_COOKIE_NAME: &str = "oauth_device_id"; +const RENEW_EXPIRY_SECONDS: i64 = 600; +const MAX_RENEWAL_STALENESS_SECONDS: i64 = 3600; fn redirect_see_other(uri: &str) -> Response { ( @@ -556,14 +558,6 @@ pub async fn authorize_post( if user.takedown_ref.is_some() { return show_login_error("This account has been taken down.", json_response); } - let is_verified = user.channel_verification.has_any_verified(); - if !is_verified { - return show_login_error( - "Please verify your account before logging in.", - json_response, - ); - } - if user.account_type.is_delegated() { if state .oauth_repo @@ -630,6 +624,35 @@ pub async fn authorize_post( if !password_valid { return show_login_error("Invalid handle/email or password.", json_response); } + let is_verified = user.channel_verification.has_any_verified(); + if !is_verified { + let resend_info = crate::api::server::auto_resend_verification(&state, &user.did).await; + let handle = resend_info + .as_ref() + .map(|r| r.handle.to_string()) + .unwrap_or_else(|| form.username.clone()); + let channel = resend_info + .map(|r| r.channel.as_str().to_owned()) + .unwrap_or_else(|| user.preferred_comms_channel.as_str().to_owned()); + if json_response { + return ( + axum::http::StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "account_not_verified", + "error_description": "Please verify your account before logging in.", + "did": user.did, + "handle": handle, + "channel": channel + })), + ) + .into_response(); + } + return redirect_see_other(&format!( + "/app/oauth/login?request_uri={}&error={}", + url_encode(&form.request_uri), + url_encode("account_not_verified") + )); + } let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await; if has_totp { let device_cookie = extract_device_cookie(&headers); @@ -955,11 +978,18 @@ pub async fn authorize_select( }; let is_verified = user.channel_verification.has_any_verified(); if !is_verified { - return json_error( + let resend_info = crate::api::server::auto_resend_verification(&state, &did).await; + return ( StatusCode::FORBIDDEN, - "access_denied", - "Please verify your account before logging in.", - ); + Json(serde_json::json!({ + "error": "account_not_verified", + "error_description": "Please verify your account before logging in.", + "did": did, + "handle": resend_info.as_ref().map(|r| r.handle.to_string()), + "channel": resend_info.as_ref().map(|r| r.channel.as_str()) + })), + ) + .into_response(); } let has_totp = crate::api::server::has_totp_enabled(&state, &did).await; let select_early_device_typed = device_id.clone(); @@ -970,11 +1000,7 @@ pub async fn authorize_select( if !device_is_trusted { if state .oauth_repo - .set_authorization_did( - &select_request_id, - &did, - Some(&select_early_device_typed), - ) + .set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed)) .await .is_err() { @@ -989,8 +1015,8 @@ pub async fn authorize_select( })) .into_response(); } - let _ = crate::api::server::extend_device_trust(state.oauth_repo.as_ref(), &device_id) - .await; + let _ = + crate::api::server::extend_device_trust(state.oauth_repo.as_ref(), &device_id).await; } if user.two_factor_enabled { let _ = state @@ -1041,55 +1067,9 @@ pub async fn authorize_select( .upsert_account_device(&did, &select_device_typed) .await; - let requested_scope_str = request_data - .parameters - .scope - .as_deref() - .unwrap_or("atproto"); - let requested_scopes: Vec = requested_scope_str - .split_whitespace() - .map(|s| s.to_string()) - .collect(); - let client_id_typed = ClientId::from(request_data.parameters.client_id.clone()); - let needs_consent = should_show_consent( - state.oauth_repo.as_ref(), - &did, - &client_id_typed, - &requested_scopes, - ) - .await - .unwrap_or(true); - - if needs_consent { - if state - .oauth_repo - .set_authorization_did(&select_request_id, &did, Some(&select_device_typed)) - .await - .is_err() - { - return json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "An error occurred. Please try again.", - ); - } - let consent_url = format!( - "/app/oauth/consent?request_uri={}", - url_encode(&form.request_uri) - ); - return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); - } - - let code = Code::generate(); - let select_code = AuthorizationCode::from(code.0.clone()); if state .oauth_repo - .update_authorization_request( - &select_request_id, - &did, - Some(&select_device_typed), - &select_code, - ) + .set_authorization_did(&select_request_id, &did, Some(&select_device_typed)) .await .is_err() { @@ -1099,16 +1079,11 @@ pub async fn authorize_select( "An error occurred. Please try again.", ); } - let redirect_url = build_intermediate_redirect_url( - &request_data.parameters.redirect_uri, - &code.0, - request_data.parameters.state.as_deref(), - request_data.parameters.response_mode.map(|m| m.as_str()), + let consent_url = format!( + "/app/oauth/consent?request_uri={}", + url_encode(&form.request_uri) ); - Json(serde_json::json!({ - "redirect_uri": redirect_url - })) - .into_response() + Json(serde_json::json!({"redirect_uri": consent_url})).into_response() } fn build_success_redirect( @@ -1401,13 +1376,9 @@ pub async fn consent_get( } }, Err(_) => { - let _ = state - .oauth_repo - .delete_authorization_request(&consent_request_id) - .await; return json_error( StatusCode::BAD_REQUEST, - "invalid_request", + "expired_request", "Authorization request has expired", ); } @@ -1758,6 +1729,93 @@ pub async fn consent_post( Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response() } +#[derive(Debug, Deserialize)] +pub struct RenewRequest { + pub request_uri: String, +} + +pub async fn authorize_renew( + State(state): State, + _rate_limit: OAuthRateLimited, + Json(form): Json, +) -> Response { + let request_id = RequestId::from(form.request_uri.clone()); + let request_data = match state + .oauth_repo + .get_authorization_request(&request_id) + .await + { + Ok(Some(data)) => data, + Ok(None) => { + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "Unknown authorization request", + ); + } + Err(_) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "Database error", + ); + } + }; + + if request_data.did.is_none() { + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "Authorization request not yet authenticated", + ); + } + + let now = Utc::now(); + if request_data.expires_at >= now { + return Json(serde_json::json!({ + "request_uri": form.request_uri, + "renewed": false + })) + .into_response(); + } + + let staleness = now - request_data.expires_at; + if staleness.num_seconds() > MAX_RENEWAL_STALENESS_SECONDS { + let _ = state + .oauth_repo + .delete_authorization_request(&request_id) + .await; + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "Authorization request expired too long ago to renew", + ); + } + + let new_expires_at = now + chrono::Duration::seconds(RENEW_EXPIRY_SECONDS); + match state + .oauth_repo + .extend_authorization_request_expiry(&request_id, new_expires_at) + .await + { + Ok(true) => Json(serde_json::json!({ + "request_uri": form.request_uri, + "renewed": true + })) + .into_response(), + Ok(false) => json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "Authorization request could not be renewed", + ), + Err(_) => json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "Database error", + ), + } +} + pub async fn authorize_2fa_post( State(state): State, _rate_limit: OAuthRateLimited, @@ -1953,8 +2011,7 @@ pub async fn authorize_2fa_post( .oauth_repo .upsert_account_device(&did, &trust_device_id) .await; - let _ = crate::api::server::trust_device(state.oauth_repo.as_ref(), &trust_device_id) - .await; + let _ = crate::api::server::trust_device(state.oauth_repo.as_ref(), &trust_device_id).await; } let requested_scope_str = request_data .parameters @@ -2240,11 +2297,15 @@ pub async fn passkey_start( let is_verified = user.channel_verification.has_any_verified(); if !is_verified { + let resend_info = crate::api::server::auto_resend_verification(&state, &user.did).await; return ( StatusCode::FORBIDDEN, Json(serde_json::json!({ - "error": "access_denied", - "error_description": "Please verify your account before logging in." + "error": "account_not_verified", + "error_description": "Please verify your account before logging in.", + "did": user.did, + "handle": resend_info.as_ref().map(|r| r.handle.to_string()), + "channel": resend_info.as_ref().map(|r| r.channel.as_str()) })), ) .into_response(); @@ -3389,11 +3450,15 @@ pub async fn register_complete( }; if !is_verified { + let resend_info = crate::api::server::auto_resend_verification(&state, &did).await; return ( StatusCode::FORBIDDEN, Json(serde_json::json!({ - "error": "access_denied", - "error_description": "Please verify your account before continuing." + "error": "account_not_verified", + "error_description": "Please verify your account before continuing.", + "did": did, + "handle": resend_info.as_ref().map(|r| r.handle.to_string()), + "channel": resend_info.as_ref().map(|r| r.channel.as_str()) })), ) .into_response(); diff --git a/crates/tranquil-pds/src/plc/mod.rs b/crates/tranquil-pds/src/plc/mod.rs index c2e1e18..d402c84 100644 --- a/crates/tranquil-pds/src/plc/mod.rs +++ b/crates/tranquil-pds/src/plc/mod.rs @@ -124,10 +124,15 @@ impl PlcClient { } pub fn with_cache(base_url: Option, cache: Option>) -> Self { - let cfg = tranquil_config::get(); - let base_url = base_url.unwrap_or_else(|| cfg.plc.directory_url.clone()); - let timeout_secs = cfg.plc.timeout_secs; - let connect_timeout_secs = cfg.plc.connect_timeout_secs; + let cfg = tranquil_config::try_get(); + let base_url = base_url + .or_else(|| std::env::var("PLC_DIRECTORY_URL").ok()) + .unwrap_or_else(|| { + cfg.map(|c| c.plc.directory_url.clone()) + .unwrap_or_else(|| "https://plc.directory".to_string()) + }); + let timeout_secs = cfg.map_or(10, |c| c.plc.timeout_secs); + let connect_timeout_secs = cfg.map_or(5, |c| c.plc.connect_timeout_secs); let client = Client::builder() .timeout(Duration::from_secs(timeout_secs)) .connect_timeout(Duration::from_secs(connect_timeout_secs)) diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index 1282f89..bf5b33a 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -1,18 +1,18 @@ use crate::appview::DidResolver; use crate::auth::webauthn::WebAuthnConfig; -use crate::cache::{create_cache, Cache, DistributedRateLimiter}; +use crate::cache::{Cache, DistributedRateLimiter, create_cache}; use crate::circuit_breaker::CircuitBreakers; use crate::config::AuthConfig; use crate::rate_limit::RateLimiters; use crate::repo::PostgresBlockStore; use crate::repo_write_lock::RepoWriteLocks; use crate::sso::{SsoConfig, SsoManager}; -use crate::storage::{create_backup_storage, create_blob_storage, BackupStorage, BlobStorage}; +use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage}; use crate::sync::firehose::SequencedEvent; use sqlx::PgPool; use std::error::Error; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use tranquil_db::{ diff --git a/crates/tranquil-pds/src/sync/verify.rs b/crates/tranquil-pds/src/sync/verify.rs index 06a9930..4aeeefd 100644 --- a/crates/tranquil-pds/src/sync/verify.rs +++ b/crates/tranquil-pds/src/sync/verify.rs @@ -145,7 +145,8 @@ impl CarVerifier { } async fn resolve_plc_did(&self, did: &str) -> Result, VerifyError> { - let plc_url = tranquil_config::get().plc.directory_url.clone(); + let plc_url = std::env::var("PLC_DIRECTORY_URL") + .unwrap_or_else(|_| tranquil_config::get().plc.directory_url.clone()); let url = format!("{}/{}", plc_url, urlencoding::encode(did)); let response = self .http_client diff --git a/crates/tranquil-pds/src/util.rs b/crates/tranquil-pds/src/util.rs index 43417a8..c5371b5 100644 --- a/crates/tranquil-pds/src/util.rs +++ b/crates/tranquil-pds/src/util.rs @@ -374,6 +374,7 @@ mod tests { #[test] fn test_build_full_url_adds_xrpc_prefix_for_atproto_paths() { unsafe { std::env::set_var("PDS_HOSTNAME", "example.com") }; + tranquil_config::ensure_test_defaults(); assert_eq!( build_full_url("/com.atproto.server.getSession"), "https://example.com/xrpc/com.atproto.server.getSession" diff --git a/crates/tranquil-pds/tests/common/mod.rs b/crates/tranquil-pds/tests/common/mod.rs index b9836a4..cd4d2de 100644 --- a/crates/tranquil-pds/tests/common/mod.rs +++ b/crates/tranquil-pds/tests/common/mod.rs @@ -548,6 +548,7 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance { unsafe { std::env::set_var("PDS_HOSTNAME", format!("pds.test:{}", addr.port())); } + tranquil_config::ensure_test_defaults(); let rate_limiters = RateLimiters::new() .with_login_limit(10000) .with_account_creation_limit(10000) diff --git a/crates/tranquil-pds/tests/import_with_verification.rs b/crates/tranquil-pds/tests/import_with_verification.rs index 97d4aca..1681b7d 100644 --- a/crates/tranquil-pds/tests/import_with_verification.rs +++ b/crates/tranquil-pds/tests/import_with_verification.rs @@ -64,7 +64,7 @@ async fn test_import_with_valid_signature_and_mock_plc() { let mock_plc = setup_mock_plc_directory(&did, did_doc).await; unsafe { std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); } let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); let import_res = client @@ -108,7 +108,7 @@ async fn test_import_with_wrong_signing_key_fails() { let mock_plc = setup_mock_plc_directory(&did, did_doc).await; unsafe { std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); } let (car_bytes, _root_cid) = build_car_with_signature(&did, &wrong_signing_key); let import_res = client @@ -157,7 +157,7 @@ async fn test_import_with_did_mismatch_fails() { let mock_plc = setup_mock_plc_directory(&did, did_doc).await; unsafe { std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); } let (car_bytes, _root_cid) = build_car_with_signature(wrong_did, &signing_key); let import_res = client @@ -202,7 +202,7 @@ async fn test_import_with_plc_resolution_failure() { .await; unsafe { std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); } let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); let import_res = client @@ -248,7 +248,7 @@ async fn test_import_with_no_signing_key_in_did_doc() { let mock_plc = setup_mock_plc_directory(&did, did_doc_without_key).await; unsafe { std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri()); - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); } let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); let import_res = client diff --git a/crates/tranquil-pds/tests/plc_migration.rs b/crates/tranquil-pds/tests/plc_migration.rs index 570c572..5d4648b 100644 --- a/crates/tranquil-pds/tests/plc_migration.rs +++ b/crates/tranquil-pds/tests/plc_migration.rs @@ -698,7 +698,7 @@ async fn test_cross_pds_migration_with_records() { .await; unsafe { std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri()); - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); } let import_res = client .post(format!( @@ -775,7 +775,7 @@ async fn test_migration_rejects_wrong_did_document() { .await; unsafe { std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri()); - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); } let import_res = client .post(format!( @@ -931,7 +931,7 @@ async fn test_full_migration_flow_end_to_end() { .expect("Submit failed"); assert_eq!(submit_res.status(), StatusCode::OK); unsafe { - std::env::remove_var("SKIP_IMPORT_VERIFICATION"); + std::env::set_var("SKIP_IMPORT_VERIFICATION", "false"); } let import_res = client .post(format!( diff --git a/crates/tranquil-storage/src/lib.rs b/crates/tranquil-storage/src/lib.rs index 855e812..91a220b 100644 --- a/crates/tranquil-storage/src/lib.rs +++ b/crates/tranquil-storage/src/lib.rs @@ -767,20 +767,20 @@ pub async fn create_backup_storage() -> Option> { _ => { let path = cfg.backup.path.clone(); FilesystemBackupStorage::new(path).await.map_or_else( - |e| { - tracing::error!( - "Failed to initialize filesystem backup storage: {}. \ + |e| { + tracing::error!( + "Failed to initialize filesystem backup storage: {}. \ Set BACKUP_STORAGE_PATH to a valid directory path. \ Backups will be disabled.", - e - ); - None - }, - |storage| { - tracing::info!("Initialized filesystem backup storage"); - Some(Arc::new(storage) as Arc) - }, - ) + e + ); + None + }, + |storage| { + tracing::info!("Initialized filesystem backup storage"); + Some(Arc::new(storage) as Arc) + }, + ) } } } diff --git a/frontend/src/components/dashboard/SecurityContent.svelte b/frontend/src/components/dashboard/SecurityContent.svelte index d862964..101674e 100644 --- a/frontend/src/components/dashboard/SecurityContent.svelte +++ b/frontend/src/components/dashboard/SecurityContent.svelte @@ -457,7 +457,13 @@ showSetPasswordForm = false } catch (e) { if (e instanceof ApiError) { - toast.error(e.message) + if (e.error === 'ReauthRequired') { + reauthMethods = e.reauthMethods || ['passkey'] + pendingAction = () => handleSetPassword(new Event('submit')) + showReauthModal = true + } else { + toast.error(e.message) + } } else { toast.error($_('security.failedToSetPassword')) } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 322dde0..fd6731d 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -542,7 +542,8 @@ "passkeyHintNotAvailable": "No passkey registered", "passwordPlaceholder": "Password", "usePasskey": "Use passkey", - "orUseCredentials": "or" + "orUseCredentials": "or", + "verificationResent": "Verification code sent" }, "sso": { "linkedAccounts": "Linked Accounts", diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index 08b9a60..d01c9bb 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -542,7 +542,8 @@ "passkeyHintNotAvailable": "Ei pääsyavainta", "passwordPlaceholder": "Salasana", "usePasskey": "Käytä pääsyavainta", - "orUseCredentials": "tai" + "orUseCredentials": "tai", + "verificationResent": "Vahvistuskoodi lähetetty" }, "register": { "title": "Luo tili", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index b445726..e22cd35 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -542,7 +542,8 @@ "passkeyHintNotAvailable": "パスキーなし", "passwordPlaceholder": "パスワード", "usePasskey": "パスキーを使用", - "orUseCredentials": "または" + "orUseCredentials": "または", + "verificationResent": "確認コードを送信しました" }, "register": { "title": "アカウント作成", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index 730a260..edcadde 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -542,7 +542,8 @@ "passkeyHintNotAvailable": "패스키 없음", "passwordPlaceholder": "비밀번호", "usePasskey": "패스키 사용", - "orUseCredentials": "또는" + "orUseCredentials": "또는", + "verificationResent": "인증 코드 전송됨" }, "register": { "title": "계정 만들기", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index f3403d4..e3d346d 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -542,7 +542,8 @@ "passkeyHintNotAvailable": "Ingen nyckel registrerad", "passwordPlaceholder": "Lösenord", "usePasskey": "Använd nyckel", - "orUseCredentials": "eller" + "orUseCredentials": "eller", + "verificationResent": "Verifieringskod skickad" }, "register": { "title": "Skapa konto", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index cf050d3..ac7f2ac 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -542,7 +542,8 @@ "passkeyHintNotAvailable": "未注册通行密钥", "passwordPlaceholder": "密码", "usePasskey": "使用通行密钥", - "orUseCredentials": "或" + "orUseCredentials": "或", + "verificationResent": "验证码已发送" }, "register": { "title": "创建账户", diff --git a/frontend/src/routes/OAuthConsent.svelte b/frontend/src/routes/OAuthConsent.svelte index 2e1d45d..82b075f 100644 --- a/frontend/src/routes/OAuthConsent.svelte +++ b/frontend/src/routes/OAuthConsent.svelte @@ -62,6 +62,21 @@ return params.get('request_uri') } + async function tryRenewRequest(requestUri: string): Promise { + try { + const response = await fetch('/oauth/authorize/renew', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_uri: requestUri }), + }) + if (!response.ok) return false + const data = await response.json() + return data.renewed === true + } catch { + return false + } + } + async function fetchConsentData() { const requestUri = getRequestUri() if (!requestUri) { @@ -72,13 +87,32 @@ } try { - const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) + let response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) if (!response.ok) { const data = await response.json() - console.error('[OAuthConsent] Consent fetch failed:', data) - error = data.error_description || data.error || $_('oauth.error.genericError') - loading = false - return + if (data.error === 'expired_request') { + const renewed = await tryRenewRequest(requestUri) + if (renewed) { + response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) + if (!response.ok) { + const retryData = await response.json() + console.error('[OAuthConsent] Consent fetch failed after renewal:', retryData) + error = retryData.error_description || retryData.error || $_('oauth.error.genericError') + loading = false + return + } + } else { + console.error('[OAuthConsent] Consent fetch failed:', data) + error = data.error_description || data.error || $_('oauth.error.genericError') + loading = false + return + } + } else { + console.error('[OAuthConsent] Consent fetch failed:', data) + error = data.error_description || data.error || $_('oauth.error.genericError') + loading = false + return + } } const data: ConsentData = await response.json() diff --git a/frontend/src/routes/OAuthLogin.svelte b/frontend/src/routes/OAuthLogin.svelte index c44dc97..f0f5b91 100644 --- a/frontend/src/routes/OAuthLogin.svelte +++ b/frontend/src/routes/OAuthLogin.svelte @@ -18,6 +18,18 @@ icon: string } + const PENDING_VERIFICATION_KEY = 'tranquil_pds_pending_verification' + + function storePendingVerification(data: { did?: string; handle?: string; channel?: string }) { + if (data.did) { + localStorage.setItem(PENDING_VERIFICATION_KEY, JSON.stringify({ + did: data.did, + handle: data.handle ?? '', + channel: data.channel ?? '', + })) + } + } + let username = $state('') let ssoProviders = $state([]) let ssoLoading = $state(null) @@ -25,6 +37,7 @@ let rememberDevice = $state(false) let submitting = $state(false) let error = $state(null) + let verificationResent = $state(false) let hasPasskeys = $state(false) let hasTotp = $state(false) let hasPassword = $state(true) @@ -52,7 +65,11 @@ $effect(() => { const urlError = getErrorFromUrl() if (urlError) { - error = urlError + if (urlError === 'account_not_verified') { + verificationResent = true + } else { + error = urlError + } } }) @@ -200,6 +217,7 @@ submitting = true error = null + verificationResent = false try { const startResponse = await fetch('/oauth/passkey/start', { @@ -216,6 +234,12 @@ if (!startResponse.ok) { const data = await startResponse.json() + if (data.error === 'account_not_verified') { + verificationResent = true + storePendingVerification(data) + submitting = false + return + } error = data.error_description || data.error || 'Failed to start passkey login' submitting = false return @@ -251,6 +275,12 @@ const data = await finishResponse.json() if (!finishResponse.ok) { + if (data.error === 'account_not_verified') { + verificationResent = true + storePendingVerification(data) + submitting = false + return + } error = data.error_description || data.error || 'Passkey authentication failed' submitting = false return @@ -294,6 +324,7 @@ submitting = true error = null + verificationResent = false try { const response = await fetch('/oauth/authorize', { @@ -313,6 +344,12 @@ const data = await response.json() if (!response.ok) { + if (data.error === 'account_not_verified') { + verificationResent = true + storePendingVerification(data) + submitting = false + return + } error = data.error_description || data.error || 'Login failed' submitting = false return @@ -354,7 +391,12 @@ {/if} - {#if error} + {#if verificationResent} +
+

{$_('oauth.login.verificationResent')}

+ {$_('verify.tokenTitle')} +
+ {:else if error}
{error}
{/if}