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