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.
+58
.sqlx/query-85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea.json
+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
-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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+4
crates/tranquil-pds/src/cache_keys.rs
+1
-1
crates/tranquil-pds/src/comms/mod.rs
+1
-1
crates/tranquil-pds/src/comms/mod.rs
+7
crates/tranquil-pds/src/comms/service.rs
+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
+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
+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
+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 {
+9
-4
crates/tranquil-pds/src/plc/mod.rs
+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
+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
+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
+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
+1
crates/tranquil-pds/tests/common/mod.rs
+5
-5
crates/tranquil-pds/tests/import_with_verification.rs
+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
+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
+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
+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
+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
+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
+2
-1
frontend/src/locales/ja.json
+2
-1
frontend/src/locales/ko.json
+2
-1
frontend/src/locales/ko.json
+2
-1
frontend/src/locales/sv.json
+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
+2
-1
frontend/src/locales/zh.json
+39
-5
frontend/src/routes/OAuthConsent.svelte
+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
+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