+491
-68
Diff
round #0
+20
-20
Cargo.lock
+20
-20
Cargo.lock
···
6094
6094
6095
6095
[[package]]
6096
6096
name = "tranquil-api"
6097
-
version = "0.4.5"
6097
+
version = "0.4.6"
6098
6098
dependencies = [
6099
6099
"anyhow",
6100
6100
"axum",
···
6142
6142
6143
6143
[[package]]
6144
6144
name = "tranquil-auth"
6145
-
version = "0.4.5"
6145
+
version = "0.4.6"
6146
6146
dependencies = [
6147
6147
"anyhow",
6148
6148
"base32",
···
6165
6165
6166
6166
[[package]]
6167
6167
name = "tranquil-cache"
6168
-
version = "0.4.5"
6168
+
version = "0.4.6"
6169
6169
dependencies = [
6170
6170
"async-trait",
6171
6171
"base64 0.22.1",
···
6179
6179
6180
6180
[[package]]
6181
6181
name = "tranquil-comms"
6182
-
version = "0.4.5"
6182
+
version = "0.4.6"
6183
6183
dependencies = [
6184
6184
"async-trait",
6185
6185
"base64 0.22.1",
···
6194
6194
6195
6195
[[package]]
6196
6196
name = "tranquil-config"
6197
-
version = "0.4.5"
6197
+
version = "0.4.6"
6198
6198
dependencies = [
6199
6199
"confique",
6200
6200
"serde",
···
6202
6202
6203
6203
[[package]]
6204
6204
name = "tranquil-crypto"
6205
-
version = "0.4.5"
6205
+
version = "0.4.6"
6206
6206
dependencies = [
6207
6207
"aes-gcm",
6208
6208
"base64 0.22.1",
···
6218
6218
6219
6219
[[package]]
6220
6220
name = "tranquil-db"
6221
-
version = "0.4.5"
6221
+
version = "0.4.6"
6222
6222
dependencies = [
6223
6223
"async-trait",
6224
6224
"chrono",
···
6235
6235
6236
6236
[[package]]
6237
6237
name = "tranquil-db-traits"
6238
-
version = "0.4.5"
6238
+
version = "0.4.6"
6239
6239
dependencies = [
6240
6240
"async-trait",
6241
6241
"base64 0.22.1",
···
6251
6251
6252
6252
[[package]]
6253
6253
name = "tranquil-infra"
6254
-
version = "0.4.5"
6254
+
version = "0.4.6"
6255
6255
dependencies = [
6256
6256
"async-trait",
6257
6257
"bytes",
···
6262
6262
6263
6263
[[package]]
6264
6264
name = "tranquil-lexicon"
6265
-
version = "0.4.5"
6265
+
version = "0.4.6"
6266
6266
dependencies = [
6267
6267
"chrono",
6268
6268
"hickory-resolver",
···
6280
6280
6281
6281
[[package]]
6282
6282
name = "tranquil-oauth"
6283
-
version = "0.4.5"
6283
+
version = "0.4.6"
6284
6284
dependencies = [
6285
6285
"anyhow",
6286
6286
"axum",
···
6303
6303
6304
6304
[[package]]
6305
6305
name = "tranquil-oauth-server"
6306
-
version = "0.4.5"
6306
+
version = "0.4.6"
6307
6307
dependencies = [
6308
6308
"axum",
6309
6309
"base64 0.22.1",
···
6336
6336
6337
6337
[[package]]
6338
6338
name = "tranquil-pds"
6339
-
version = "0.4.5"
6339
+
version = "0.4.6"
6340
6340
dependencies = [
6341
6341
"aes-gcm",
6342
6342
"anyhow",
···
6424
6424
6425
6425
[[package]]
6426
6426
name = "tranquil-repo"
6427
-
version = "0.4.5"
6427
+
version = "0.4.6"
6428
6428
dependencies = [
6429
6429
"bytes",
6430
6430
"cid",
···
6436
6436
6437
6437
[[package]]
6438
6438
name = "tranquil-ripple"
6439
-
version = "0.4.5"
6439
+
version = "0.4.6"
6440
6440
dependencies = [
6441
6441
"async-trait",
6442
6442
"backon",
···
6461
6461
6462
6462
[[package]]
6463
6463
name = "tranquil-scopes"
6464
-
version = "0.4.5"
6464
+
version = "0.4.6"
6465
6465
dependencies = [
6466
6466
"axum",
6467
6467
"futures",
···
6477
6477
6478
6478
[[package]]
6479
6479
name = "tranquil-server"
6480
-
version = "0.4.5"
6480
+
version = "0.4.6"
6481
6481
dependencies = [
6482
6482
"axum",
6483
6483
"clap",
···
6497
6497
6498
6498
[[package]]
6499
6499
name = "tranquil-storage"
6500
-
version = "0.4.5"
6500
+
version = "0.4.6"
6501
6501
dependencies = [
6502
6502
"async-trait",
6503
6503
"aws-config",
···
6514
6514
6515
6515
[[package]]
6516
6516
name = "tranquil-sync"
6517
-
version = "0.4.5"
6517
+
version = "0.4.6"
6518
6518
dependencies = [
6519
6519
"anyhow",
6520
6520
"axum",
···
6536
6536
6537
6537
[[package]]
6538
6538
name = "tranquil-types"
6539
-
version = "0.4.5"
6539
+
version = "0.4.6"
6540
6540
dependencies = [
6541
6541
"chrono",
6542
6542
"cid",
+1
-1
Cargo.toml
+1
-1
Cargo.toml
+262
crates/tranquil-api/src/common.rs
+262
crates/tranquil-api/src/common.rs
···
1
+
use bcrypt::DEFAULT_COST;
2
+
use chrono::{DateTime, Utc};
3
+
use std::collections::HashMap;
4
+
use tracing::error;
5
+
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
6
+
use tranquil_pds::api::error::ApiError;
7
+
use tranquil_pds::api::error::DbResultExt;
8
+
use tranquil_pds::types::{AtIdentifier, Did, Handle};
9
+
10
+
pub struct ResolvedRepo {
11
+
pub user_id: uuid::Uuid,
12
+
pub did: Did,
13
+
pub handle: Handle,
14
+
}
15
+
16
+
fn qualify_handle(handle: &Handle) -> Result<Handle, ApiError> {
17
+
let raw = handle.as_str();
18
+
let qualified = match raw.contains('.') {
19
+
true => return Ok(handle.clone()),
20
+
false => format!(
21
+
"{}.{}",
22
+
raw,
23
+
tranquil_config::get().server.hostname_without_port()
24
+
),
25
+
};
26
+
qualified
27
+
.parse()
28
+
.map_err(|_| ApiError::InvalidRequest("Invalid handle format".into()))
29
+
}
30
+
31
+
pub async fn resolve_repo(
32
+
user_repo: &dyn UserRepository,
33
+
repo: &AtIdentifier,
34
+
) -> Result<ResolvedRepo, ApiError> {
35
+
let row = match repo {
36
+
AtIdentifier::Did(did) => user_repo
37
+
.get_by_did(did)
38
+
.await
39
+
.log_db_err("resolving repo by DID")?,
40
+
AtIdentifier::Handle(handle) => {
41
+
let qualified = qualify_handle(handle)?;
42
+
user_repo
43
+
.get_by_handle(&qualified)
44
+
.await
45
+
.log_db_err("resolving repo by handle")?
46
+
}
47
+
};
48
+
row.map(|r| ResolvedRepo {
49
+
user_id: r.id,
50
+
did: r.did,
51
+
handle: r.handle,
52
+
})
53
+
.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
54
+
}
55
+
56
+
pub async fn resolve_repo_user_id(
57
+
user_repo: &dyn UserRepository,
58
+
repo: &AtIdentifier,
59
+
) -> Result<uuid::Uuid, ApiError> {
60
+
let id = match repo {
61
+
AtIdentifier::Did(did) => user_repo
62
+
.get_id_by_did(did)
63
+
.await
64
+
.log_db_err("resolving repo user ID by DID")?,
65
+
AtIdentifier::Handle(handle) => {
66
+
let qualified = qualify_handle(handle)?;
67
+
user_repo
68
+
.get_id_by_handle(&qualified)
69
+
.await
70
+
.log_db_err("resolving repo user ID by handle")?
71
+
}
72
+
};
73
+
id.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
74
+
}
75
+
76
+
pub fn group_invite_uses_by_code<U, F>(
77
+
uses: Vec<tranquil_db_traits::InviteCodeUse>,
78
+
map_use: F,
79
+
) -> HashMap<String, Vec<U>>
80
+
where
81
+
F: Fn(tranquil_db_traits::InviteCodeUse) -> U,
82
+
{
83
+
uses.into_iter().fold(HashMap::new(), |mut acc, u| {
84
+
let code = u.code.clone();
85
+
acc.entry(code).or_default().push(map_use(u));
86
+
acc
87
+
})
88
+
}
89
+
90
+
pub fn resolve_also_known_as(
91
+
overrides: Option<&DidWebOverrides>,
92
+
current_handle: &str,
93
+
) -> Vec<String> {
94
+
overrides
95
+
.filter(|ovr| !ovr.also_known_as.is_empty())
96
+
.map(|ovr| ovr.also_known_as.clone())
97
+
.unwrap_or_else(|| vec![format!("at://{}", current_handle)])
98
+
}
99
+
100
+
pub fn build_did_document(
101
+
did: &str,
102
+
also_known_as: Vec<String>,
103
+
verification_methods: Vec<serde_json::Value>,
104
+
service_endpoint: &str,
105
+
) -> serde_json::Value {
106
+
serde_json::json!({
107
+
"@context": [
108
+
"https://www.w3.org/ns/did/v1",
109
+
"https://w3id.org/security/multikey/v1",
110
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
111
+
],
112
+
"id": did,
113
+
"alsoKnownAs": also_known_as,
114
+
"verificationMethod": verification_methods,
115
+
"service": [{
116
+
"id": "#atproto_pds",
117
+
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
118
+
"serviceEndpoint": service_endpoint
119
+
}]
120
+
})
121
+
}
122
+
123
+
pub async fn set_channel_verified_flag(
124
+
user_repo: &dyn UserRepository,
125
+
user_id: uuid::Uuid,
126
+
channel: CommsChannel,
127
+
) -> Result<(), ApiError> {
128
+
match channel {
129
+
CommsChannel::Email => user_repo
130
+
.set_email_verified_flag(user_id)
131
+
.await
132
+
.log_db_err("updating email verified status")?,
133
+
CommsChannel::Discord => user_repo
134
+
.set_discord_verified_flag(user_id)
135
+
.await
136
+
.log_db_err("updating discord verified status")?,
137
+
CommsChannel::Telegram => user_repo
138
+
.set_telegram_verified_flag(user_id)
139
+
.await
140
+
.log_db_err("updating telegram verified status")?,
141
+
CommsChannel::Signal => user_repo
142
+
.set_signal_verified_flag(user_id)
143
+
.await
144
+
.log_db_err("updating signal verified status")?,
145
+
};
146
+
Ok(())
147
+
}
148
+
149
+
pub struct ChannelInput<'a> {
150
+
pub email: Option<&'a str>,
151
+
pub discord_username: Option<&'a str>,
152
+
pub telegram_username: Option<&'a str>,
153
+
pub signal_username: Option<&'a str>,
154
+
}
155
+
156
+
pub fn extract_verification_recipient(
157
+
channel: CommsChannel,
158
+
input: &ChannelInput<'_>,
159
+
) -> Result<String, ApiError> {
160
+
match channel {
161
+
CommsChannel::Email => match input.email {
162
+
Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()),
163
+
_ => Err(ApiError::MissingEmail),
164
+
},
165
+
CommsChannel::Discord => match input.discord_username {
166
+
Some(username) if !username.trim().is_empty() => {
167
+
let clean = username.trim().to_lowercase();
168
+
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) {
169
+
return Err(ApiError::InvalidRequest(
170
+
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
171
+
));
172
+
}
173
+
Ok(clean)
174
+
}
175
+
_ => Err(ApiError::MissingDiscordId),
176
+
},
177
+
CommsChannel::Telegram => match input.telegram_username {
178
+
Some(username) if !username.trim().is_empty() => {
179
+
let clean = username.trim().trim_start_matches('@');
180
+
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) {
181
+
return Err(ApiError::InvalidRequest(
182
+
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
183
+
));
184
+
}
185
+
Ok(clean.to_string())
186
+
}
187
+
_ => Err(ApiError::MissingTelegramUsername),
188
+
},
189
+
CommsChannel::Signal => match input.signal_username {
190
+
Some(username) if !username.trim().is_empty() => {
191
+
Ok(username.trim().trim_start_matches('@').to_lowercase())
192
+
}
193
+
_ => Err(ApiError::MissingSignalNumber),
194
+
},
195
+
}
196
+
}
197
+
198
+
pub fn create_self_hosted_did_web(handle: &str) -> Result<String, ApiError> {
199
+
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
200
+
return Err(ApiError::SelfHostedDidWebDisabled);
201
+
}
202
+
let encoded_handle = handle.replace(':', "%3A");
203
+
Ok(format!("did:web:{}", encoded_handle))
204
+
}
205
+
206
+
pub enum CredentialMatch {
207
+
MainPassword,
208
+
AppPassword {
209
+
name: String,
210
+
scopes: Option<String>,
211
+
controller_did: Option<Did>,
212
+
},
213
+
}
214
+
215
+
pub async fn verify_credential(
216
+
session_repo: &dyn SessionRepository,
217
+
user_id: uuid::Uuid,
218
+
password: &str,
219
+
password_hash: Option<&str>,
220
+
) -> Option<CredentialMatch> {
221
+
let main_valid = password_hash
222
+
.map(|h| bcrypt::verify(password, h).unwrap_or(false))
223
+
.unwrap_or(false);
224
+
if main_valid {
225
+
return Some(CredentialMatch::MainPassword);
226
+
}
227
+
let app_passwords = session_repo
228
+
.get_app_passwords_for_login(user_id)
229
+
.await
230
+
.unwrap_or_default();
231
+
app_passwords
232
+
.into_iter()
233
+
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
234
+
.map(|app| CredentialMatch::AppPassword {
235
+
name: app.name,
236
+
scopes: app.scopes,
237
+
controller_did: app.created_by_controller_did,
238
+
})
239
+
}
240
+
241
+
pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
242
+
bcrypt::hash(value, DEFAULT_COST).map_err(|e| {
243
+
error!("Bcrypt hash error: {:?}", e);
244
+
ApiError::InternalError(None)
245
+
})
246
+
}
247
+
248
+
pub fn validate_token_hash(
249
+
expires_at: Option<DateTime<Utc>>,
250
+
stored_hash: &str,
251
+
input_token: &str,
252
+
expired_err: ApiError,
253
+
invalid_err: ApiError,
254
+
) -> Result<(), ApiError> {
255
+
match expires_at {
256
+
Some(exp) if exp < Utc::now() => Err(expired_err),
257
+
_ => match bcrypt::verify(input_token, stored_hash).unwrap_or(false) {
258
+
true => Ok(()),
259
+
false => Err(invalid_err),
260
+
},
261
+
}
262
+
}
+2
-2
crates/tranquil-api/src/lib.rs
+2
-2
crates/tranquil-api/src/lib.rs
···
1
1
pub mod actor;
2
2
pub mod admin;
3
3
pub mod age_assurance;
4
+
pub mod common;
4
5
pub mod delegation;
5
6
pub mod discord_webhook;
6
7
pub mod identity;
···
10
11
pub mod server;
11
12
pub mod telegram_webhook;
12
13
pub mod temp;
13
-
pub mod verification;
14
14
15
15
use tranquil_pds::state::AppState;
16
16
···
386
386
)
387
387
.route(
388
388
"/_account.confirmChannelVerification",
389
-
post(verification::confirm_channel_verification),
389
+
post(server::confirm_channel_verification),
390
390
)
391
391
.route("/_account.verifyToken", post(server::verify_token))
392
392
.route(
+50
-1
crates/tranquil-pds/src/api/error.rs
+50
-1
crates/tranquil-pds/src/api/error.rs
···
112
112
SsoLinkNotFound,
113
113
AuthFactorTokenRequired,
114
114
LegacyLoginBlocked,
115
+
ReauthRequired {
116
+
methods: Vec<String>,
117
+
},
118
+
MfaVerificationRequiredWithMethods {
119
+
methods: Vec<String>,
120
+
},
115
121
}
116
122
117
123
impl ApiError {
···
131
137
| Self::InvalidPassword(_)
132
138
| Self::InvalidToken(_)
133
139
| Self::PasskeyCounterAnomaly
134
-
| Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED,
140
+
| Self::OAuthExpiredToken(_)
141
+
| Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED,
135
142
Self::InvalidCode(_) => StatusCode::BAD_REQUEST,
136
143
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
137
144
Self::Forbidden
···
142
149
| Self::AccountMigrated
143
150
| Self::AccountNotVerified
144
151
| Self::MfaVerificationRequired
152
+
| Self::MfaVerificationRequiredWithMethods { .. }
145
153
| Self::AuthorizationError(_) => StatusCode::FORBIDDEN,
146
154
Self::RateLimitExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
147
155
Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
···
310
318
Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"),
311
319
Self::AuthFactorTokenRequired => Cow::Borrowed("AuthFactorTokenRequired"),
312
320
Self::LegacyLoginBlocked => Cow::Borrowed("MfaRequired"),
321
+
Self::ReauthRequired { .. } => Cow::Borrowed("ReauthRequired"),
322
+
Self::MfaVerificationRequiredWithMethods { .. } => {
323
+
Cow::Borrowed("MfaVerificationRequired")
324
+
}
313
325
}
314
326
}
315
327
fn message(&self) -> String {
···
471
483
Self::AuthFactorTokenRequired => {
472
484
"A sign-in code has been sent to your email address".into()
473
485
}
486
+
Self::ReauthRequired { .. } => {
487
+
"Re-authentication required for this action".into()
488
+
}
489
+
Self::MfaVerificationRequiredWithMethods { .. } => {
490
+
"This sensitive operation requires MFA verification".into()
491
+
}
474
492
}
475
493
}
476
494
pub fn from_upstream_response(status: StatusCode, body: &[u8]) -> Self {
···
499
517
500
518
impl IntoResponse for ApiError {
501
519
fn into_response(self) -> Response {
520
+
match self {
521
+
Self::ReauthRequired { ref methods } => {
522
+
return (
523
+
self.status_code(),
524
+
Json(serde_json::json!({
525
+
"error": "ReauthRequired",
526
+
"message": "Re-authentication required for this action",
527
+
"reauthMethods": methods,
528
+
})),
529
+
)
530
+
.into_response();
531
+
}
532
+
Self::MfaVerificationRequiredWithMethods { ref methods } => {
533
+
return (
534
+
self.status_code(),
535
+
Json(serde_json::json!({
536
+
"error": "MfaVerificationRequired",
537
+
"message": "This sensitive operation requires MFA verification",
538
+
"reauthMethods": methods,
539
+
})),
540
+
)
541
+
.into_response();
542
+
}
543
+
_ => {}
544
+
}
502
545
let body = ErrorBody {
503
546
error: self.error_name(),
504
547
message: self.message(),
···
594
637
}
595
638
}
596
639
640
+
impl From<crate::auth::scope_verified::ScopeVerificationError> for ApiError {
641
+
fn from(e: crate::auth::scope_verified::ScopeVerificationError) -> Self {
642
+
Self::InsufficientScope(Some(e.to_string()))
643
+
}
644
+
}
645
+
597
646
impl From<crate::handle::HandleResolutionError> for ApiError {
598
647
fn from(e: crate::handle::HandleResolutionError) -> Self {
599
648
match e {
+4
-2
crates/tranquil-pds/src/api/mod.rs
+4
-2
crates/tranquil-pds/src/api/mod.rs
···
7
7
pub use error::ApiError;
8
8
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_limit};
9
9
pub use responses::{
10
-
DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse,
11
-
StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse,
10
+
AccountsOutput, AuditLogOutput, ControllersOutput, DidResponse, EmailUpdateStatusOutput,
11
+
EmptyResponse, EnabledResponse, HasPasswordResponse, InUseOutput, OptionsResponse,
12
+
PasswordResetOutput, PreferredLocaleOutput, PresetsOutput, StatusResponse, SuccessResponse,
13
+
TokenRequiredResponse, VerifiedResponse,
12
14
};
+1
-1
crates/tranquil-pds/src/api/proxy.rs
+1
-1
crates/tranquil-pds/src/api/proxy.rs
+57
crates/tranquil-pds/src/api/responses.rs
+57
crates/tranquil-pds/src/api/responses.rs
···
116
116
Json(Self { options })
117
117
}
118
118
}
119
+
120
+
#[derive(Debug, Serialize)]
121
+
#[serde(rename_all = "camelCase")]
122
+
pub struct AccountsOutput<T: Serialize> {
123
+
pub accounts: T,
124
+
}
125
+
126
+
#[derive(Debug, Serialize)]
127
+
#[serde(rename_all = "camelCase")]
128
+
pub struct AuditLogOutput<T: Serialize> {
129
+
pub entries: T,
130
+
pub total: i64,
131
+
}
132
+
133
+
#[derive(Debug, Serialize)]
134
+
#[serde(rename_all = "camelCase")]
135
+
pub struct ControllersOutput<T: Serialize> {
136
+
pub controllers: T,
137
+
}
138
+
139
+
#[derive(Debug, Serialize)]
140
+
#[serde(rename_all = "camelCase")]
141
+
pub struct PresetsOutput<T: Serialize> {
142
+
pub presets: T,
143
+
}
144
+
145
+
#[derive(Debug, Serialize)]
146
+
#[serde(rename_all = "camelCase")]
147
+
pub struct EmailUpdateStatusOutput {
148
+
pub pending: bool,
149
+
pub authorized: bool,
150
+
pub new_email: Option<String>,
151
+
}
152
+
153
+
#[derive(Debug, Serialize)]
154
+
#[serde(rename_all = "camelCase")]
155
+
pub struct InUseOutput {
156
+
pub in_use: bool,
157
+
}
158
+
159
+
#[derive(Debug, Serialize)]
160
+
#[serde(rename_all = "camelCase")]
161
+
pub struct PasswordResetOutput {
162
+
pub success: bool,
163
+
#[serde(skip_serializing_if = "Option::is_none")]
164
+
pub multiple_accounts: Option<bool>,
165
+
#[serde(skip_serializing_if = "Option::is_none")]
166
+
pub account_count: Option<i64>,
167
+
#[serde(skip_serializing_if = "Option::is_none")]
168
+
pub message: Option<String>,
169
+
}
170
+
171
+
#[derive(Debug, Serialize)]
172
+
#[serde(rename_all = "camelCase")]
173
+
pub struct PreferredLocaleOutput {
174
+
pub preferred_locale: Option<String>,
175
+
}
+7
-10
crates/tranquil-pds/src/auth/account_verified.rs
+7
-10
crates/tranquil-pds/src/auth/account_verified.rs
···
1
-
use axum::response::{IntoResponse, Response};
2
-
3
1
use super::AuthenticatedUser;
4
2
use crate::api::error::ApiError;
5
3
use crate::state::AppState;
···
22
20
pub async fn require_verified_or_delegated<'a>(
23
21
state: &AppState,
24
22
user: &'a AuthenticatedUser,
25
-
) -> Result<AccountVerified<'a>, Response> {
23
+
) -> Result<AccountVerified<'a>, ApiError> {
26
24
let is_verified = state
27
25
.user_repo
28
26
.has_verified_comms_channel(&user.did)
···
43
41
return Ok(AccountVerified { user });
44
42
}
45
43
46
-
Err(ApiError::AccountNotVerified.into_response())
44
+
Err(ApiError::AccountNotVerified)
47
45
}
48
46
49
-
pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), Response> {
47
+
pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), ApiError> {
50
48
match state.user_repo.is_account_migrated(did).await {
51
-
Ok(true) => Err(ApiError::AccountMigrated.into_response()),
49
+
Ok(true) => Err(ApiError::AccountMigrated),
52
50
Ok(false) => Ok(()),
53
51
Err(e) => {
54
52
tracing::error!("Failed to check migration status: {:?}", e);
55
-
Err(
56
-
ApiError::InternalError(Some("Failed to verify migration status".into()))
57
-
.into_response(),
58
-
)
53
+
Err(ApiError::InternalError(Some(
54
+
"Failed to verify migration status".into(),
55
+
)))
59
56
}
60
57
}
61
58
}
+22
-4
crates/tranquil-pds/src/auth/extractor.rs
+22
-4
crates/tranquil-pds/src/auth/extractor.rs
···
12
12
is_service_token, scope_verified::VerifyScope, validate_bearer_token_for_service_auth,
13
13
};
14
14
use crate::api::error::ApiError;
15
-
use crate::oauth::scopes::{RepoAction, ScopePermissions};
15
+
use crate::oauth::scopes::{AccountAction, AccountAttr, RepoAction, ScopePermissions};
16
16
use crate::state::AppState;
17
17
use crate::types::Did;
18
18
use crate::util::build_full_url;
···
130
130
None
131
131
}
132
132
133
+
pub fn extract_jti_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
134
+
let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?;
135
+
let token = extract_bearer_token_from_header(Some(auth_header))?;
136
+
tranquil_auth::get_jti_from_token(&token).ok()
137
+
}
138
+
133
139
pub trait AuthPolicy: Send + Sync + 'static {
134
140
fn validate(user: &AuthenticatedUser) -> Result<(), AuthError>;
135
141
}
···
356
362
self.0.permissions()
357
363
}
358
364
359
-
#[allow(clippy::result_large_err)]
360
-
pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), Response> {
365
+
pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), ApiError> {
361
366
if !self.needs_scope_check() {
362
367
return Ok(());
363
368
}
364
369
self.permissions()
365
370
.assert_repo(action, collection)
366
-
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
371
+
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
372
+
}
373
+
374
+
pub fn check_account_scope(
375
+
&self,
376
+
attr: AccountAttr,
377
+
action: AccountAction,
378
+
) -> Result<(), ApiError> {
379
+
if !self.needs_scope_check() {
380
+
return Ok(());
381
+
}
382
+
self.permissions()
383
+
.assert_account(attr, action)
384
+
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
367
385
}
368
386
}
369
387
+43
-11
crates/tranquil-pds/src/auth/mfa_verified.rs
+43
-11
crates/tranquil-pds/src/auth/mfa_verified.rs
···
1
-
use axum::response::Response;
1
+
use crate::api::error::ApiError;
2
2
3
3
use super::AuthenticatedUser;
4
4
use crate::state::AppState;
···
73
73
pub async fn require_legacy_session_mfa<'a>(
74
74
state: &AppState,
75
75
user: &'a AuthenticatedUser,
76
-
) -> Result<MfaVerified<'a>, Response> {
77
-
use crate::auth::reauth::{check_legacy_session_mfa, legacy_mfa_required_response};
76
+
) -> Result<MfaVerified<'a>, ApiError> {
77
+
use crate::auth::reauth::check_legacy_session_mfa;
78
78
79
79
if check_legacy_session_mfa(&*state.session_repo, &user.did).await {
80
80
Ok(MfaVerified::from_session_reauth(user))
81
81
} else {
82
-
Err(legacy_mfa_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
82
+
let methods = crate::auth::reauth::get_available_reauth_methods(
83
+
&*state.user_repo,
84
+
&*state.session_repo,
85
+
&user.did,
86
+
)
87
+
.await;
88
+
Err(ApiError::MfaVerificationRequiredWithMethods {
89
+
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
90
+
})
83
91
}
84
92
}
85
93
86
94
pub async fn require_reauth_window<'a>(
87
95
state: &AppState,
88
96
user: &'a AuthenticatedUser,
89
-
) -> Result<MfaVerified<'a>, Response> {
90
-
use crate::auth::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response};
97
+
) -> Result<MfaVerified<'a>, ApiError> {
98
+
use crate::auth::reauth::REAUTH_WINDOW_SECONDS;
91
99
use chrono::Utc;
92
100
93
101
let status = state
···
105
113
return Ok(MfaVerified::from_session_reauth(user));
106
114
}
107
115
}
108
-
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
116
+
let methods = crate::auth::reauth::get_available_reauth_methods(
117
+
&*state.user_repo,
118
+
&*state.session_repo,
119
+
&user.did,
120
+
)
121
+
.await;
122
+
Err(ApiError::ReauthRequired {
123
+
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
124
+
})
109
125
}
110
126
None => {
111
-
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
127
+
let methods = crate::auth::reauth::get_available_reauth_methods(
128
+
&*state.user_repo,
129
+
&*state.session_repo,
130
+
&user.did,
131
+
)
132
+
.await;
133
+
Err(ApiError::ReauthRequired {
134
+
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
135
+
})
112
136
}
113
137
}
114
138
}
···
116
140
pub async fn require_reauth_window_if_available<'a>(
117
141
state: &AppState,
118
142
user: &'a AuthenticatedUser,
119
-
) -> Result<Option<MfaVerified<'a>>, Response> {
120
-
use crate::auth::reauth::{check_reauth_required_cached, reauth_required_response};
143
+
) -> Result<Option<MfaVerified<'a>>, ApiError> {
144
+
use crate::auth::reauth::check_reauth_required_cached;
121
145
122
146
let has_password = state
123
147
.user_repo
···
144
168
}
145
169
146
170
if check_reauth_required_cached(&*state.session_repo, &state.cache, &user.did).await {
147
-
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
171
+
let methods = crate::auth::reauth::get_available_reauth_methods(
172
+
&*state.user_repo,
173
+
&*state.session_repo,
174
+
&user.did,
175
+
)
176
+
.await;
177
+
Err(ApiError::ReauthRequired {
178
+
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
179
+
})
148
180
} else {
149
181
Ok(Some(MfaVerified::from_session_reauth(user)))
150
182
}
+1
-1
crates/tranquil-pds/src/auth/mod.rs
+1
-1
crates/tranquil-pds/src/auth/mod.rs
···
29
29
pub use extractor::{
30
30
Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, AuthScheme, ExtractedToken,
31
31
NotTakendown, Permissive, ServiceAuth, extract_auth_token_from_header,
32
-
extract_bearer_token_from_header,
32
+
extract_bearer_token_from_header, extract_jti_from_headers,
33
33
};
34
34
pub use mfa_verified::{
35
35
MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window,
+11
-1
crates/tranquil-pds/src/auth/reauth.rs
+11
-1
crates/tranquil-pds/src/auth/reauth.rs
···
17
17
Passkey,
18
18
}
19
19
20
+
impl ReauthMethod {
21
+
pub fn as_str(&self) -> &'static str {
22
+
match self {
23
+
Self::Password => "password",
24
+
Self::Totp => "totp",
25
+
Self::Passkey => "passkey",
26
+
}
27
+
}
28
+
}
29
+
20
30
fn is_reauth_required(last_reauth_at: Option<chrono::DateTime<Utc>>) -> bool {
21
31
match last_reauth_at {
22
32
None => true,
···
27
37
}
28
38
}
29
39
30
-
async fn get_available_reauth_methods(
40
+
pub async fn get_available_reauth_methods(
31
41
user_repo: &dyn UserRepository,
32
42
_session_repo: &dyn SessionRepository,
33
43
did: &crate::types::Did,
+10
-14
crates/tranquil-pds/src/auth/scope_check.rs
+10
-14
crates/tranquil-pds/src/auth/scope_check.rs
···
1
-
#![allow(clippy::result_large_err)]
2
-
3
-
use axum::response::{IntoResponse, Response};
4
-
5
1
use crate::api::error::ApiError;
6
2
use crate::oauth::scopes::{
7
3
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
···
24
20
scope: Option<&str>,
25
21
action: RepoAction,
26
22
collection: &str,
27
-
) -> Result<(), Response> {
23
+
) -> Result<(), ApiError> {
28
24
if !requires_scope_check(auth_source, scope) {
29
25
return Ok(());
30
26
}
···
32
28
let permissions = ScopePermissions::from_scope_string(scope);
33
29
permissions
34
30
.assert_repo(action, collection)
35
-
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
31
+
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
36
32
}
37
33
38
34
pub fn check_blob_scope(
39
35
auth_source: &AuthSource,
40
36
scope: Option<&str>,
41
37
mime: &str,
42
-
) -> Result<(), Response> {
38
+
) -> Result<(), ApiError> {
43
39
if !requires_scope_check(auth_source, scope) {
44
40
return Ok(());
45
41
}
···
47
43
let permissions = ScopePermissions::from_scope_string(scope);
48
44
permissions
49
45
.assert_blob(mime)
50
-
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
46
+
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
51
47
}
52
48
53
49
pub fn check_rpc_scope(
···
55
51
scope: Option<&str>,
56
52
aud: &str,
57
53
lxm: &str,
58
-
) -> Result<(), Response> {
54
+
) -> Result<(), ApiError> {
59
55
if !requires_scope_check(auth_source, scope) {
60
56
return Ok(());
61
57
}
···
63
59
let permissions = ScopePermissions::from_scope_string(scope);
64
60
permissions
65
61
.assert_rpc(aud, lxm)
66
-
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
62
+
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
67
63
}
68
64
69
65
pub fn check_account_scope(
···
71
67
scope: Option<&str>,
72
68
attr: AccountAttr,
73
69
action: AccountAction,
74
-
) -> Result<(), Response> {
70
+
) -> Result<(), ApiError> {
75
71
if !requires_scope_check(auth_source, scope) {
76
72
return Ok(());
77
73
}
···
79
75
let permissions = ScopePermissions::from_scope_string(scope);
80
76
permissions
81
77
.assert_account(attr, action)
82
-
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
78
+
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
83
79
}
84
80
85
81
pub fn check_identity_scope(
86
82
auth_source: &AuthSource,
87
83
scope: Option<&str>,
88
84
attr: IdentityAttr,
89
-
) -> Result<(), Response> {
85
+
) -> Result<(), ApiError> {
90
86
if !requires_scope_check(auth_source, scope) {
91
87
return Ok(());
92
88
}
···
94
90
let permissions = ScopePermissions::from_scope_string(scope);
95
91
permissions
96
92
.assert_identity(attr)
97
-
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
93
+
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
98
94
}
History
1 round
0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
refactor(api): extract common helpers module, extend API error types with auth methods
expand 0 comments
pull request successfully merged