fix: ability to send more verifications

This commit is contained in:
lewis
2026-02-24 10:13:11 +00:00
committed by Tangled
parent 3913bf5c1a
commit 28ca66624a
42 changed files with 651 additions and 216 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+14 -7
View File
@@ -265,8 +265,7 @@ mod tests {
#[test]
fn token_type_accepts_bluesky_uppercase_jwt() {
let result: Result<Header, _> =
serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#);
let result: Result<Header, _> = 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<Header, _> =
serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#);
let result: Result<Header, _> = 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
);
}
}
+29
View File
@@ -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()`
+5
View File
@@ -192,6 +192,11 @@ pub trait OAuthRepository: Send + Sync {
) -> Result<Option<RequestData>, DbError>;
async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError>;
async fn delete_expired_authorization_requests(&self) -> Result<u64, DbError>;
async fn extend_authorization_request_expiry(
&self,
request_id: &RequestId,
new_expires_at: DateTime<Utc>,
) -> Result<bool, DbError>;
async fn mark_request_authenticated(
&self,
request_id: &RequestId,
+3
View File
@@ -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<DateTime<Utc>>,
}
@@ -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<String>,
pub recovery_token_expires_at: Option<DateTime<Utc>>,
}
+20
View File
@@ -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<Utc>,
) -> Result<bool, DbError> {
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,
+5 -2
View File
@@ -1715,7 +1715,7 @@ impl UserRepository for PostgresUserRepository {
code: &str,
) -> Result<Option<UserResetCodeInfo>, 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<Option<UserForRecovery>, 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,
}))
+1 -1
View File
@@ -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 {
+8 -1
View File
@@ -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)");
+3 -3
View File
@@ -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::{
@@ -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()
}
@@ -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()
}
+86 -2
View File
@@ -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<AutoResendResult> {
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 {
@@ -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);
+4
View File
@@ -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)
}
+1 -1
View File
@@ -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};
+7
View File
@@ -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,
+4 -2
View File
@@ -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)
+1
View File
@@ -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),
+5 -1
View File
@@ -34,7 +34,11 @@ fn get_slur_regexes() -> &'static Vec<Regex> {
}
fn get_extra_banned_words() -> &'static Vec<String> {
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 {
@@ -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<String> = 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<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
Json(form): Json<RenewRequest>,
) -> 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<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
@@ -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();
+9 -4
View File
@@ -124,10 +124,15 @@ impl PlcClient {
}
pub fn with_cache(base_url: Option<String>, cache: Option<Arc<dyn Cache>>) -> 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))
+3 -3
View File
@@ -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::{
+2 -1
View File
@@ -145,7 +145,8 @@ impl CarVerifier {
}
async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, 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
+1
View File
@@ -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"
+1
View File
@@ -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)
@@ -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
+3 -3
View File
@@ -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!(
+12 -12
View File
@@ -767,20 +767,20 @@ pub async fn create_backup_storage() -> Option<Arc<dyn BackupStorage>> {
_ => {
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<dyn BackupStorage>)
},
)
e
);
None
},
|storage| {
tracing::info!("Initialized filesystem backup storage");
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
},
)
}
}
}
@@ -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'))
}
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -542,7 +542,8 @@
"passkeyHintNotAvailable": "パスキーなし",
"passwordPlaceholder": "パスワード",
"usePasskey": "パスキーを使用",
"orUseCredentials": "または"
"orUseCredentials": "または",
"verificationResent": "確認コードを送信しました"
},
"register": {
"title": "アカウント作成",
+2 -1
View File
@@ -542,7 +542,8 @@
"passkeyHintNotAvailable": "패스키 없음",
"passwordPlaceholder": "비밀번호",
"usePasskey": "패스키 사용",
"orUseCredentials": "또는"
"orUseCredentials": "또는",
"verificationResent": "인증 코드 전송됨"
},
"register": {
"title": "계정 만들기",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -542,7 +542,8 @@
"passkeyHintNotAvailable": "未注册通行密钥",
"passwordPlaceholder": "密码",
"usePasskey": "使用通行密钥",
"orUseCredentials": "或"
"orUseCredentials": "或",
"verificationResent": "验证码已发送"
},
"register": {
"title": "创建账户",
+39 -5
View File
@@ -62,6 +62,21 @@
return params.get('request_uri')
}
async function tryRenewRequest(requestUri: string): Promise<boolean> {
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()
+44 -2
View File
@@ -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<SsoProvider[]>([])
let ssoLoading = $state<string | null>(null)
@@ -25,6 +37,7 @@
let rememberDevice = $state(false)
let submitting = $state(false)
let error = $state<string | null>(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}
</header>
{#if error}
{#if verificationResent}
<div class="message warning">
<p>{$_('oauth.login.verificationResent')}</p>
<a href={`${getFullUrl(routes.verify)}${getRequestUri() ? `?request_uri=${encodeURIComponent(getRequestUri()!)}` : ''}`}>{$_('verify.tokenTitle')}</a>
</div>
{:else if error}
<div class="message error">{error}</div>
{/if}