- auth extraction should be happening in the auth crate, yes, who coulda thought
- include: scope should actually be doing the right thing and going out and requesting stuff to expand out the perms
- more tests!!!1!
- more correct parsing of the #bsky-appview or whatever suffixes on did webs that come through auth
-77
.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json
-77
.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "token",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "request_uri",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "provider: SsoProviderType",
19
-
"type_info": {
20
-
"Custom": {
21
-
"name": "sso_provider_type",
22
-
"kind": {
23
-
"Enum": [
24
-
"github",
25
-
"discord",
26
-
"google",
27
-
"gitlab",
28
-
"oidc"
29
-
]
30
-
}
31
-
}
32
-
}
33
-
},
34
-
{
35
-
"ordinal": 3,
36
-
"name": "provider_user_id",
37
-
"type_info": "Text"
38
-
},
39
-
{
40
-
"ordinal": 4,
41
-
"name": "provider_username",
42
-
"type_info": "Text"
43
-
},
44
-
{
45
-
"ordinal": 5,
46
-
"name": "provider_email",
47
-
"type_info": "Text"
48
-
},
49
-
{
50
-
"ordinal": 6,
51
-
"name": "created_at",
52
-
"type_info": "Timestamptz"
53
-
},
54
-
{
55
-
"ordinal": 7,
56
-
"name": "expires_at",
57
-
"type_info": "Timestamptz"
58
-
}
59
-
],
60
-
"parameters": {
61
-
"Left": [
62
-
"Text"
63
-
]
64
-
},
65
-
"nullable": [
66
-
false,
67
-
false,
68
-
false,
69
-
false,
70
-
true,
71
-
true,
72
-
false,
73
-
false
74
-
]
75
-
},
76
-
"hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82"
77
-
}
···
-77
.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json
-77
.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "token",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "request_uri",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "provider: SsoProviderType",
19
-
"type_info": {
20
-
"Custom": {
21
-
"name": "sso_provider_type",
22
-
"kind": {
23
-
"Enum": [
24
-
"github",
25
-
"discord",
26
-
"google",
27
-
"gitlab",
28
-
"oidc"
29
-
]
30
-
}
31
-
}
32
-
}
33
-
},
34
-
{
35
-
"ordinal": 3,
36
-
"name": "provider_user_id",
37
-
"type_info": "Text"
38
-
},
39
-
{
40
-
"ordinal": 4,
41
-
"name": "provider_username",
42
-
"type_info": "Text"
43
-
},
44
-
{
45
-
"ordinal": 5,
46
-
"name": "provider_email",
47
-
"type_info": "Text"
48
-
},
49
-
{
50
-
"ordinal": 6,
51
-
"name": "created_at",
52
-
"type_info": "Timestamptz"
53
-
},
54
-
{
55
-
"ordinal": 7,
56
-
"name": "expires_at",
57
-
"type_info": "Timestamptz"
58
-
}
59
-
],
60
-
"parameters": {
61
-
"Left": [
62
-
"Text"
63
-
]
64
-
},
65
-
"nullable": [
66
-
false,
67
-
false,
68
-
false,
69
-
false,
70
-
true,
71
-
true,
72
-
false,
73
-
false
74
-
]
75
-
},
76
-
"hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a"
77
-
}
···
-22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
-22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "email_verified",
9
-
"type_info": "Bool"
10
-
}
11
-
],
12
-
"parameters": {
13
-
"Left": [
14
-
"Text"
15
-
]
16
-
},
17
-
"nullable": [
18
-
false
19
-
]
20
-
},
21
-
"hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4"
22
-
}
···
-31
.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json
-31
.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Text",
9
-
{
10
-
"Custom": {
11
-
"name": "sso_provider_type",
12
-
"kind": {
13
-
"Enum": [
14
-
"github",
15
-
"discord",
16
-
"google",
17
-
"gitlab",
18
-
"oidc"
19
-
]
20
-
}
21
-
}
22
-
},
23
-
"Text",
24
-
"Text",
25
-
"Text"
26
-
]
27
-
},
28
-
"nullable": []
29
-
},
30
-
"hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6"
31
-
}
···
-32
.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json
-32
.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Text",
9
-
"Text",
10
-
{
11
-
"Custom": {
12
-
"name": "sso_provider_type",
13
-
"kind": {
14
-
"Enum": [
15
-
"github",
16
-
"discord",
17
-
"google",
18
-
"gitlab",
19
-
"oidc"
20
-
]
21
-
}
22
-
}
23
-
},
24
-
"Text",
25
-
"Text",
26
-
"Text"
27
-
]
28
-
},
29
-
"nullable": []
30
-
},
31
-
"hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a"
32
-
}
···
+2
Cargo.lock
+2
Cargo.lock
+7
-12
crates/tranquil-pds/src/api/actor/preferences.rs
+7
-12
crates/tranquil-pds/src/api/actor/preferences.rs
···
1
use crate::api::error::ApiError;
2
-
use crate::auth::BearerAuthAllowDeactivated;
3
use crate::state::AppState;
4
use axum::{
5
Json,
···
32
pub struct GetPreferencesOutput {
33
pub preferences: Vec<Value>,
34
}
35
-
pub async fn get_preferences(
36
-
State(state): State<AppState>,
37
-
auth: BearerAuthAllowDeactivated,
38
-
) -> Response {
39
-
let auth_user = auth.0;
40
-
let has_full_access = auth_user.permissions().has_full_access();
41
-
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth_user.did).await {
42
Ok(Some(id)) => id,
43
_ => {
44
return ApiError::InternalError(Some("User not found".into())).into_response();
···
93
}
94
pub async fn put_preferences(
95
State(state): State<AppState>,
96
-
auth: BearerAuthAllowDeactivated,
97
Json(input): Json<PutPreferencesInput>,
98
) -> Response {
99
-
let auth_user = auth.0;
100
-
let has_full_access = auth_user.permissions().has_full_access();
101
-
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth_user.did).await {
102
Ok(Some(id)) => id,
103
_ => {
104
return ApiError::InternalError(Some("User not found".into())).into_response();
···
1
use crate::api::error::ApiError;
2
+
use crate::auth::{Auth, NotTakendown, Permissive};
3
use crate::state::AppState;
4
use axum::{
5
Json,
···
32
pub struct GetPreferencesOutput {
33
pub preferences: Vec<Value>,
34
}
35
+
pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Permissive>) -> Response {
36
+
let has_full_access = auth.permissions().has_full_access();
37
+
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth.did).await {
38
Ok(Some(id)) => id,
39
_ => {
40
return ApiError::InternalError(Some("User not found".into())).into_response();
···
89
}
90
pub async fn put_preferences(
91
State(state): State<AppState>,
92
+
auth: Auth<NotTakendown>,
93
Json(input): Json<PutPreferencesInput>,
94
) -> Response {
95
+
let has_full_access = auth.permissions().has_full_access();
96
+
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth.did).await {
97
Ok(Some(id)) => id,
98
_ => {
99
return ApiError::InternalError(Some("User not found".into())).into_response();
+20
-18
crates/tranquil-pds/src/api/admin/account/delete.rs
+20
-18
crates/tranquil-pds/src/api/admin/account/delete.rs
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
-
use crate::auth::BearerAuthAdmin;
4
use crate::state::AppState;
5
use crate::types::Did;
6
use axum::{
···
18
19
pub async fn delete_account(
20
State(state): State<AppState>,
21
-
_auth: BearerAuthAdmin,
22
Json(input): Json<DeleteAccountInput>,
23
-
) -> Response {
24
let did = &input.did;
25
-
let (user_id, handle) = match state.user_repo.get_id_and_handle_by_did(did).await {
26
-
Ok(Some(row)) => (row.id, row.handle),
27
-
Ok(None) => {
28
-
return ApiError::AccountNotFound.into_response();
29
-
}
30
-
Err(e) => {
31
error!("DB error in delete_account: {:?}", e);
32
-
return ApiError::InternalError(None).into_response();
33
-
}
34
-
};
35
-
if let Err(e) = state
36
.user_repo
37
.admin_delete_account_complete(user_id, did)
38
.await
39
-
{
40
-
error!("Failed to delete account {}: {:?}", did, e);
41
-
return ApiError::InternalError(Some("Failed to delete account".into())).into_response();
42
-
}
43
if let Err(e) =
44
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await
45
{
···
49
);
50
}
51
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
52
-
EmptyResponse::ok().into_response()
53
}
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
+
use crate::auth::{Admin, Auth};
4
use crate::state::AppState;
5
use crate::types::Did;
6
use axum::{
···
18
19
pub async fn delete_account(
20
State(state): State<AppState>,
21
+
_auth: Auth<Admin>,
22
Json(input): Json<DeleteAccountInput>,
23
+
) -> Result<Response, ApiError> {
24
let did = &input.did;
25
+
let (user_id, handle) = state
26
+
.user_repo
27
+
.get_id_and_handle_by_did(did)
28
+
.await
29
+
.map_err(|e| {
30
error!("DB error in delete_account: {:?}", e);
31
+
ApiError::InternalError(None)
32
+
})?
33
+
.ok_or(ApiError::AccountNotFound)
34
+
.map(|row| (row.id, row.handle))?;
35
+
36
+
state
37
.user_repo
38
.admin_delete_account_complete(user_id, did)
39
.await
40
+
.map_err(|e| {
41
+
error!("Failed to delete account {}: {:?}", did, e);
42
+
ApiError::InternalError(Some("Failed to delete account".into()))
43
+
})?;
44
+
45
if let Err(e) =
46
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await
47
{
···
51
);
52
}
53
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
54
+
Ok(EmptyResponse::ok().into_response())
55
}
+16
-21
crates/tranquil-pds/src/api/admin/account/email.rs
+16
-21
crates/tranquil-pds/src/api/admin/account/email.rs
···
1
use crate::api::error::{ApiError, AtpJson};
2
-
use crate::auth::BearerAuthAdmin;
3
use crate::state::AppState;
4
use crate::types::Did;
5
use axum::{
···
28
29
pub async fn send_email(
30
State(state): State<AppState>,
31
-
_auth: BearerAuthAdmin,
32
AtpJson(input): AtpJson<SendEmailInput>,
33
-
) -> Response {
34
let content = input.content.trim();
35
if content.is_empty() {
36
-
return ApiError::InvalidRequest("content is required".into()).into_response();
37
}
38
-
let user = match state.user_repo.get_by_did(&input.recipient_did).await {
39
-
Ok(Some(row)) => row,
40
-
Ok(None) => {
41
-
return ApiError::AccountNotFound.into_response();
42
-
}
43
-
Err(e) => {
44
error!("DB error in send_email: {:?}", e);
45
-
return ApiError::InternalError(None).into_response();
46
-
}
47
-
};
48
-
let email = match user.email {
49
-
Some(e) => e,
50
-
None => {
51
-
return ApiError::NoEmail.into_response();
52
-
}
53
-
};
54
let (user_id, handle) = (user.id, user.handle);
55
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
56
let subject = input
···
76
handle,
77
input.recipient_did
78
);
79
-
(StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response()
80
}
81
Err(e) => {
82
warn!("Failed to enqueue admin email: {:?}", e);
83
-
(StatusCode::OK, Json(SendEmailOutput { sent: false })).into_response()
84
}
85
}
86
}
···
1
use crate::api::error::{ApiError, AtpJson};
2
+
use crate::auth::{Admin, Auth};
3
use crate::state::AppState;
4
use crate::types::Did;
5
use axum::{
···
28
29
pub async fn send_email(
30
State(state): State<AppState>,
31
+
_auth: Auth<Admin>,
32
AtpJson(input): AtpJson<SendEmailInput>,
33
+
) -> Result<Response, ApiError> {
34
let content = input.content.trim();
35
if content.is_empty() {
36
+
return Err(ApiError::InvalidRequest("content is required".into()));
37
}
38
+
let user = state
39
+
.user_repo
40
+
.get_by_did(&input.recipient_did)
41
+
.await
42
+
.map_err(|e| {
43
error!("DB error in send_email: {:?}", e);
44
+
ApiError::InternalError(None)
45
+
})?
46
+
.ok_or(ApiError::AccountNotFound)?;
47
+
48
+
let email = user.email.ok_or(ApiError::NoEmail)?;
49
let (user_id, handle) = (user.id, user.handle);
50
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
51
let subject = input
···
71
handle,
72
input.recipient_did
73
);
74
+
Ok((StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response())
75
}
76
Err(e) => {
77
warn!("Failed to enqueue admin email: {:?}", e);
78
+
Ok((StatusCode::OK, Json(SendEmailOutput { sent: false })).into_response())
79
}
80
}
81
}
+18
-24
crates/tranquil-pds/src/api/admin/account/info.rs
+18
-24
crates/tranquil-pds/src/api/admin/account/info.rs
···
1
use crate::api::error::ApiError;
2
-
use crate::auth::BearerAuthAdmin;
3
use crate::state::AppState;
4
use crate::types::{Did, Handle};
5
use axum::{
···
67
68
pub async fn get_account_info(
69
State(state): State<AppState>,
70
-
_auth: BearerAuthAdmin,
71
Query(params): Query<GetAccountInfoParams>,
72
-
) -> Response {
73
-
let account = match state
74
.infra_repo
75
.get_admin_account_info_by_did(¶ms.did)
76
.await
77
-
{
78
-
Ok(Some(a)) => a,
79
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
80
-
Err(e) => {
81
error!("DB error in get_account_info: {:?}", e);
82
-
return ApiError::InternalError(None).into_response();
83
-
}
84
-
};
85
86
let invited_by = get_invited_by(&state, account.id).await;
87
let invites = get_invites_for_user(&state, account.id).await;
88
89
-
(
90
StatusCode::OK,
91
Json(AccountInfo {
92
did: account.did,
···
105
invites,
106
}),
107
)
108
-
.into_response()
109
}
110
111
async fn get_invited_by(state: &AppState, user_id: uuid::Uuid) -> Option<InviteCodeInfo> {
···
200
201
pub async fn get_account_infos(
202
State(state): State<AppState>,
203
-
_auth: BearerAuthAdmin,
204
RawQuery(raw_query): RawQuery,
205
-
) -> Response {
206
let dids: Vec<String> = crate::util::parse_repeated_query_param(raw_query.as_deref(), "dids")
207
.into_iter()
208
.filter(|d| !d.is_empty())
209
.collect();
210
211
if dids.is_empty() {
212
-
return ApiError::InvalidRequest("dids is required".into()).into_response();
213
}
214
215
let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
216
-
let accounts = match state
217
.infra_repo
218
.get_admin_account_infos_by_dids(&dids_typed)
219
.await
220
-
{
221
-
Ok(accounts) => accounts,
222
-
Err(e) => {
223
error!("Failed to fetch account infos: {:?}", e);
224
-
return ApiError::InternalError(None).into_response();
225
-
}
226
-
};
227
228
let user_ids: Vec<uuid::Uuid> = accounts.iter().map(|u| u.id).collect();
229
···
316
})
317
.collect();
318
319
-
(StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response()
320
}
···
1
use crate::api::error::ApiError;
2
+
use crate::auth::{Admin, Auth};
3
use crate::state::AppState;
4
use crate::types::{Did, Handle};
5
use axum::{
···
67
68
pub async fn get_account_info(
69
State(state): State<AppState>,
70
+
_auth: Auth<Admin>,
71
Query(params): Query<GetAccountInfoParams>,
72
+
) -> Result<Response, ApiError> {
73
+
let account = state
74
.infra_repo
75
.get_admin_account_info_by_did(¶ms.did)
76
.await
77
+
.map_err(|e| {
78
error!("DB error in get_account_info: {:?}", e);
79
+
ApiError::InternalError(None)
80
+
})?
81
+
.ok_or(ApiError::AccountNotFound)?;
82
83
let invited_by = get_invited_by(&state, account.id).await;
84
let invites = get_invites_for_user(&state, account.id).await;
85
86
+
Ok((
87
StatusCode::OK,
88
Json(AccountInfo {
89
did: account.did,
···
102
invites,
103
}),
104
)
105
+
.into_response())
106
}
107
108
async fn get_invited_by(state: &AppState, user_id: uuid::Uuid) -> Option<InviteCodeInfo> {
···
197
198
pub async fn get_account_infos(
199
State(state): State<AppState>,
200
+
_auth: Auth<Admin>,
201
RawQuery(raw_query): RawQuery,
202
+
) -> Result<Response, ApiError> {
203
let dids: Vec<String> = crate::util::parse_repeated_query_param(raw_query.as_deref(), "dids")
204
.into_iter()
205
.filter(|d| !d.is_empty())
206
.collect();
207
208
if dids.is_empty() {
209
+
return Err(ApiError::InvalidRequest("dids is required".into()));
210
}
211
212
let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
213
+
let accounts = state
214
.infra_repo
215
.get_admin_account_infos_by_dids(&dids_typed)
216
.await
217
+
.map_err(|e| {
218
error!("Failed to fetch account infos: {:?}", e);
219
+
ApiError::InternalError(None)
220
+
})?;
221
222
let user_ids: Vec<uuid::Uuid> = accounts.iter().map(|u| u.id).collect();
223
···
310
})
311
.collect();
312
313
+
Ok((StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response())
314
}
+39
-42
crates/tranquil-pds/src/api/admin/account/search.rs
+39
-42
crates/tranquil-pds/src/api/admin/account/search.rs
···
1
use crate::api::error::ApiError;
2
-
use crate::auth::BearerAuthAdmin;
3
use crate::state::AppState;
4
use crate::types::{Did, Handle};
5
use axum::{
···
50
51
pub async fn search_accounts(
52
State(state): State<AppState>,
53
-
_auth: BearerAuthAdmin,
54
Query(params): Query<SearchAccountsParams>,
55
-
) -> Response {
56
let limit = params.limit.clamp(1, 100);
57
let email_filter = params.email.as_deref().map(|e| format!("%{}%", e));
58
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
59
let cursor_did: Option<Did> = params.cursor.as_ref().and_then(|c| c.parse().ok());
60
-
let result = state
61
.user_repo
62
.search_accounts(
63
cursor_did.as_ref(),
···
65
handle_filter.as_deref(),
66
limit + 1,
67
)
68
-
.await;
69
-
match result {
70
-
Ok(rows) => {
71
-
let has_more = rows.len() > limit as usize;
72
-
let accounts: Vec<AccountView> = rows
73
-
.into_iter()
74
-
.take(limit as usize)
75
-
.map(|row| AccountView {
76
-
did: row.did.clone(),
77
-
handle: row.handle,
78
-
email: row.email,
79
-
indexed_at: row.created_at.to_rfc3339(),
80
-
email_confirmed_at: if row.email_verified {
81
-
Some(row.created_at.to_rfc3339())
82
-
} else {
83
-
None
84
-
},
85
-
deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()),
86
-
invites_disabled: row.invites_disabled,
87
-
})
88
-
.collect();
89
-
let next_cursor = if has_more {
90
-
accounts.last().map(|a| a.did.to_string())
91
} else {
92
None
93
-
};
94
-
(
95
-
StatusCode::OK,
96
-
Json(SearchAccountsOutput {
97
-
cursor: next_cursor,
98
-
accounts,
99
-
}),
100
-
)
101
-
.into_response()
102
-
}
103
-
Err(e) => {
104
-
error!("DB error in search_accounts: {:?}", e);
105
-
ApiError::InternalError(None).into_response()
106
-
}
107
-
}
108
}
···
1
use crate::api::error::ApiError;
2
+
use crate::auth::{Admin, Auth};
3
use crate::state::AppState;
4
use crate::types::{Did, Handle};
5
use axum::{
···
50
51
pub async fn search_accounts(
52
State(state): State<AppState>,
53
+
_auth: Auth<Admin>,
54
Query(params): Query<SearchAccountsParams>,
55
+
) -> Result<Response, ApiError> {
56
let limit = params.limit.clamp(1, 100);
57
let email_filter = params.email.as_deref().map(|e| format!("%{}%", e));
58
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
59
let cursor_did: Option<Did> = params.cursor.as_ref().and_then(|c| c.parse().ok());
60
+
let rows = state
61
.user_repo
62
.search_accounts(
63
cursor_did.as_ref(),
···
65
handle_filter.as_deref(),
66
limit + 1,
67
)
68
+
.await
69
+
.map_err(|e| {
70
+
error!("DB error in search_accounts: {:?}", e);
71
+
ApiError::InternalError(None)
72
+
})?;
73
+
74
+
let has_more = rows.len() > limit as usize;
75
+
let accounts: Vec<AccountView> = rows
76
+
.into_iter()
77
+
.take(limit as usize)
78
+
.map(|row| AccountView {
79
+
did: row.did.clone(),
80
+
handle: row.handle,
81
+
email: row.email,
82
+
indexed_at: row.created_at.to_rfc3339(),
83
+
email_confirmed_at: if row.email_verified {
84
+
Some(row.created_at.to_rfc3339())
85
} else {
86
None
87
+
},
88
+
deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()),
89
+
invites_disabled: row.invites_disabled,
90
+
})
91
+
.collect();
92
+
let next_cursor = if has_more {
93
+
accounts.last().map(|a| a.did.to_string())
94
+
} else {
95
+
None
96
+
};
97
+
Ok((
98
+
StatusCode::OK,
99
+
Json(SearchAccountsOutput {
100
+
cursor: next_cursor,
101
+
accounts,
102
+
}),
103
+
)
104
+
.into_response())
105
}
+39
-36
crates/tranquil-pds/src/api/admin/account/update.rs
+39
-36
crates/tranquil-pds/src/api/admin/account/update.rs
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
-
use crate::auth::BearerAuthAdmin;
4
use crate::state::AppState;
5
use crate::types::{Did, Handle, PlainPassword};
6
use axum::{
···
19
20
pub async fn update_account_email(
21
State(state): State<AppState>,
22
-
_auth: BearerAuthAdmin,
23
Json(input): Json<UpdateAccountEmailInput>,
24
-
) -> Response {
25
let account = input.account.trim();
26
let email = input.email.trim();
27
if account.is_empty() || email.is_empty() {
28
-
return ApiError::InvalidRequest("account and email are required".into()).into_response();
29
}
30
-
let account_did: Did = match account.parse() {
31
-
Ok(d) => d,
32
-
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
33
-
};
34
match state
35
.user_repo
36
.admin_update_email(&account_did, email)
37
.await
38
{
39
-
Ok(0) => ApiError::AccountNotFound.into_response(),
40
-
Ok(_) => EmptyResponse::ok().into_response(),
41
Err(e) => {
42
error!("DB error updating email: {:?}", e);
43
-
ApiError::InternalError(None).into_response()
44
}
45
}
46
}
···
53
54
pub async fn update_account_handle(
55
State(state): State<AppState>,
56
-
_auth: BearerAuthAdmin,
57
Json(input): Json<UpdateAccountHandleInput>,
58
-
) -> Response {
59
let did = &input.did;
60
let input_handle = input.handle.trim();
61
if input_handle.is_empty() {
62
-
return ApiError::InvalidRequest("handle is required".into()).into_response();
63
}
64
if !input_handle
65
.chars()
66
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
67
{
68
-
return ApiError::InvalidHandle(None).into_response();
69
}
70
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
71
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
···
75
input_handle.to_string()
76
};
77
let old_handle = state.user_repo.get_handle_by_did(did).await.ok().flatten();
78
-
let user_id = match state.user_repo.get_id_by_did(did).await {
79
-
Ok(Some(id)) => id,
80
-
_ => return ApiError::AccountNotFound.into_response(),
81
-
};
82
let handle_for_check = Handle::new_unchecked(&handle);
83
if let Ok(true) = state
84
.user_repo
85
.check_handle_exists(&handle_for_check, user_id)
86
.await
87
{
88
-
return ApiError::HandleTaken.into_response();
89
}
90
match state
91
.user_repo
92
.admin_update_handle(did, &handle_for_check)
93
.await
94
{
95
-
Ok(0) => ApiError::AccountNotFound.into_response(),
96
Ok(_) => {
97
if let Some(old) = old_handle {
98
let _ = state.cache.delete(&format!("handle:{}", old)).await;
···
115
{
116
warn!("Failed to update PLC handle for admin handle update: {}", e);
117
}
118
-
EmptyResponse::ok().into_response()
119
}
120
Err(e) => {
121
error!("DB error updating handle: {:?}", e);
122
-
ApiError::InternalError(None).into_response()
123
}
124
}
125
}
···
132
133
pub async fn update_account_password(
134
State(state): State<AppState>,
135
-
_auth: BearerAuthAdmin,
136
Json(input): Json<UpdateAccountPasswordInput>,
137
-
) -> Response {
138
let did = &input.did;
139
let password = input.password.trim();
140
if password.is_empty() {
141
-
return ApiError::InvalidRequest("password is required".into()).into_response();
142
}
143
-
let password_hash = match bcrypt::hash(password, bcrypt::DEFAULT_COST) {
144
-
Ok(h) => h,
145
-
Err(e) => {
146
-
error!("Failed to hash password: {:?}", e);
147
-
return ApiError::InternalError(None).into_response();
148
-
}
149
-
};
150
match state
151
.user_repo
152
.admin_update_password(did, &password_hash)
153
.await
154
{
155
-
Ok(0) => ApiError::AccountNotFound.into_response(),
156
-
Ok(_) => EmptyResponse::ok().into_response(),
157
Err(e) => {
158
error!("DB error updating password: {:?}", e);
159
-
ApiError::InternalError(None).into_response()
160
}
161
}
162
}
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
+
use crate::auth::{Admin, Auth};
4
use crate::state::AppState;
5
use crate::types::{Did, Handle, PlainPassword};
6
use axum::{
···
19
20
pub async fn update_account_email(
21
State(state): State<AppState>,
22
+
_auth: Auth<Admin>,
23
Json(input): Json<UpdateAccountEmailInput>,
24
+
) -> Result<Response, ApiError> {
25
let account = input.account.trim();
26
let email = input.email.trim();
27
if account.is_empty() || email.is_empty() {
28
+
return Err(ApiError::InvalidRequest(
29
+
"account and email are required".into(),
30
+
));
31
}
32
+
let account_did: Did = account
33
+
.parse()
34
+
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
35
+
36
match state
37
.user_repo
38
.admin_update_email(&account_did, email)
39
.await
40
{
41
+
Ok(0) => Err(ApiError::AccountNotFound),
42
+
Ok(_) => Ok(EmptyResponse::ok().into_response()),
43
Err(e) => {
44
error!("DB error updating email: {:?}", e);
45
+
Err(ApiError::InternalError(None))
46
}
47
}
48
}
···
55
56
pub async fn update_account_handle(
57
State(state): State<AppState>,
58
+
_auth: Auth<Admin>,
59
Json(input): Json<UpdateAccountHandleInput>,
60
+
) -> Result<Response, ApiError> {
61
let did = &input.did;
62
let input_handle = input.handle.trim();
63
if input_handle.is_empty() {
64
+
return Err(ApiError::InvalidRequest("handle is required".into()));
65
}
66
if !input_handle
67
.chars()
68
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
69
{
70
+
return Err(ApiError::InvalidHandle(None));
71
}
72
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
73
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
···
77
input_handle.to_string()
78
};
79
let old_handle = state.user_repo.get_handle_by_did(did).await.ok().flatten();
80
+
let user_id = state
81
+
.user_repo
82
+
.get_id_by_did(did)
83
+
.await
84
+
.ok()
85
+
.flatten()
86
+
.ok_or(ApiError::AccountNotFound)?;
87
let handle_for_check = Handle::new_unchecked(&handle);
88
if let Ok(true) = state
89
.user_repo
90
.check_handle_exists(&handle_for_check, user_id)
91
.await
92
{
93
+
return Err(ApiError::HandleTaken);
94
}
95
match state
96
.user_repo
97
.admin_update_handle(did, &handle_for_check)
98
.await
99
{
100
+
Ok(0) => Err(ApiError::AccountNotFound),
101
Ok(_) => {
102
if let Some(old) = old_handle {
103
let _ = state.cache.delete(&format!("handle:{}", old)).await;
···
120
{
121
warn!("Failed to update PLC handle for admin handle update: {}", e);
122
}
123
+
Ok(EmptyResponse::ok().into_response())
124
}
125
Err(e) => {
126
error!("DB error updating handle: {:?}", e);
127
+
Err(ApiError::InternalError(None))
128
}
129
}
130
}
···
137
138
pub async fn update_account_password(
139
State(state): State<AppState>,
140
+
_auth: Auth<Admin>,
141
Json(input): Json<UpdateAccountPasswordInput>,
142
+
) -> Result<Response, ApiError> {
143
let did = &input.did;
144
let password = input.password.trim();
145
if password.is_empty() {
146
+
return Err(ApiError::InvalidRequest("password is required".into()));
147
}
148
+
let password_hash = bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| {
149
+
error!("Failed to hash password: {:?}", e);
150
+
ApiError::InternalError(None)
151
+
})?;
152
+
153
match state
154
.user_repo
155
.admin_update_password(did, &password_hash)
156
.await
157
{
158
+
Ok(0) => Err(ApiError::AccountNotFound),
159
+
Ok(_) => Ok(EmptyResponse::ok().into_response()),
160
Err(e) => {
161
error!("DB error updating password: {:?}", e);
162
+
Err(ApiError::InternalError(None))
163
}
164
}
165
}
+2
-2
crates/tranquil-pds/src/api/admin/config.rs
+2
-2
crates/tranquil-pds/src/api/admin/config.rs
···
1
use crate::api::error::ApiError;
2
-
use crate::auth::BearerAuthAdmin;
3
use crate::state::AppState;
4
use axum::{Json, extract::State};
5
use serde::{Deserialize, Serialize};
···
78
79
pub async fn update_server_config(
80
State(state): State<AppState>,
81
-
_admin: BearerAuthAdmin,
82
Json(req): Json<UpdateServerConfigRequest>,
83
) -> Result<Json<UpdateServerConfigResponse>, ApiError> {
84
if let Some(server_name) = req.server_name {
···
1
use crate::api::error::ApiError;
2
+
use crate::auth::{Admin, Auth};
3
use crate::state::AppState;
4
use axum::{Json, extract::State};
5
use serde::{Deserialize, Serialize};
···
78
79
pub async fn update_server_config(
80
State(state): State<AppState>,
81
+
_auth: Auth<Admin>,
82
Json(req): Json<UpdateServerConfigRequest>,
83
) -> Result<Json<UpdateServerConfigResponse>, ApiError> {
84
if let Some(server_name) = req.server_name {
+32
-35
crates/tranquil-pds/src/api/admin/invite.rs
+32
-35
crates/tranquil-pds/src/api/admin/invite.rs
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
-
use crate::auth::BearerAuthAdmin;
4
use crate::state::AppState;
5
use axum::{
6
Json,
···
21
22
pub async fn disable_invite_codes(
23
State(state): State<AppState>,
24
-
_auth: BearerAuthAdmin,
25
Json(input): Json<DisableInviteCodesInput>,
26
-
) -> Response {
27
if let Some(codes) = &input.codes
28
&& let Err(e) = state.infra_repo.disable_invite_codes_by_code(codes).await
29
{
···
40
error!("DB error disabling invite codes by account: {:?}", e);
41
}
42
}
43
-
EmptyResponse::ok().into_response()
44
}
45
46
#[derive(Deserialize)]
···
78
79
pub async fn get_invite_codes(
80
State(state): State<AppState>,
81
-
_auth: BearerAuthAdmin,
82
Query(params): Query<GetInviteCodesParams>,
83
-
) -> Response {
84
let limit = params.limit.unwrap_or(100).clamp(1, 500);
85
let sort_order = match params.sort.as_deref() {
86
Some("usage") => InviteCodeSortOrder::Usage,
87
_ => InviteCodeSortOrder::Recent,
88
};
89
90
-
let codes_rows = match state
91
.infra_repo
92
.list_invite_codes(params.cursor.as_deref(), limit, sort_order)
93
.await
94
-
{
95
-
Ok(rows) => rows,
96
-
Err(e) => {
97
error!("DB error fetching invite codes: {:?}", e);
98
-
return ApiError::InternalError(None).into_response();
99
-
}
100
-
};
101
102
let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|r| r.created_by_user).collect();
103
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
···
155
} else {
156
None
157
};
158
-
(
159
StatusCode::OK,
160
Json(GetInviteCodesOutput {
161
cursor: next_cursor,
162
codes,
163
}),
164
)
165
-
.into_response()
166
}
167
168
#[derive(Deserialize)]
···
172
173
pub async fn disable_account_invites(
174
State(state): State<AppState>,
175
-
_auth: BearerAuthAdmin,
176
Json(input): Json<DisableAccountInvitesInput>,
177
-
) -> Response {
178
let account = input.account.trim();
179
if account.is_empty() {
180
-
return ApiError::InvalidRequest("account is required".into()).into_response();
181
}
182
-
let account_did: tranquil_types::Did = match account.parse() {
183
-
Ok(d) => d,
184
-
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
185
-
};
186
match state
187
.user_repo
188
.set_invites_disabled(&account_did, true)
189
.await
190
{
191
-
Ok(true) => EmptyResponse::ok().into_response(),
192
-
Ok(false) => ApiError::AccountNotFound.into_response(),
193
Err(e) => {
194
error!("DB error disabling account invites: {:?}", e);
195
-
ApiError::InternalError(None).into_response()
196
}
197
}
198
}
···
204
205
pub async fn enable_account_invites(
206
State(state): State<AppState>,
207
-
_auth: BearerAuthAdmin,
208
Json(input): Json<EnableAccountInvitesInput>,
209
-
) -> Response {
210
let account = input.account.trim();
211
if account.is_empty() {
212
-
return ApiError::InvalidRequest("account is required".into()).into_response();
213
}
214
-
let account_did: tranquil_types::Did = match account.parse() {
215
-
Ok(d) => d,
216
-
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
217
-
};
218
match state
219
.user_repo
220
.set_invites_disabled(&account_did, false)
221
.await
222
{
223
-
Ok(true) => EmptyResponse::ok().into_response(),
224
-
Ok(false) => ApiError::AccountNotFound.into_response(),
225
Err(e) => {
226
error!("DB error enabling account invites: {:?}", e);
227
-
ApiError::InternalError(None).into_response()
228
}
229
}
230
}
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
+
use crate::auth::{Admin, Auth};
4
use crate::state::AppState;
5
use axum::{
6
Json,
···
21
22
pub async fn disable_invite_codes(
23
State(state): State<AppState>,
24
+
_auth: Auth<Admin>,
25
Json(input): Json<DisableInviteCodesInput>,
26
+
) -> Result<Response, ApiError> {
27
if let Some(codes) = &input.codes
28
&& let Err(e) = state.infra_repo.disable_invite_codes_by_code(codes).await
29
{
···
40
error!("DB error disabling invite codes by account: {:?}", e);
41
}
42
}
43
+
Ok(EmptyResponse::ok().into_response())
44
}
45
46
#[derive(Deserialize)]
···
78
79
pub async fn get_invite_codes(
80
State(state): State<AppState>,
81
+
_auth: Auth<Admin>,
82
Query(params): Query<GetInviteCodesParams>,
83
+
) -> Result<Response, ApiError> {
84
let limit = params.limit.unwrap_or(100).clamp(1, 500);
85
let sort_order = match params.sort.as_deref() {
86
Some("usage") => InviteCodeSortOrder::Usage,
87
_ => InviteCodeSortOrder::Recent,
88
};
89
90
+
let codes_rows = state
91
.infra_repo
92
.list_invite_codes(params.cursor.as_deref(), limit, sort_order)
93
.await
94
+
.map_err(|e| {
95
error!("DB error fetching invite codes: {:?}", e);
96
+
ApiError::InternalError(None)
97
+
})?;
98
99
let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|r| r.created_by_user).collect();
100
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
···
152
} else {
153
None
154
};
155
+
Ok((
156
StatusCode::OK,
157
Json(GetInviteCodesOutput {
158
cursor: next_cursor,
159
codes,
160
}),
161
)
162
+
.into_response())
163
}
164
165
#[derive(Deserialize)]
···
169
170
pub async fn disable_account_invites(
171
State(state): State<AppState>,
172
+
_auth: Auth<Admin>,
173
Json(input): Json<DisableAccountInvitesInput>,
174
+
) -> Result<Response, ApiError> {
175
let account = input.account.trim();
176
if account.is_empty() {
177
+
return Err(ApiError::InvalidRequest("account is required".into()));
178
}
179
+
let account_did: tranquil_types::Did = account
180
+
.parse()
181
+
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
182
+
183
match state
184
.user_repo
185
.set_invites_disabled(&account_did, true)
186
.await
187
{
188
+
Ok(true) => Ok(EmptyResponse::ok().into_response()),
189
+
Ok(false) => Err(ApiError::AccountNotFound),
190
Err(e) => {
191
error!("DB error disabling account invites: {:?}", e);
192
+
Err(ApiError::InternalError(None))
193
}
194
}
195
}
···
201
202
pub async fn enable_account_invites(
203
State(state): State<AppState>,
204
+
_auth: Auth<Admin>,
205
Json(input): Json<EnableAccountInvitesInput>,
206
+
) -> Result<Response, ApiError> {
207
let account = input.account.trim();
208
if account.is_empty() {
209
+
return Err(ApiError::InvalidRequest("account is required".into()));
210
}
211
+
let account_did: tranquil_types::Did = account
212
+
.parse()
213
+
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
214
+
215
match state
216
.user_repo
217
.set_invites_disabled(&account_did, false)
218
.await
219
{
220
+
Ok(true) => Ok(EmptyResponse::ok().into_response()),
221
+
Ok(false) => Err(ApiError::AccountNotFound),
222
Err(e) => {
223
error!("DB error enabling account invites: {:?}", e);
224
+
Err(ApiError::InternalError(None))
225
}
226
}
227
}
+8
-4
crates/tranquil-pds/src/api/admin/server_stats.rs
+8
-4
crates/tranquil-pds/src/api/admin/server_stats.rs
···
1
-
use crate::auth::BearerAuthAdmin;
2
use crate::state::AppState;
3
use axum::{
4
Json,
···
16
pub blob_storage_bytes: i64,
17
}
18
19
-
pub async fn get_server_stats(State(state): State<AppState>, _auth: BearerAuthAdmin) -> Response {
20
let user_count = state.user_repo.count_users().await.unwrap_or(0);
21
let repo_count = state.repo_repo.count_repos().await.unwrap_or(0);
22
let record_count = state.repo_repo.count_all_records().await.unwrap_or(0);
23
let blob_storage_bytes = state.blob_repo.sum_blob_storage().await.unwrap_or(0);
24
25
-
Json(ServerStatsResponse {
26
user_count,
27
repo_count,
28
record_count,
29
blob_storage_bytes,
30
})
31
-
.into_response()
32
}
···
1
+
use crate::api::error::ApiError;
2
+
use crate::auth::{Admin, Auth};
3
use crate::state::AppState;
4
use axum::{
5
Json,
···
17
pub blob_storage_bytes: i64,
18
}
19
20
+
pub async fn get_server_stats(
21
+
State(state): State<AppState>,
22
+
_auth: Auth<Admin>,
23
+
) -> Result<Response, ApiError> {
24
let user_count = state.user_repo.count_users().await.unwrap_or(0);
25
let repo_count = state.repo_repo.count_repos().await.unwrap_or(0);
26
let record_count = state.repo_repo.count_all_records().await.unwrap_or(0);
27
let blob_storage_bytes = state.blob_repo.sum_blob_storage().await.unwrap_or(0);
28
29
+
Ok(Json(ServerStatsResponse {
30
user_count,
31
repo_count,
32
record_count,
33
blob_storage_bytes,
34
})
35
+
.into_response())
36
}
+73
-94
crates/tranquil-pds/src/api/admin/status.rs
+73
-94
crates/tranquil-pds/src/api/admin/status.rs
···
1
use crate::api::error::ApiError;
2
-
use crate::auth::BearerAuthAdmin;
3
use crate::state::AppState;
4
use crate::types::{CidLink, Did};
5
use axum::{
···
35
36
pub async fn get_subject_status(
37
State(state): State<AppState>,
38
-
_auth: BearerAuthAdmin,
39
Query(params): Query<GetSubjectStatusParams>,
40
-
) -> Response {
41
if params.did.is_none() && params.uri.is_none() && params.blob.is_none() {
42
-
return ApiError::InvalidRequest("Must provide did, uri, or blob".into()).into_response();
43
}
44
if let Some(did_str) = ¶ms.did {
45
-
let did: Did = match did_str.parse() {
46
-
Ok(d) => d,
47
-
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
48
-
};
49
match state.user_repo.get_status_by_did(&did).await {
50
Ok(Some(status)) => {
51
let deactivated = status.deactivated_at.map(|_| StatusAttr {
···
56
applied: true,
57
r#ref: Some(r.clone()),
58
});
59
-
return (
60
StatusCode::OK,
61
Json(SubjectStatus {
62
subject: json!({
···
67
deactivated,
68
}),
69
)
70
-
.into_response();
71
}
72
Ok(None) => {
73
-
return ApiError::SubjectNotFound.into_response();
74
}
75
Err(e) => {
76
error!("DB error in get_subject_status: {:?}", e);
77
-
return ApiError::InternalError(None).into_response();
78
}
79
}
80
}
81
if let Some(uri_str) = ¶ms.uri {
82
-
let cid: CidLink = match uri_str.parse() {
83
-
Ok(c) => c,
84
-
Err(_) => return ApiError::InvalidRequest("Invalid CID format".into()).into_response(),
85
-
};
86
match state.repo_repo.get_record_by_cid(&cid).await {
87
Ok(Some(record)) => {
88
let takedown = record.takedown_ref.as_ref().map(|r| StatusAttr {
89
applied: true,
90
r#ref: Some(r.clone()),
91
});
92
-
return (
93
StatusCode::OK,
94
Json(SubjectStatus {
95
subject: json!({
···
101
deactivated: None,
102
}),
103
)
104
-
.into_response();
105
}
106
Ok(None) => {
107
-
return ApiError::RecordNotFound.into_response();
108
}
109
Err(e) => {
110
error!("DB error in get_subject_status: {:?}", e);
111
-
return ApiError::InternalError(None).into_response();
112
}
113
}
114
}
115
if let Some(blob_cid_str) = ¶ms.blob {
116
-
let blob_cid: CidLink = match blob_cid_str.parse() {
117
-
Ok(c) => c,
118
-
Err(_) => return ApiError::InvalidRequest("Invalid CID format".into()).into_response(),
119
-
};
120
-
let did = match ¶ms.did {
121
-
Some(d) => d,
122
-
None => {
123
-
return ApiError::InvalidRequest("Must provide a did to request blob state".into())
124
-
.into_response();
125
-
}
126
-
};
127
match state.blob_repo.get_blob_with_takedown(&blob_cid).await {
128
Ok(Some(blob)) => {
129
let takedown = blob.takedown_ref.as_ref().map(|r| StatusAttr {
130
applied: true,
131
r#ref: Some(r.clone()),
132
});
133
-
return (
134
StatusCode::OK,
135
Json(SubjectStatus {
136
subject: json!({
···
142
deactivated: None,
143
}),
144
)
145
-
.into_response();
146
}
147
Ok(None) => {
148
-
return ApiError::BlobNotFound(None).into_response();
149
}
150
Err(e) => {
151
error!("DB error in get_subject_status: {:?}", e);
152
-
return ApiError::InternalError(None).into_response();
153
}
154
}
155
}
156
-
ApiError::InvalidRequest("Invalid subject type".into()).into_response()
157
}
158
159
#[derive(Deserialize)]
···
172
173
pub async fn update_subject_status(
174
State(state): State<AppState>,
175
-
_auth: BearerAuthAdmin,
176
Json(input): Json<UpdateSubjectStatusInput>,
177
-
) -> Response {
178
let subject_type = input.subject.get("$type").and_then(|t| t.as_str());
179
match subject_type {
180
Some("com.atproto.admin.defs#repoRef") => {
···
187
} else {
188
None
189
};
190
-
if let Err(e) = state.user_repo.set_user_takedown(&did, takedown_ref).await {
191
-
error!("Failed to update user takedown status for {}: {:?}", did, e);
192
-
return ApiError::InternalError(Some(
193
-
"Failed to update takedown status".into(),
194
-
))
195
-
.into_response();
196
-
}
197
}
198
if let Some(deactivated) = &input.deactivated {
199
let result = if deactivated.applied {
···
201
} else {
202
state.user_repo.activate_account(&did).await
203
};
204
-
if let Err(e) = result {
205
error!(
206
"Failed to update user deactivation status for {}: {:?}",
207
did, e
208
);
209
-
return ApiError::InternalError(Some(
210
-
"Failed to update deactivation status".into(),
211
-
))
212
-
.into_response();
213
-
}
214
}
215
if let Some(takedown) = &input.takedown {
216
let status = if takedown.applied {
···
249
if let Ok(Some(handle)) = state.user_repo.get_handle_by_did(&did).await {
250
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
251
}
252
-
return (
253
StatusCode::OK,
254
Json(json!({
255
"subject": input.subject,
···
262
}))
263
})),
264
)
265
-
.into_response();
266
}
267
}
268
Some("com.atproto.repo.strongRef") => {
269
let uri_str = input.subject.get("uri").and_then(|u| u.as_str());
270
if let Some(uri_str) = uri_str {
271
-
let cid: CidLink = match uri_str.parse() {
272
-
Ok(c) => c,
273
-
Err(_) => {
274
-
return ApiError::InvalidRequest("Invalid CID format".into())
275
-
.into_response();
276
-
}
277
-
};
278
if let Some(takedown) = &input.takedown {
279
let takedown_ref = if takedown.applied {
280
takedown.r#ref.as_deref()
281
} else {
282
None
283
};
284
-
if let Err(e) = state
285
.repo_repo
286
.set_record_takedown(&cid, takedown_ref)
287
.await
288
-
{
289
-
error!(
290
-
"Failed to update record takedown status for {}: {:?}",
291
-
uri_str, e
292
-
);
293
-
return ApiError::InternalError(Some(
294
-
"Failed to update takedown status".into(),
295
-
))
296
-
.into_response();
297
-
}
298
}
299
-
return (
300
StatusCode::OK,
301
Json(json!({
302
"subject": input.subject,
···
306
}))
307
})),
308
)
309
-
.into_response();
310
}
311
}
312
Some("com.atproto.admin.defs#repoBlobRef") => {
313
let cid_str = input.subject.get("cid").and_then(|c| c.as_str());
314
if let Some(cid_str) = cid_str {
315
-
let cid: CidLink = match cid_str.parse() {
316
-
Ok(c) => c,
317
-
Err(_) => {
318
-
return ApiError::InvalidRequest("Invalid CID format".into())
319
-
.into_response();
320
-
}
321
-
};
322
if let Some(takedown) = &input.takedown {
323
let takedown_ref = if takedown.applied {
324
takedown.r#ref.as_deref()
325
} else {
326
None
327
};
328
-
if let Err(e) = state
329
.blob_repo
330
.update_blob_takedown(&cid, takedown_ref)
331
.await
332
-
{
333
-
error!(
334
-
"Failed to update blob takedown status for {}: {:?}",
335
-
cid_str, e
336
-
);
337
-
return ApiError::InternalError(Some(
338
-
"Failed to update takedown status".into(),
339
-
))
340
-
.into_response();
341
-
}
342
}
343
-
return (
344
StatusCode::OK,
345
Json(json!({
346
"subject": input.subject,
···
350
}))
351
})),
352
)
353
-
.into_response();
354
}
355
}
356
_ => {}
357
}
358
-
ApiError::InvalidRequest("Invalid subject type".into()).into_response()
359
}
···
1
use crate::api::error::ApiError;
2
+
use crate::auth::{Admin, Auth};
3
use crate::state::AppState;
4
use crate::types::{CidLink, Did};
5
use axum::{
···
35
36
pub async fn get_subject_status(
37
State(state): State<AppState>,
38
+
_auth: Auth<Admin>,
39
Query(params): Query<GetSubjectStatusParams>,
40
+
) -> Result<Response, ApiError> {
41
if params.did.is_none() && params.uri.is_none() && params.blob.is_none() {
42
+
return Err(ApiError::InvalidRequest(
43
+
"Must provide did, uri, or blob".into(),
44
+
));
45
}
46
if let Some(did_str) = ¶ms.did {
47
+
let did: Did = did_str
48
+
.parse()
49
+
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
50
match state.user_repo.get_status_by_did(&did).await {
51
Ok(Some(status)) => {
52
let deactivated = status.deactivated_at.map(|_| StatusAttr {
···
57
applied: true,
58
r#ref: Some(r.clone()),
59
});
60
+
return Ok((
61
StatusCode::OK,
62
Json(SubjectStatus {
63
subject: json!({
···
68
deactivated,
69
}),
70
)
71
+
.into_response());
72
}
73
Ok(None) => {
74
+
return Err(ApiError::SubjectNotFound);
75
}
76
Err(e) => {
77
error!("DB error in get_subject_status: {:?}", e);
78
+
return Err(ApiError::InternalError(None));
79
}
80
}
81
}
82
if let Some(uri_str) = ¶ms.uri {
83
+
let cid: CidLink = uri_str
84
+
.parse()
85
+
.map_err(|_| ApiError::InvalidRequest("Invalid CID format".into()))?;
86
match state.repo_repo.get_record_by_cid(&cid).await {
87
Ok(Some(record)) => {
88
let takedown = record.takedown_ref.as_ref().map(|r| StatusAttr {
89
applied: true,
90
r#ref: Some(r.clone()),
91
});
92
+
return Ok((
93
StatusCode::OK,
94
Json(SubjectStatus {
95
subject: json!({
···
101
deactivated: None,
102
}),
103
)
104
+
.into_response());
105
}
106
Ok(None) => {
107
+
return Err(ApiError::RecordNotFound);
108
}
109
Err(e) => {
110
error!("DB error in get_subject_status: {:?}", e);
111
+
return Err(ApiError::InternalError(None));
112
}
113
}
114
}
115
if let Some(blob_cid_str) = ¶ms.blob {
116
+
let blob_cid: CidLink = blob_cid_str
117
+
.parse()
118
+
.map_err(|_| ApiError::InvalidRequest("Invalid CID format".into()))?;
119
+
let did = params.did.as_ref().ok_or_else(|| {
120
+
ApiError::InvalidRequest("Must provide a did to request blob state".into())
121
+
})?;
122
match state.blob_repo.get_blob_with_takedown(&blob_cid).await {
123
Ok(Some(blob)) => {
124
let takedown = blob.takedown_ref.as_ref().map(|r| StatusAttr {
125
applied: true,
126
r#ref: Some(r.clone()),
127
});
128
+
return Ok((
129
StatusCode::OK,
130
Json(SubjectStatus {
131
subject: json!({
···
137
deactivated: None,
138
}),
139
)
140
+
.into_response());
141
}
142
Ok(None) => {
143
+
return Err(ApiError::BlobNotFound(None));
144
}
145
Err(e) => {
146
error!("DB error in get_subject_status: {:?}", e);
147
+
return Err(ApiError::InternalError(None));
148
}
149
}
150
}
151
+
Err(ApiError::InvalidRequest("Invalid subject type".into()))
152
}
153
154
#[derive(Deserialize)]
···
167
168
pub async fn update_subject_status(
169
State(state): State<AppState>,
170
+
_auth: Auth<Admin>,
171
Json(input): Json<UpdateSubjectStatusInput>,
172
+
) -> Result<Response, ApiError> {
173
let subject_type = input.subject.get("$type").and_then(|t| t.as_str());
174
match subject_type {
175
Some("com.atproto.admin.defs#repoRef") => {
···
182
} else {
183
None
184
};
185
+
state
186
+
.user_repo
187
+
.set_user_takedown(&did, takedown_ref)
188
+
.await
189
+
.map_err(|e| {
190
+
error!("Failed to update user takedown status for {}: {:?}", did, e);
191
+
ApiError::InternalError(Some("Failed to update takedown status".into()))
192
+
})?;
193
}
194
if let Some(deactivated) = &input.deactivated {
195
let result = if deactivated.applied {
···
197
} else {
198
state.user_repo.activate_account(&did).await
199
};
200
+
result.map_err(|e| {
201
error!(
202
"Failed to update user deactivation status for {}: {:?}",
203
did, e
204
);
205
+
ApiError::InternalError(Some("Failed to update deactivation status".into()))
206
+
})?;
207
}
208
if let Some(takedown) = &input.takedown {
209
let status = if takedown.applied {
···
242
if let Ok(Some(handle)) = state.user_repo.get_handle_by_did(&did).await {
243
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
244
}
245
+
return Ok((
246
StatusCode::OK,
247
Json(json!({
248
"subject": input.subject,
···
255
}))
256
})),
257
)
258
+
.into_response());
259
}
260
}
261
Some("com.atproto.repo.strongRef") => {
262
let uri_str = input.subject.get("uri").and_then(|u| u.as_str());
263
if let Some(uri_str) = uri_str {
264
+
let cid: CidLink = uri_str
265
+
.parse()
266
+
.map_err(|_| ApiError::InvalidRequest("Invalid CID format".into()))?;
267
if let Some(takedown) = &input.takedown {
268
let takedown_ref = if takedown.applied {
269
takedown.r#ref.as_deref()
270
} else {
271
None
272
};
273
+
state
274
.repo_repo
275
.set_record_takedown(&cid, takedown_ref)
276
.await
277
+
.map_err(|e| {
278
+
error!(
279
+
"Failed to update record takedown status for {}: {:?}",
280
+
uri_str, e
281
+
);
282
+
ApiError::InternalError(Some("Failed to update takedown status".into()))
283
+
})?;
284
}
285
+
return Ok((
286
StatusCode::OK,
287
Json(json!({
288
"subject": input.subject,
···
292
}))
293
})),
294
)
295
+
.into_response());
296
}
297
}
298
Some("com.atproto.admin.defs#repoBlobRef") => {
299
let cid_str = input.subject.get("cid").and_then(|c| c.as_str());
300
if let Some(cid_str) = cid_str {
301
+
let cid: CidLink = cid_str
302
+
.parse()
303
+
.map_err(|_| ApiError::InvalidRequest("Invalid CID format".into()))?;
304
if let Some(takedown) = &input.takedown {
305
let takedown_ref = if takedown.applied {
306
takedown.r#ref.as_deref()
307
} else {
308
None
309
};
310
+
state
311
.blob_repo
312
.update_blob_takedown(&cid, takedown_ref)
313
.await
314
+
.map_err(|e| {
315
+
error!(
316
+
"Failed to update blob takedown status for {}: {:?}",
317
+
cid_str, e
318
+
);
319
+
ApiError::InternalError(Some("Failed to update takedown status".into()))
320
+
})?;
321
}
322
+
return Ok((
323
StatusCode::OK,
324
Json(json!({
325
"subject": input.subject,
···
329
}))
330
})),
331
)
332
+
.into_response());
333
}
334
}
335
_ => {}
336
}
337
+
Err(ApiError::InvalidRequest("Invalid subject type".into()))
338
}
+93
-73
crates/tranquil-pds/src/api/backup.rs
+93
-73
crates/tranquil-pds/src/api/backup.rs
···
1
use crate::api::error::ApiError;
2
use crate::api::{EmptyResponse, EnabledResponse};
3
-
use crate::auth::BearerAuth;
4
use crate::scheduled::generate_full_backup;
5
use crate::state::AppState;
6
use crate::storage::{BackupStorage, backup_retention_count};
···
35
pub backup_enabled: bool,
36
}
37
38
-
pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Response {
39
-
let (user_id, backup_enabled) =
40
-
match state.backup_repo.get_user_backup_status(&auth.0.did).await {
41
-
Ok(Some(status)) => status,
42
-
Ok(None) => {
43
-
return ApiError::AccountNotFound.into_response();
44
-
}
45
-
Err(e) => {
46
-
error!("DB error fetching user: {:?}", e);
47
-
return ApiError::InternalError(None).into_response();
48
-
}
49
-
};
50
51
let backups = match state.backup_repo.list_backups_for_user(user_id).await {
52
Ok(rows) => rows,
53
Err(e) => {
54
error!("DB error fetching backups: {:?}", e);
55
-
return ApiError::InternalError(None).into_response();
56
}
57
};
58
···
68
})
69
.collect();
70
71
-
(
72
StatusCode::OK,
73
Json(ListBackupsOutput {
74
backups: backup_list,
75
backup_enabled,
76
}),
77
)
78
-
.into_response()
79
}
80
81
#[derive(Deserialize)]
···
85
86
pub async fn get_backup(
87
State(state): State<AppState>,
88
-
auth: BearerAuth,
89
Query(query): Query<GetBackupQuery>,
90
-
) -> Response {
91
let backup_id = match uuid::Uuid::parse_str(&query.id) {
92
Ok(id) => id,
93
Err(_) => {
94
-
return ApiError::InvalidRequest("Invalid backup ID".into()).into_response();
95
}
96
};
97
98
let backup_info = match state
99
.backup_repo
100
-
.get_backup_storage_info(backup_id, &auth.0.did)
101
.await
102
{
103
Ok(Some(b)) => b,
104
Ok(None) => {
105
-
return ApiError::BackupNotFound.into_response();
106
}
107
Err(e) => {
108
error!("DB error fetching backup: {:?}", e);
109
-
return ApiError::InternalError(None).into_response();
110
}
111
};
112
113
let backup_storage = match state.backup_storage.as_ref() {
114
Some(storage) => storage,
115
None => {
116
-
return ApiError::BackupsDisabled.into_response();
117
}
118
};
119
···
121
Ok(bytes) => bytes,
122
Err(e) => {
123
error!("Failed to fetch backup from storage: {:?}", e);
124
-
return ApiError::InternalError(Some("Failed to retrieve backup".into()))
125
-
.into_response();
126
}
127
};
128
129
-
(
130
StatusCode::OK,
131
[
132
(axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car"),
···
137
],
138
car_bytes,
139
)
140
-
.into_response()
141
}
142
143
#[derive(Serialize)]
···
149
pub block_count: i32,
150
}
151
152
-
pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> Response {
153
let backup_storage = match state.backup_storage.as_ref() {
154
Some(storage) => storage,
155
None => {
156
-
return ApiError::BackupsDisabled.into_response();
157
}
158
};
159
160
-
let user = match state.backup_repo.get_user_for_backup(&auth.0.did).await {
161
Ok(Some(u)) => u,
162
Ok(None) => {
163
-
return ApiError::AccountNotFound.into_response();
164
}
165
Err(e) => {
166
error!("DB error fetching user: {:?}", e);
167
-
return ApiError::InternalError(None).into_response();
168
}
169
};
170
171
if user.deactivated_at.is_some() {
172
-
return ApiError::AccountDeactivated.into_response();
173
}
174
175
let repo_rev = match &user.repo_rev {
176
Some(rev) => rev.clone(),
177
None => {
178
-
return ApiError::RepoNotReady.into_response();
179
}
180
};
181
182
let head_cid = match Cid::from_str(&user.repo_root_cid) {
183
Ok(c) => c,
184
Err(_) => {
185
-
return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response();
186
}
187
};
188
···
197
Ok(bytes) => bytes,
198
Err(e) => {
199
error!("Failed to generate CAR: {:?}", e);
200
-
return ApiError::InternalError(Some("Failed to generate backup".into()))
201
-
.into_response();
202
}
203
};
204
···
212
Ok(key) => key,
213
Err(e) => {
214
error!("Failed to upload backup: {:?}", e);
215
-
return ApiError::InternalError(Some("Failed to store backup".into())).into_response();
216
}
217
};
218
···
238
"Failed to rollback orphaned backup from S3"
239
);
240
}
241
-
return ApiError::InternalError(Some("Failed to record backup".into())).into_response();
242
}
243
};
244
···
261
warn!(did = %user.did, error = %e, "Failed to cleanup old backups after manual backup");
262
}
263
264
-
(
265
StatusCode::OK,
266
Json(CreateBackupOutput {
267
id: backup_id.to_string(),
···
270
block_count,
271
}),
272
)
273
-
.into_response()
274
}
275
276
async fn cleanup_old_backups(
···
310
311
pub async fn delete_backup(
312
State(state): State<AppState>,
313
-
auth: BearerAuth,
314
Query(query): Query<DeleteBackupQuery>,
315
-
) -> Response {
316
let backup_id = match uuid::Uuid::parse_str(&query.id) {
317
Ok(id) => id,
318
Err(_) => {
319
-
return ApiError::InvalidRequest("Invalid backup ID".into()).into_response();
320
}
321
};
322
323
let backup = match state
324
.backup_repo
325
-
.get_backup_for_deletion(backup_id, &auth.0.did)
326
.await
327
{
328
Ok(Some(b)) => b,
329
Ok(None) => {
330
-
return ApiError::BackupNotFound.into_response();
331
}
332
Err(e) => {
333
error!("DB error fetching backup: {:?}", e);
334
-
return ApiError::InternalError(None).into_response();
335
}
336
};
337
338
if backup.deactivated_at.is_some() {
339
-
return ApiError::AccountDeactivated.into_response();
340
}
341
342
if let Some(backup_storage) = state.backup_storage.as_ref()
···
351
352
if let Err(e) = state.backup_repo.delete_backup(backup.id).await {
353
error!("DB error deleting backup: {:?}", e);
354
-
return ApiError::InternalError(Some("Failed to delete backup".into())).into_response();
355
}
356
357
-
info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup");
358
359
-
EmptyResponse::ok().into_response()
360
}
361
362
#[derive(Deserialize)]
···
367
368
pub async fn set_backup_enabled(
369
State(state): State<AppState>,
370
-
auth: BearerAuth,
371
Json(input): Json<SetBackupEnabledInput>,
372
-
) -> Response {
373
let deactivated_at = match state
374
.backup_repo
375
-
.get_user_deactivated_status(&auth.0.did)
376
.await
377
{
378
Ok(Some(status)) => status,
379
Ok(None) => {
380
-
return ApiError::AccountNotFound.into_response();
381
}
382
Err(e) => {
383
error!("DB error fetching user: {:?}", e);
384
-
return ApiError::InternalError(None).into_response();
385
}
386
};
387
388
if deactivated_at.is_some() {
389
-
return ApiError::AccountDeactivated.into_response();
390
}
391
392
if let Err(e) = state
393
.backup_repo
394
-
.update_backup_enabled(&auth.0.did, input.enabled)
395
.await
396
{
397
error!("DB error updating backup_enabled: {:?}", e);
398
-
return ApiError::InternalError(Some("Failed to update setting".into())).into_response();
399
}
400
401
-
info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting");
402
403
-
EnabledResponse::response(input.enabled).into_response()
404
}
405
406
-
pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response {
407
-
let user_id = match state.backup_repo.get_user_id_by_did(&auth.0.did).await {
408
Ok(Some(id)) => id,
409
Ok(None) => {
410
-
return ApiError::AccountNotFound.into_response();
411
}
412
Err(e) => {
413
error!("DB error fetching user: {:?}", e);
414
-
return ApiError::InternalError(None).into_response();
415
}
416
};
417
···
419
Ok(rows) => rows,
420
Err(e) => {
421
error!("DB error fetching blobs: {:?}", e);
422
-
return ApiError::InternalError(None).into_response();
423
}
424
};
425
426
if blobs.is_empty() {
427
-
return (
428
StatusCode::OK,
429
[
430
(axum::http::header::CONTENT_TYPE, "application/zip"),
···
435
],
436
Vec::<u8>::new(),
437
)
438
-
.into_response();
439
}
440
441
let mut zip_buffer = std::io::Cursor::new(Vec::new());
···
513
514
if let Err(e) = zip.finish() {
515
error!("Failed to finish zip: {:?}", e);
516
-
return ApiError::InternalError(Some("Failed to create zip file".into()))
517
-
.into_response();
518
}
519
}
520
521
let zip_bytes = zip_buffer.into_inner();
522
523
-
info!(did = %auth.0.did, blob_count = blobs.len(), size_bytes = zip_bytes.len(), "Exported blobs");
524
525
-
(
526
StatusCode::OK,
527
[
528
(axum::http::header::CONTENT_TYPE, "application/zip"),
···
533
],
534
zip_bytes,
535
)
536
-
.into_response()
537
}
538
539
fn mime_to_extension(mime_type: &str) -> &'static str {
···
1
use crate::api::error::ApiError;
2
use crate::api::{EmptyResponse, EnabledResponse};
3
+
use crate::auth::{Active, Auth};
4
use crate::scheduled::generate_full_backup;
5
use crate::state::AppState;
6
use crate::storage::{BackupStorage, backup_retention_count};
···
35
pub backup_enabled: bool,
36
}
37
38
+
pub async fn list_backups(
39
+
State(state): State<AppState>,
40
+
auth: Auth<Active>,
41
+
) -> Result<Response, crate::api::error::ApiError> {
42
+
let (user_id, backup_enabled) = match state.backup_repo.get_user_backup_status(&auth.did).await
43
+
{
44
+
Ok(Some(status)) => status,
45
+
Ok(None) => {
46
+
return Ok(ApiError::AccountNotFound.into_response());
47
+
}
48
+
Err(e) => {
49
+
error!("DB error fetching user: {:?}", e);
50
+
return Ok(ApiError::InternalError(None).into_response());
51
+
}
52
+
};
53
54
let backups = match state.backup_repo.list_backups_for_user(user_id).await {
55
Ok(rows) => rows,
56
Err(e) => {
57
error!("DB error fetching backups: {:?}", e);
58
+
return Ok(ApiError::InternalError(None).into_response());
59
}
60
};
61
···
71
})
72
.collect();
73
74
+
Ok((
75
StatusCode::OK,
76
Json(ListBackupsOutput {
77
backups: backup_list,
78
backup_enabled,
79
}),
80
)
81
+
.into_response())
82
}
83
84
#[derive(Deserialize)]
···
88
89
pub async fn get_backup(
90
State(state): State<AppState>,
91
+
auth: Auth<Active>,
92
Query(query): Query<GetBackupQuery>,
93
+
) -> Result<Response, crate::api::error::ApiError> {
94
let backup_id = match uuid::Uuid::parse_str(&query.id) {
95
Ok(id) => id,
96
Err(_) => {
97
+
return Ok(ApiError::InvalidRequest("Invalid backup ID".into()).into_response());
98
}
99
};
100
101
let backup_info = match state
102
.backup_repo
103
+
.get_backup_storage_info(backup_id, &auth.did)
104
.await
105
{
106
Ok(Some(b)) => b,
107
Ok(None) => {
108
+
return Ok(ApiError::BackupNotFound.into_response());
109
}
110
Err(e) => {
111
error!("DB error fetching backup: {:?}", e);
112
+
return Ok(ApiError::InternalError(None).into_response());
113
}
114
};
115
116
let backup_storage = match state.backup_storage.as_ref() {
117
Some(storage) => storage,
118
None => {
119
+
return Ok(ApiError::BackupsDisabled.into_response());
120
}
121
};
122
···
124
Ok(bytes) => bytes,
125
Err(e) => {
126
error!("Failed to fetch backup from storage: {:?}", e);
127
+
return Ok(
128
+
ApiError::InternalError(Some("Failed to retrieve backup".into())).into_response(),
129
+
);
130
}
131
};
132
133
+
Ok((
134
StatusCode::OK,
135
[
136
(axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car"),
···
141
],
142
car_bytes,
143
)
144
+
.into_response())
145
}
146
147
#[derive(Serialize)]
···
153
pub block_count: i32,
154
}
155
156
+
pub async fn create_backup(
157
+
State(state): State<AppState>,
158
+
auth: Auth<Active>,
159
+
) -> Result<Response, crate::api::error::ApiError> {
160
let backup_storage = match state.backup_storage.as_ref() {
161
Some(storage) => storage,
162
None => {
163
+
return Ok(ApiError::BackupsDisabled.into_response());
164
}
165
};
166
167
+
let user = match state.backup_repo.get_user_for_backup(&auth.did).await {
168
Ok(Some(u)) => u,
169
Ok(None) => {
170
+
return Ok(ApiError::AccountNotFound.into_response());
171
}
172
Err(e) => {
173
error!("DB error fetching user: {:?}", e);
174
+
return Ok(ApiError::InternalError(None).into_response());
175
}
176
};
177
178
if user.deactivated_at.is_some() {
179
+
return Ok(ApiError::AccountDeactivated.into_response());
180
}
181
182
let repo_rev = match &user.repo_rev {
183
Some(rev) => rev.clone(),
184
None => {
185
+
return Ok(ApiError::RepoNotReady.into_response());
186
}
187
};
188
189
let head_cid = match Cid::from_str(&user.repo_root_cid) {
190
Ok(c) => c,
191
Err(_) => {
192
+
return Ok(
193
+
ApiError::InternalError(Some("Invalid repo root CID".into())).into_response(),
194
+
);
195
}
196
};
197
···
206
Ok(bytes) => bytes,
207
Err(e) => {
208
error!("Failed to generate CAR: {:?}", e);
209
+
return Ok(
210
+
ApiError::InternalError(Some("Failed to generate backup".into())).into_response(),
211
+
);
212
}
213
};
214
···
222
Ok(key) => key,
223
Err(e) => {
224
error!("Failed to upload backup: {:?}", e);
225
+
return Ok(
226
+
ApiError::InternalError(Some("Failed to store backup".into())).into_response(),
227
+
);
228
}
229
};
230
···
250
"Failed to rollback orphaned backup from S3"
251
);
252
}
253
+
return Ok(
254
+
ApiError::InternalError(Some("Failed to record backup".into())).into_response(),
255
+
);
256
}
257
};
258
···
275
warn!(did = %user.did, error = %e, "Failed to cleanup old backups after manual backup");
276
}
277
278
+
Ok((
279
StatusCode::OK,
280
Json(CreateBackupOutput {
281
id: backup_id.to_string(),
···
284
block_count,
285
}),
286
)
287
+
.into_response())
288
}
289
290
async fn cleanup_old_backups(
···
324
325
pub async fn delete_backup(
326
State(state): State<AppState>,
327
+
auth: Auth<Active>,
328
Query(query): Query<DeleteBackupQuery>,
329
+
) -> Result<Response, crate::api::error::ApiError> {
330
let backup_id = match uuid::Uuid::parse_str(&query.id) {
331
Ok(id) => id,
332
Err(_) => {
333
+
return Ok(ApiError::InvalidRequest("Invalid backup ID".into()).into_response());
334
}
335
};
336
337
let backup = match state
338
.backup_repo
339
+
.get_backup_for_deletion(backup_id, &auth.did)
340
.await
341
{
342
Ok(Some(b)) => b,
343
Ok(None) => {
344
+
return Ok(ApiError::BackupNotFound.into_response());
345
}
346
Err(e) => {
347
error!("DB error fetching backup: {:?}", e);
348
+
return Ok(ApiError::InternalError(None).into_response());
349
}
350
};
351
352
if backup.deactivated_at.is_some() {
353
+
return Ok(ApiError::AccountDeactivated.into_response());
354
}
355
356
if let Some(backup_storage) = state.backup_storage.as_ref()
···
365
366
if let Err(e) = state.backup_repo.delete_backup(backup.id).await {
367
error!("DB error deleting backup: {:?}", e);
368
+
return Ok(ApiError::InternalError(Some("Failed to delete backup".into())).into_response());
369
}
370
371
+
info!(did = %auth.did, backup_id = %backup_id, "Deleted backup");
372
373
+
Ok(EmptyResponse::ok().into_response())
374
}
375
376
#[derive(Deserialize)]
···
381
382
pub async fn set_backup_enabled(
383
State(state): State<AppState>,
384
+
auth: Auth<Active>,
385
Json(input): Json<SetBackupEnabledInput>,
386
+
) -> Result<Response, crate::api::error::ApiError> {
387
let deactivated_at = match state
388
.backup_repo
389
+
.get_user_deactivated_status(&auth.did)
390
.await
391
{
392
Ok(Some(status)) => status,
393
Ok(None) => {
394
+
return Ok(ApiError::AccountNotFound.into_response());
395
}
396
Err(e) => {
397
error!("DB error fetching user: {:?}", e);
398
+
return Ok(ApiError::InternalError(None).into_response());
399
}
400
};
401
402
if deactivated_at.is_some() {
403
+
return Ok(ApiError::AccountDeactivated.into_response());
404
}
405
406
if let Err(e) = state
407
.backup_repo
408
+
.update_backup_enabled(&auth.did, input.enabled)
409
.await
410
{
411
error!("DB error updating backup_enabled: {:?}", e);
412
+
return Ok(
413
+
ApiError::InternalError(Some("Failed to update setting".into())).into_response(),
414
+
);
415
}
416
417
+
info!(did = %auth.did, enabled = input.enabled, "Updated backup_enabled setting");
418
419
+
Ok(EnabledResponse::response(input.enabled).into_response())
420
}
421
422
+
pub async fn export_blobs(
423
+
State(state): State<AppState>,
424
+
auth: Auth<Active>,
425
+
) -> Result<Response, crate::api::error::ApiError> {
426
+
let user_id = match state.backup_repo.get_user_id_by_did(&auth.did).await {
427
Ok(Some(id)) => id,
428
Ok(None) => {
429
+
return Ok(ApiError::AccountNotFound.into_response());
430
}
431
Err(e) => {
432
error!("DB error fetching user: {:?}", e);
433
+
return Ok(ApiError::InternalError(None).into_response());
434
}
435
};
436
···
438
Ok(rows) => rows,
439
Err(e) => {
440
error!("DB error fetching blobs: {:?}", e);
441
+
return Ok(ApiError::InternalError(None).into_response());
442
}
443
};
444
445
if blobs.is_empty() {
446
+
return Ok((
447
StatusCode::OK,
448
[
449
(axum::http::header::CONTENT_TYPE, "application/zip"),
···
454
],
455
Vec::<u8>::new(),
456
)
457
+
.into_response());
458
}
459
460
let mut zip_buffer = std::io::Cursor::new(Vec::new());
···
532
533
if let Err(e) = zip.finish() {
534
error!("Failed to finish zip: {:?}", e);
535
+
return Ok(
536
+
ApiError::InternalError(Some("Failed to create zip file".into())).into_response(),
537
+
);
538
}
539
}
540
541
let zip_bytes = zip_buffer.into_inner();
542
543
+
info!(did = %auth.did, blob_count = blobs.len(), size_bytes = zip_bytes.len(), "Exported blobs");
544
545
+
Ok((
546
StatusCode::OK,
547
[
548
(axum::http::header::CONTENT_TYPE, "application/zip"),
···
553
],
554
zip_bytes,
555
)
556
+
.into_response())
557
}
558
559
fn mime_to_extension(mime_type: &str) -> &'static str {
+115
-98
crates/tranquil-pds/src/api/delegation.rs
+115
-98
crates/tranquil-pds/src/api/delegation.rs
···
1
use crate::api::error::ApiError;
2
use crate::api::repo::record::utils::create_signed_commit;
3
-
use crate::auth::BearerAuth;
4
use crate::delegation::{DelegationActionType, SCOPE_PRESETS, scopes};
5
use crate::state::{AppState, RateLimitKind};
6
use crate::types::{Did, Handle, Nsid, Rkey};
···
33
pub controllers: Vec<ControllerInfo>,
34
}
35
36
-
pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -> Response {
37
let controllers = match state
38
.delegation_repo
39
-
.get_delegations_for_account(&auth.0.did)
40
.await
41
{
42
Ok(c) => c,
43
Err(e) => {
44
tracing::error!("Failed to list controllers: {:?}", e);
45
-
return ApiError::InternalError(Some("Failed to list controllers".into()))
46
-
.into_response();
47
}
48
};
49
50
-
Json(ListControllersResponse {
51
controllers: controllers
52
.into_iter()
53
.map(|c| ControllerInfo {
···
59
})
60
.collect(),
61
})
62
-
.into_response()
63
}
64
65
#[derive(Debug, Deserialize)]
···
70
71
pub async fn add_controller(
72
State(state): State<AppState>,
73
-
auth: BearerAuth,
74
Json(input): Json<AddControllerInput>,
75
-
) -> Response {
76
if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) {
77
-
return ApiError::InvalidScopes(e).into_response();
78
}
79
80
let controller_exists = state
···
86
.is_some();
87
88
if !controller_exists {
89
-
return ApiError::ControllerNotFound.into_response();
90
}
91
92
-
match state
93
-
.delegation_repo
94
-
.controls_any_accounts(&auth.0.did)
95
-
.await
96
-
{
97
Ok(true) => {
98
-
return ApiError::InvalidDelegation(
99
"Cannot add controllers to an account that controls other accounts".into(),
100
)
101
-
.into_response();
102
}
103
Err(e) => {
104
tracing::error!("Failed to check delegation status: {:?}", e);
105
-
return ApiError::InternalError(Some("Failed to verify delegation status".into()))
106
-
.into_response();
107
}
108
Ok(false) => {}
109
}
···
114
.await
115
{
116
Ok(true) => {
117
-
return ApiError::InvalidDelegation(
118
"Cannot add a controlled account as a controller".into(),
119
)
120
-
.into_response();
121
}
122
Err(e) => {
123
tracing::error!("Failed to check controller status: {:?}", e);
124
-
return ApiError::InternalError(Some("Failed to verify controller status".into()))
125
-
.into_response();
126
}
127
Ok(false) => {}
128
}
···
130
match state
131
.delegation_repo
132
.create_delegation(
133
-
&auth.0.did,
134
&input.controller_did,
135
&input.granted_scopes,
136
-
&auth.0.did,
137
)
138
.await
139
{
···
141
let _ = state
142
.delegation_repo
143
.log_delegation_action(
144
-
&auth.0.did,
145
-
&auth.0.did,
146
Some(&input.controller_did),
147
DelegationActionType::GrantCreated,
148
Some(serde_json::json!({
···
153
)
154
.await;
155
156
-
(
157
StatusCode::OK,
158
Json(serde_json::json!({
159
"success": true
160
})),
161
)
162
-
.into_response()
163
}
164
Err(e) => {
165
tracing::error!("Failed to add controller: {:?}", e);
166
-
ApiError::InternalError(Some("Failed to add controller".into())).into_response()
167
}
168
}
169
}
···
175
176
pub async fn remove_controller(
177
State(state): State<AppState>,
178
-
auth: BearerAuth,
179
Json(input): Json<RemoveControllerInput>,
180
-
) -> Response {
181
match state
182
.delegation_repo
183
-
.revoke_delegation(&auth.0.did, &input.controller_did, &auth.0.did)
184
.await
185
{
186
Ok(true) => {
187
let revoked_app_passwords = state
188
.session_repo
189
-
.delete_app_passwords_by_controller(&auth.0.did, &input.controller_did)
190
.await
191
.unwrap_or(0) as usize;
192
193
let revoked_oauth_tokens = state
194
.oauth_repo
195
-
.revoke_tokens_for_controller(&auth.0.did, &input.controller_did)
196
.await
197
.unwrap_or(0);
198
199
let _ = state
200
.delegation_repo
201
.log_delegation_action(
202
-
&auth.0.did,
203
-
&auth.0.did,
204
Some(&input.controller_did),
205
DelegationActionType::GrantRevoked,
206
Some(serde_json::json!({
···
212
)
213
.await;
214
215
-
(
216
StatusCode::OK,
217
Json(serde_json::json!({
218
"success": true
219
})),
220
)
221
-
.into_response()
222
}
223
-
Ok(false) => ApiError::DelegationNotFound.into_response(),
224
Err(e) => {
225
tracing::error!("Failed to remove controller: {:?}", e);
226
-
ApiError::InternalError(Some("Failed to remove controller".into())).into_response()
227
}
228
}
229
}
···
236
237
pub async fn update_controller_scopes(
238
State(state): State<AppState>,
239
-
auth: BearerAuth,
240
Json(input): Json<UpdateControllerScopesInput>,
241
-
) -> Response {
242
if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) {
243
-
return ApiError::InvalidScopes(e).into_response();
244
}
245
246
match state
247
.delegation_repo
248
-
.update_delegation_scopes(&auth.0.did, &input.controller_did, &input.granted_scopes)
249
.await
250
{
251
Ok(true) => {
252
let _ = state
253
.delegation_repo
254
.log_delegation_action(
255
-
&auth.0.did,
256
-
&auth.0.did,
257
Some(&input.controller_did),
258
DelegationActionType::ScopesModified,
259
Some(serde_json::json!({
···
264
)
265
.await;
266
267
-
(
268
StatusCode::OK,
269
Json(serde_json::json!({
270
"success": true
271
})),
272
)
273
-
.into_response()
274
}
275
-
Ok(false) => ApiError::DelegationNotFound.into_response(),
276
Err(e) => {
277
tracing::error!("Failed to update controller scopes: {:?}", e);
278
-
ApiError::InternalError(Some("Failed to update controller scopes".into()))
279
-
.into_response()
280
}
281
}
282
}
···
295
pub accounts: Vec<DelegatedAccountInfo>,
296
}
297
298
-
pub async fn list_controlled_accounts(State(state): State<AppState>, auth: BearerAuth) -> Response {
299
let accounts = match state
300
.delegation_repo
301
-
.get_accounts_controlled_by(&auth.0.did)
302
.await
303
{
304
Ok(a) => a,
305
Err(e) => {
306
tracing::error!("Failed to list controlled accounts: {:?}", e);
307
-
return ApiError::InternalError(Some("Failed to list controlled accounts".into()))
308
-
.into_response();
309
}
310
};
311
312
-
Json(ListControlledAccountsResponse {
313
accounts: accounts
314
.into_iter()
315
.map(|a| DelegatedAccountInfo {
···
320
})
321
.collect(),
322
})
323
-
.into_response()
324
}
325
326
#[derive(Debug, Deserialize)]
···
355
356
pub async fn get_audit_log(
357
State(state): State<AppState>,
358
-
auth: BearerAuth,
359
Query(params): Query<AuditLogParams>,
360
-
) -> Response {
361
let limit = params.limit.clamp(1, 100);
362
let offset = params.offset.max(0);
363
364
let entries = match state
365
.delegation_repo
366
-
.get_audit_log_for_account(&auth.0.did, limit, offset)
367
.await
368
{
369
Ok(e) => e,
370
Err(e) => {
371
tracing::error!("Failed to get audit log: {:?}", e);
372
-
return ApiError::InternalError(Some("Failed to get audit log".into())).into_response();
373
}
374
};
375
376
let total = state
377
.delegation_repo
378
-
.count_audit_log_entries(&auth.0.did)
379
.await
380
.unwrap_or_default();
381
382
-
Json(GetAuditLogResponse {
383
entries: entries
384
.into_iter()
385
.map(|e| AuditLogEntry {
···
394
.collect(),
395
total,
396
})
397
-
.into_response()
398
}
399
400
#[derive(Debug, Serialize)]
···
444
pub async fn create_delegated_account(
445
State(state): State<AppState>,
446
headers: HeaderMap,
447
-
auth: BearerAuth,
448
Json(input): Json<CreateDelegatedAccountInput>,
449
-
) -> Response {
450
let client_ip = extract_client_ip(&headers);
451
if !state
452
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
453
.await
454
{
455
warn!(ip = %client_ip, "Delegated account creation rate limit exceeded");
456
-
return ApiError::RateLimitExceeded(Some(
457
"Too many account creation attempts. Please try again later.".into(),
458
))
459
-
.into_response();
460
}
461
462
if let Err(e) = scopes::validate_delegation_scopes(&input.controller_scopes) {
463
-
return ApiError::InvalidScopes(e).into_response();
464
}
465
466
-
match state.delegation_repo.has_any_controllers(&auth.0.did).await {
467
Ok(true) => {
468
-
return ApiError::InvalidDelegation(
469
"Cannot create delegated accounts from a controlled account".into(),
470
)
471
-
.into_response();
472
}
473
Err(e) => {
474
tracing::error!("Failed to check controller status: {:?}", e);
475
-
return ApiError::InternalError(Some("Failed to verify controller status".into()))
476
-
.into_response();
477
}
478
Ok(false) => {}
479
}
···
494
match crate::api::validation::validate_short_handle(handle_to_validate) {
495
Ok(h) => format!("{}.{}", h, hostname_for_handles),
496
Err(e) => {
497
-
return ApiError::InvalidRequest(e.to_string()).into_response();
498
}
499
}
500
} else {
···
509
if let Some(ref email) = email
510
&& !crate::api::validation::is_valid_email(email)
511
{
512
-
return ApiError::InvalidEmail.into_response();
513
}
514
515
if let Some(ref code) = input.invite_code {
···
520
.unwrap_or(false);
521
522
if !valid {
523
-
return ApiError::InvalidInviteCode.into_response();
524
}
525
} else {
526
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
527
.map(|v| v == "true" || v == "1")
528
.unwrap_or(false);
529
if invite_required {
530
-
return ApiError::InviteCodeRequired.into_response();
531
}
532
}
533
···
542
Ok(k) => k,
543
Err(e) => {
544
error!("Error creating signing key: {:?}", e);
545
-
return ApiError::InternalError(None).into_response();
546
}
547
};
548
···
558
Ok(r) => r,
559
Err(e) => {
560
error!("Error creating PLC genesis operation: {:?}", e);
561
-
return ApiError::InternalError(Some("Failed to create PLC operation".into()))
562
-
.into_response();
563
}
564
};
565
···
569
.await
570
{
571
error!("Failed to submit PLC genesis operation: {:?}", e);
572
-
return ApiError::UpstreamErrorMsg(format!(
573
"Failed to register DID with PLC directory: {}",
574
e
575
))
576
-
.into_response();
577
}
578
579
let did = Did::new_unchecked(&genesis_result.did);
580
let handle = Handle::new_unchecked(&handle);
581
-
info!(did = %did, handle = %handle, controller = %&auth.0.did, "Created DID for delegated account");
582
583
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
584
Ok(bytes) => bytes,
585
Err(e) => {
586
error!("Error encrypting signing key: {:?}", e);
587
-
return ApiError::InternalError(None).into_response();
588
}
589
};
590
···
593
Ok(c) => c,
594
Err(e) => {
595
error!("Error persisting MST: {:?}", e);
596
-
return ApiError::InternalError(None).into_response();
597
}
598
};
599
let rev = Tid::now(LimitedU32::MIN);
···
602
Ok(result) => result,
603
Err(e) => {
604
error!("Error creating genesis commit: {:?}", e);
605
-
return ApiError::InternalError(None).into_response();
606
}
607
};
608
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
609
Ok(c) => c,
610
Err(e) => {
611
error!("Error saving genesis commit: {:?}", e);
612
-
return ApiError::InternalError(None).into_response();
613
}
614
};
615
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
···
618
handle: handle.clone(),
619
email: email.clone(),
620
did: did.clone(),
621
-
controller_did: auth.0.did.clone(),
622
controller_scopes: input.controller_scopes.clone(),
623
encrypted_key_bytes,
624
encryption_version: crate::config::ENCRYPTION_VERSION,
···
635
{
636
Ok(id) => id,
637
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
638
-
return ApiError::HandleNotAvailable(None).into_response();
639
}
640
Err(tranquil_db_traits::CreateAccountError::EmailTaken) => {
641
-
return ApiError::EmailTaken.into_response();
642
}
643
Err(e) => {
644
error!("Error creating delegated account: {:?}", e);
645
-
return ApiError::InternalError(None).into_response();
646
}
647
};
648
···
678
.delegation_repo
679
.log_delegation_action(
680
&did,
681
-
&auth.0.did,
682
-
Some(&auth.0.did),
683
DelegationActionType::GrantCreated,
684
Some(json!({
685
"account_created": true,
···
690
)
691
.await;
692
693
-
info!(did = %did, handle = %handle, controller = %&auth.0.did, "Delegated account created");
694
695
-
Json(CreateDelegatedAccountResponse { did, handle }).into_response()
696
}
···
1
use crate::api::error::ApiError;
2
use crate::api::repo::record::utils::create_signed_commit;
3
+
use crate::auth::{Active, Auth};
4
use crate::delegation::{DelegationActionType, SCOPE_PRESETS, scopes};
5
use crate::state::{AppState, RateLimitKind};
6
use crate::types::{Did, Handle, Nsid, Rkey};
···
33
pub controllers: Vec<ControllerInfo>,
34
}
35
36
+
pub async fn list_controllers(
37
+
State(state): State<AppState>,
38
+
auth: Auth<Active>,
39
+
) -> Result<Response, ApiError> {
40
let controllers = match state
41
.delegation_repo
42
+
.get_delegations_for_account(&auth.did)
43
.await
44
{
45
Ok(c) => c,
46
Err(e) => {
47
tracing::error!("Failed to list controllers: {:?}", e);
48
+
return Ok(
49
+
ApiError::InternalError(Some("Failed to list controllers".into())).into_response(),
50
+
);
51
}
52
};
53
54
+
Ok(Json(ListControllersResponse {
55
controllers: controllers
56
.into_iter()
57
.map(|c| ControllerInfo {
···
63
})
64
.collect(),
65
})
66
+
.into_response())
67
}
68
69
#[derive(Debug, Deserialize)]
···
74
75
pub async fn add_controller(
76
State(state): State<AppState>,
77
+
auth: Auth<Active>,
78
Json(input): Json<AddControllerInput>,
79
+
) -> Result<Response, ApiError> {
80
if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) {
81
+
return Ok(ApiError::InvalidScopes(e).into_response());
82
}
83
84
let controller_exists = state
···
90
.is_some();
91
92
if !controller_exists {
93
+
return Ok(ApiError::ControllerNotFound.into_response());
94
}
95
96
+
match state.delegation_repo.controls_any_accounts(&auth.did).await {
97
Ok(true) => {
98
+
return Ok(ApiError::InvalidDelegation(
99
"Cannot add controllers to an account that controls other accounts".into(),
100
)
101
+
.into_response());
102
}
103
Err(e) => {
104
tracing::error!("Failed to check delegation status: {:?}", e);
105
+
return Ok(
106
+
ApiError::InternalError(Some("Failed to verify delegation status".into()))
107
+
.into_response(),
108
+
);
109
}
110
Ok(false) => {}
111
}
···
116
.await
117
{
118
Ok(true) => {
119
+
return Ok(ApiError::InvalidDelegation(
120
"Cannot add a controlled account as a controller".into(),
121
)
122
+
.into_response());
123
}
124
Err(e) => {
125
tracing::error!("Failed to check controller status: {:?}", e);
126
+
return Ok(
127
+
ApiError::InternalError(Some("Failed to verify controller status".into()))
128
+
.into_response(),
129
+
);
130
}
131
Ok(false) => {}
132
}
···
134
match state
135
.delegation_repo
136
.create_delegation(
137
+
&auth.did,
138
&input.controller_did,
139
&input.granted_scopes,
140
+
&auth.did,
141
)
142
.await
143
{
···
145
let _ = state
146
.delegation_repo
147
.log_delegation_action(
148
+
&auth.did,
149
+
&auth.did,
150
Some(&input.controller_did),
151
DelegationActionType::GrantCreated,
152
Some(serde_json::json!({
···
157
)
158
.await;
159
160
+
Ok((
161
StatusCode::OK,
162
Json(serde_json::json!({
163
"success": true
164
})),
165
)
166
+
.into_response())
167
}
168
Err(e) => {
169
tracing::error!("Failed to add controller: {:?}", e);
170
+
Ok(ApiError::InternalError(Some("Failed to add controller".into())).into_response())
171
}
172
}
173
}
···
179
180
pub async fn remove_controller(
181
State(state): State<AppState>,
182
+
auth: Auth<Active>,
183
Json(input): Json<RemoveControllerInput>,
184
+
) -> Result<Response, ApiError> {
185
match state
186
.delegation_repo
187
+
.revoke_delegation(&auth.did, &input.controller_did, &auth.did)
188
.await
189
{
190
Ok(true) => {
191
let revoked_app_passwords = state
192
.session_repo
193
+
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
194
.await
195
.unwrap_or(0) as usize;
196
197
let revoked_oauth_tokens = state
198
.oauth_repo
199
+
.revoke_tokens_for_controller(&auth.did, &input.controller_did)
200
.await
201
.unwrap_or(0);
202
203
let _ = state
204
.delegation_repo
205
.log_delegation_action(
206
+
&auth.did,
207
+
&auth.did,
208
Some(&input.controller_did),
209
DelegationActionType::GrantRevoked,
210
Some(serde_json::json!({
···
216
)
217
.await;
218
219
+
Ok((
220
StatusCode::OK,
221
Json(serde_json::json!({
222
"success": true
223
})),
224
)
225
+
.into_response())
226
}
227
+
Ok(false) => Ok(ApiError::DelegationNotFound.into_response()),
228
Err(e) => {
229
tracing::error!("Failed to remove controller: {:?}", e);
230
+
Ok(ApiError::InternalError(Some("Failed to remove controller".into())).into_response())
231
}
232
}
233
}
···
240
241
pub async fn update_controller_scopes(
242
State(state): State<AppState>,
243
+
auth: Auth<Active>,
244
Json(input): Json<UpdateControllerScopesInput>,
245
+
) -> Result<Response, ApiError> {
246
if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) {
247
+
return Ok(ApiError::InvalidScopes(e).into_response());
248
}
249
250
match state
251
.delegation_repo
252
+
.update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes)
253
.await
254
{
255
Ok(true) => {
256
let _ = state
257
.delegation_repo
258
.log_delegation_action(
259
+
&auth.did,
260
+
&auth.did,
261
Some(&input.controller_did),
262
DelegationActionType::ScopesModified,
263
Some(serde_json::json!({
···
268
)
269
.await;
270
271
+
Ok((
272
StatusCode::OK,
273
Json(serde_json::json!({
274
"success": true
275
})),
276
)
277
+
.into_response())
278
}
279
+
Ok(false) => Ok(ApiError::DelegationNotFound.into_response()),
280
Err(e) => {
281
tracing::error!("Failed to update controller scopes: {:?}", e);
282
+
Ok(
283
+
ApiError::InternalError(Some("Failed to update controller scopes".into()))
284
+
.into_response(),
285
+
)
286
}
287
}
288
}
···
301
pub accounts: Vec<DelegatedAccountInfo>,
302
}
303
304
+
pub async fn list_controlled_accounts(
305
+
State(state): State<AppState>,
306
+
auth: Auth<Active>,
307
+
) -> Result<Response, ApiError> {
308
let accounts = match state
309
.delegation_repo
310
+
.get_accounts_controlled_by(&auth.did)
311
.await
312
{
313
Ok(a) => a,
314
Err(e) => {
315
tracing::error!("Failed to list controlled accounts: {:?}", e);
316
+
return Ok(
317
+
ApiError::InternalError(Some("Failed to list controlled accounts".into()))
318
+
.into_response(),
319
+
);
320
}
321
};
322
323
+
Ok(Json(ListControlledAccountsResponse {
324
accounts: accounts
325
.into_iter()
326
.map(|a| DelegatedAccountInfo {
···
331
})
332
.collect(),
333
})
334
+
.into_response())
335
}
336
337
#[derive(Debug, Deserialize)]
···
366
367
pub async fn get_audit_log(
368
State(state): State<AppState>,
369
+
auth: Auth<Active>,
370
Query(params): Query<AuditLogParams>,
371
+
) -> Result<Response, ApiError> {
372
let limit = params.limit.clamp(1, 100);
373
let offset = params.offset.max(0);
374
375
let entries = match state
376
.delegation_repo
377
+
.get_audit_log_for_account(&auth.did, limit, offset)
378
.await
379
{
380
Ok(e) => e,
381
Err(e) => {
382
tracing::error!("Failed to get audit log: {:?}", e);
383
+
return Ok(
384
+
ApiError::InternalError(Some("Failed to get audit log".into())).into_response(),
385
+
);
386
}
387
};
388
389
let total = state
390
.delegation_repo
391
+
.count_audit_log_entries(&auth.did)
392
.await
393
.unwrap_or_default();
394
395
+
Ok(Json(GetAuditLogResponse {
396
entries: entries
397
.into_iter()
398
.map(|e| AuditLogEntry {
···
407
.collect(),
408
total,
409
})
410
+
.into_response())
411
}
412
413
#[derive(Debug, Serialize)]
···
457
pub async fn create_delegated_account(
458
State(state): State<AppState>,
459
headers: HeaderMap,
460
+
auth: Auth<Active>,
461
Json(input): Json<CreateDelegatedAccountInput>,
462
+
) -> Result<Response, ApiError> {
463
let client_ip = extract_client_ip(&headers);
464
if !state
465
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
466
.await
467
{
468
warn!(ip = %client_ip, "Delegated account creation rate limit exceeded");
469
+
return Ok(ApiError::RateLimitExceeded(Some(
470
"Too many account creation attempts. Please try again later.".into(),
471
))
472
+
.into_response());
473
}
474
475
if let Err(e) = scopes::validate_delegation_scopes(&input.controller_scopes) {
476
+
return Ok(ApiError::InvalidScopes(e).into_response());
477
}
478
479
+
match state.delegation_repo.has_any_controllers(&auth.did).await {
480
Ok(true) => {
481
+
return Ok(ApiError::InvalidDelegation(
482
"Cannot create delegated accounts from a controlled account".into(),
483
)
484
+
.into_response());
485
}
486
Err(e) => {
487
tracing::error!("Failed to check controller status: {:?}", e);
488
+
return Ok(
489
+
ApiError::InternalError(Some("Failed to verify controller status".into()))
490
+
.into_response(),
491
+
);
492
}
493
Ok(false) => {}
494
}
···
509
match crate::api::validation::validate_short_handle(handle_to_validate) {
510
Ok(h) => format!("{}.{}", h, hostname_for_handles),
511
Err(e) => {
512
+
return Ok(ApiError::InvalidRequest(e.to_string()).into_response());
513
}
514
}
515
} else {
···
524
if let Some(ref email) = email
525
&& !crate::api::validation::is_valid_email(email)
526
{
527
+
return Ok(ApiError::InvalidEmail.into_response());
528
}
529
530
if let Some(ref code) = input.invite_code {
···
535
.unwrap_or(false);
536
537
if !valid {
538
+
return Ok(ApiError::InvalidInviteCode.into_response());
539
}
540
} else {
541
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
542
.map(|v| v == "true" || v == "1")
543
.unwrap_or(false);
544
if invite_required {
545
+
return Ok(ApiError::InviteCodeRequired.into_response());
546
}
547
}
548
···
557
Ok(k) => k,
558
Err(e) => {
559
error!("Error creating signing key: {:?}", e);
560
+
return Ok(ApiError::InternalError(None).into_response());
561
}
562
};
563
···
573
Ok(r) => r,
574
Err(e) => {
575
error!("Error creating PLC genesis operation: {:?}", e);
576
+
return Ok(
577
+
ApiError::InternalError(Some("Failed to create PLC operation".into()))
578
+
.into_response(),
579
+
);
580
}
581
};
582
···
586
.await
587
{
588
error!("Failed to submit PLC genesis operation: {:?}", e);
589
+
return Ok(ApiError::UpstreamErrorMsg(format!(
590
"Failed to register DID with PLC directory: {}",
591
e
592
))
593
+
.into_response());
594
}
595
596
let did = Did::new_unchecked(&genesis_result.did);
597
let handle = Handle::new_unchecked(&handle);
598
+
info!(did = %did, handle = %handle, controller = %&auth.did, "Created DID for delegated account");
599
600
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
601
Ok(bytes) => bytes,
602
Err(e) => {
603
error!("Error encrypting signing key: {:?}", e);
604
+
return Ok(ApiError::InternalError(None).into_response());
605
}
606
};
607
···
610
Ok(c) => c,
611
Err(e) => {
612
error!("Error persisting MST: {:?}", e);
613
+
return Ok(ApiError::InternalError(None).into_response());
614
}
615
};
616
let rev = Tid::now(LimitedU32::MIN);
···
619
Ok(result) => result,
620
Err(e) => {
621
error!("Error creating genesis commit: {:?}", e);
622
+
return Ok(ApiError::InternalError(None).into_response());
623
}
624
};
625
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
626
Ok(c) => c,
627
Err(e) => {
628
error!("Error saving genesis commit: {:?}", e);
629
+
return Ok(ApiError::InternalError(None).into_response());
630
}
631
};
632
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
···
635
handle: handle.clone(),
636
email: email.clone(),
637
did: did.clone(),
638
+
controller_did: auth.did.clone(),
639
controller_scopes: input.controller_scopes.clone(),
640
encrypted_key_bytes,
641
encryption_version: crate::config::ENCRYPTION_VERSION,
···
652
{
653
Ok(id) => id,
654
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
655
+
return Ok(ApiError::HandleNotAvailable(None).into_response());
656
}
657
Err(tranquil_db_traits::CreateAccountError::EmailTaken) => {
658
+
return Ok(ApiError::EmailTaken.into_response());
659
}
660
Err(e) => {
661
error!("Error creating delegated account: {:?}", e);
662
+
return Ok(ApiError::InternalError(None).into_response());
663
}
664
};
665
···
695
.delegation_repo
696
.log_delegation_action(
697
&did,
698
+
&auth.did,
699
+
Some(&auth.did),
700
DelegationActionType::GrantCreated,
701
Some(json!({
702
"account_created": true,
···
707
)
708
.await;
709
710
+
info!(did = %did, handle = %handle, controller = %&auth.did, "Delegated account created");
711
712
+
Ok(Json(CreateDelegatedAccountResponse { did, handle }).into_response())
713
}
+13
crates/tranquil-pds/src/api/error.rs
+13
crates/tranquil-pds/src/api/error.rs
···
543
crate::auth::extractor::AuthError::AccountDeactivated => Self::AccountDeactivated,
544
crate::auth::extractor::AuthError::AccountTakedown => Self::AccountTakedown,
545
crate::auth::extractor::AuthError::AdminRequired => Self::AdminRequired,
546
+
crate::auth::extractor::AuthError::ServiceAuthNotAllowed => Self::AuthenticationFailed(
547
+
Some("Service authentication not allowed for this endpoint".to_string()),
548
+
),
549
+
crate::auth::extractor::AuthError::InsufficientScope(msg) => {
550
+
Self::InsufficientScope(Some(msg))
551
+
}
552
+
crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => {
553
+
Self::OAuthExpiredToken(Some(msg))
554
+
}
555
+
crate::auth::extractor::AuthError::UseDpopNonce(_)
556
+
| crate::auth::extractor::AuthError::InvalidDpopProof(_) => {
557
+
Self::AuthenticationFailed(None)
558
+
}
559
}
560
}
561
}
+4
-4
crates/tranquil-pds/src/api/identity/account.rs
+4
-4
crates/tranquil-pds/src/api/identity/account.rs
···
1
use super::did::verify_did_web;
2
use crate::api::error::ApiError;
3
use crate::api::repo::record::utils::create_signed_commit;
4
-
use crate::auth::{ServiceTokenVerifier, is_service_token};
5
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
6
use crate::state::{AppState, RateLimitKind};
7
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
···
96
.into_response();
97
}
98
99
-
let migration_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header(
100
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
101
-
) {
102
let token = extracted.token;
103
if is_service_token(&token) {
104
let verifier = ServiceTokenVerifier::new();
···
1
use super::did::verify_did_web;
2
use crate::api::error::ApiError;
3
use crate::api::repo::record::utils::create_signed_commit;
4
+
use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
5
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
6
use crate::state::{AppState, RateLimitKind};
7
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
···
96
.into_response();
97
}
98
99
+
let migration_auth = if let Some(extracted) =
100
+
extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
101
+
{
102
let token = extracted.token;
103
if is_service_token(&token) {
104
let verifier = ServiceTokenVerifier::new();
+88
-102
crates/tranquil-pds/src/api/identity/did.rs
+88
-102
crates/tranquil-pds/src/api/identity/did.rs
···
1
use crate::api::{ApiError, DidResponse, EmptyResponse};
2
-
use crate::auth::BearerAuthAllowDeactivated;
3
use crate::plc::signing_key_to_did_key;
4
use crate::state::AppState;
5
use crate::types::Handle;
···
518
519
pub async fn get_recommended_did_credentials(
520
State(state): State<AppState>,
521
-
auth: BearerAuthAllowDeactivated,
522
-
) -> Response {
523
-
let auth_user = auth.0;
524
-
let handle = match state.user_repo.get_handle_by_did(&auth_user.did).await {
525
-
Ok(Some(h)) => h,
526
-
Ok(None) => return ApiError::InternalError(None).into_response(),
527
-
Err(_) => return ApiError::InternalError(None).into_response(),
528
-
};
529
-
let key_bytes = match auth_user.key_bytes {
530
-
Some(kb) => kb,
531
-
None => {
532
-
return ApiError::AuthenticationFailed(Some(
533
-
"OAuth tokens cannot get DID credentials".into(),
534
-
))
535
-
.into_response();
536
-
}
537
-
};
538
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
539
let pds_endpoint = format!("https://{}", hostname);
540
-
let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) {
541
-
Ok(k) => k,
542
-
Err(_) => return ApiError::InternalError(None).into_response(),
543
-
};
544
let did_key = signing_key_to_did_key(&signing_key);
545
-
let rotation_keys = if auth_user.did.starts_with("did:web:") {
546
vec![]
547
} else {
548
let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") {
···
556
};
557
vec![server_rotation_key]
558
};
559
-
(
560
StatusCode::OK,
561
Json(GetRecommendedDidCredentialsOutput {
562
rotation_keys,
···
570
},
571
}),
572
)
573
-
.into_response()
574
}
575
576
#[derive(Deserialize)]
···
580
581
pub async fn update_handle(
582
State(state): State<AppState>,
583
-
auth: BearerAuthAllowDeactivated,
584
Json(input): Json<UpdateHandleInput>,
585
-
) -> Response {
586
-
let auth_user = auth.0;
587
if let Err(e) = crate::auth::scope_check::check_identity_scope(
588
-
auth_user.is_oauth,
589
-
auth_user.scope.as_deref(),
590
crate::oauth::scopes::IdentityAttr::Handle,
591
) {
592
-
return e;
593
}
594
-
let did = auth_user.did;
595
if !state
596
.check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did)
597
.await
598
{
599
-
return ApiError::RateLimitExceeded(Some(
600
"Too many handle updates. Try again later.".into(),
601
-
))
602
-
.into_response();
603
}
604
if !state
605
.check_rate_limit(crate::state::RateLimitKind::HandleUpdateDaily, &did)
606
.await
607
{
608
-
return ApiError::RateLimitExceeded(Some("Daily handle update limit exceeded.".into()))
609
-
.into_response();
610
}
611
-
let user_row = match state.user_repo.get_id_and_handle_by_did(&did).await {
612
-
Ok(Some(row)) => row,
613
-
Ok(None) => return ApiError::InternalError(None).into_response(),
614
-
Err(_) => return ApiError::InternalError(None).into_response(),
615
-
};
616
let user_id = user_row.id;
617
let current_handle = user_row.handle;
618
let new_handle = input.handle.trim().to_ascii_lowercase();
619
if new_handle.is_empty() {
620
-
return ApiError::InvalidRequest("handle is required".into()).into_response();
621
}
622
if !new_handle
623
.chars()
624
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
625
{
626
-
return ApiError::InvalidHandle(Some("Handle contains invalid characters".into()))
627
-
.into_response();
628
}
629
if new_handle.split('.').any(|segment| segment.is_empty()) {
630
-
return ApiError::InvalidHandle(Some("Handle contains empty segment".into()))
631
-
.into_response();
632
}
633
if new_handle
634
.split('.')
635
.any(|segment| segment.starts_with('-') || segment.ends_with('-'))
636
{
637
-
return ApiError::InvalidHandle(Some(
638
"Handle segment cannot start or end with hyphen".into(),
639
-
))
640
-
.into_response();
641
}
642
if crate::moderation::has_explicit_slur(&new_handle) {
643
-
return ApiError::InvalidHandle(Some("Inappropriate language in handle".into()))
644
-
.into_response();
645
}
646
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
647
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
···
667
{
668
warn!("Failed to sequence identity event for handle update: {}", e);
669
}
670
-
return EmptyResponse::ok().into_response();
671
}
672
if short_part.contains('.') {
673
-
return ApiError::InvalidHandle(Some(
674
"Nested subdomains are not allowed. Use a simple handle without dots.".into(),
675
-
))
676
-
.into_response();
677
}
678
if short_part.len() < 3 {
679
-
return ApiError::InvalidHandle(Some("Handle too short".into())).into_response();
680
}
681
if short_part.len() > 18 {
682
-
return ApiError::InvalidHandle(Some("Handle too long".into())).into_response();
683
}
684
full_handle
685
} else {
···
691
{
692
warn!("Failed to sequence identity event for handle update: {}", e);
693
}
694
-
return EmptyResponse::ok().into_response();
695
}
696
match crate::handle::verify_handle_ownership(&new_handle, &did).await {
697
Ok(()) => {}
698
Err(crate::handle::HandleResolutionError::NotFound) => {
699
-
return ApiError::HandleNotAvailable(None).into_response();
700
}
701
Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => {
702
-
return ApiError::HandleNotAvailable(Some(format!(
703
"Handle points to different DID. Expected {}, got {}",
704
expected, actual
705
-
)))
706
-
.into_response();
707
}
708
Err(e) => {
709
warn!("Handle verification failed: {}", e);
710
-
return ApiError::HandleNotAvailable(Some(format!(
711
"Handle verification failed: {}",
712
e
713
-
)))
714
-
.into_response();
715
}
716
}
717
new_handle.clone()
718
};
719
-
let handle_typed: Handle = match handle.parse() {
720
-
Ok(h) => h,
721
-
Err(_) => {
722
-
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
723
-
}
724
-
};
725
-
let handle_exists = match state
726
.user_repo
727
.check_handle_exists(&handle_typed, user_id)
728
.await
729
-
{
730
-
Ok(exists) => exists,
731
-
Err(_) => return ApiError::InternalError(None).into_response(),
732
-
};
733
if handle_exists {
734
-
return ApiError::HandleTaken.into_response();
735
}
736
-
let result = state.user_repo.update_handle(user_id, &handle_typed).await;
737
-
match result {
738
-
Ok(_) => {
739
-
if !current_handle.is_empty() {
740
-
let _ = state
741
-
.cache
742
-
.delete(&format!("handle:{}", current_handle))
743
-
.await;
744
-
}
745
-
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
746
-
if let Err(e) =
747
-
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed))
748
-
.await
749
-
{
750
-
warn!("Failed to sequence identity event for handle update: {}", e);
751
-
}
752
-
if let Err(e) = update_plc_handle(&state, &did, &handle_typed).await {
753
-
warn!("Failed to update PLC handle: {}", e);
754
-
}
755
-
EmptyResponse::ok().into_response()
756
-
}
757
-
Err(e) => {
758
error!("DB error updating handle: {:?}", e);
759
-
ApiError::InternalError(None).into_response()
760
-
}
761
}
762
}
763
764
pub async fn update_plc_handle(
···
1
use crate::api::{ApiError, DidResponse, EmptyResponse};
2
+
use crate::auth::{Auth, NotTakendown};
3
use crate::plc::signing_key_to_did_key;
4
use crate::state::AppState;
5
use crate::types::Handle;
···
518
519
pub async fn get_recommended_did_credentials(
520
State(state): State<AppState>,
521
+
auth: Auth<NotTakendown>,
522
+
) -> Result<Response, ApiError> {
523
+
let handle = state
524
+
.user_repo
525
+
.get_handle_by_did(&auth.did)
526
+
.await
527
+
.map_err(|_| ApiError::InternalError(None))?
528
+
.ok_or(ApiError::InternalError(None))?;
529
+
530
+
let key_bytes = auth.key_bytes.clone().ok_or_else(|| {
531
+
ApiError::AuthenticationFailed(Some("OAuth tokens cannot get DID credentials".into()))
532
+
})?;
533
+
534
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
535
let pds_endpoint = format!("https://{}", hostname);
536
+
let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)
537
+
.map_err(|_| ApiError::InternalError(None))?;
538
let did_key = signing_key_to_did_key(&signing_key);
539
+
let rotation_keys = if auth.did.starts_with("did:web:") {
540
vec![]
541
} else {
542
let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") {
···
550
};
551
vec![server_rotation_key]
552
};
553
+
Ok((
554
StatusCode::OK,
555
Json(GetRecommendedDidCredentialsOutput {
556
rotation_keys,
···
564
},
565
}),
566
)
567
+
.into_response())
568
}
569
570
#[derive(Deserialize)]
···
574
575
pub async fn update_handle(
576
State(state): State<AppState>,
577
+
auth: Auth<NotTakendown>,
578
Json(input): Json<UpdateHandleInput>,
579
+
) -> Result<Response, ApiError> {
580
if let Err(e) = crate::auth::scope_check::check_identity_scope(
581
+
auth.is_oauth(),
582
+
auth.scope.as_deref(),
583
crate::oauth::scopes::IdentityAttr::Handle,
584
) {
585
+
return Ok(e);
586
}
587
+
let did = auth.did.clone();
588
if !state
589
.check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did)
590
.await
591
{
592
+
return Err(ApiError::RateLimitExceeded(Some(
593
"Too many handle updates. Try again later.".into(),
594
+
)));
595
}
596
if !state
597
.check_rate_limit(crate::state::RateLimitKind::HandleUpdateDaily, &did)
598
.await
599
{
600
+
return Err(ApiError::RateLimitExceeded(Some(
601
+
"Daily handle update limit exceeded.".into(),
602
+
)));
603
}
604
+
let user_row = state
605
+
.user_repo
606
+
.get_id_and_handle_by_did(&did)
607
+
.await
608
+
.map_err(|_| ApiError::InternalError(None))?
609
+
.ok_or(ApiError::InternalError(None))?;
610
let user_id = user_row.id;
611
let current_handle = user_row.handle;
612
let new_handle = input.handle.trim().to_ascii_lowercase();
613
if new_handle.is_empty() {
614
+
return Err(ApiError::InvalidRequest("handle is required".into()));
615
}
616
if !new_handle
617
.chars()
618
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
619
{
620
+
return Err(ApiError::InvalidHandle(Some(
621
+
"Handle contains invalid characters".into(),
622
+
)));
623
}
624
if new_handle.split('.').any(|segment| segment.is_empty()) {
625
+
return Err(ApiError::InvalidHandle(Some(
626
+
"Handle contains empty segment".into(),
627
+
)));
628
}
629
if new_handle
630
.split('.')
631
.any(|segment| segment.starts_with('-') || segment.ends_with('-'))
632
{
633
+
return Err(ApiError::InvalidHandle(Some(
634
"Handle segment cannot start or end with hyphen".into(),
635
+
)));
636
}
637
if crate::moderation::has_explicit_slur(&new_handle) {
638
+
return Err(ApiError::InvalidHandle(Some(
639
+
"Inappropriate language in handle".into(),
640
+
)));
641
}
642
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
643
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
···
663
{
664
warn!("Failed to sequence identity event for handle update: {}", e);
665
}
666
+
return Ok(EmptyResponse::ok().into_response());
667
}
668
if short_part.contains('.') {
669
+
return Err(ApiError::InvalidHandle(Some(
670
"Nested subdomains are not allowed. Use a simple handle without dots.".into(),
671
+
)));
672
}
673
if short_part.len() < 3 {
674
+
return Err(ApiError::InvalidHandle(Some("Handle too short".into())));
675
}
676
if short_part.len() > 18 {
677
+
return Err(ApiError::InvalidHandle(Some("Handle too long".into())));
678
}
679
full_handle
680
} else {
···
686
{
687
warn!("Failed to sequence identity event for handle update: {}", e);
688
}
689
+
return Ok(EmptyResponse::ok().into_response());
690
}
691
match crate::handle::verify_handle_ownership(&new_handle, &did).await {
692
Ok(()) => {}
693
Err(crate::handle::HandleResolutionError::NotFound) => {
694
+
return Err(ApiError::HandleNotAvailable(None));
695
}
696
Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => {
697
+
return Err(ApiError::HandleNotAvailable(Some(format!(
698
"Handle points to different DID. Expected {}, got {}",
699
expected, actual
700
+
))));
701
}
702
Err(e) => {
703
warn!("Handle verification failed: {}", e);
704
+
return Err(ApiError::HandleNotAvailable(Some(format!(
705
"Handle verification failed: {}",
706
e
707
+
))));
708
}
709
}
710
new_handle.clone()
711
};
712
+
let handle_typed: Handle = handle
713
+
.parse()
714
+
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
715
+
let handle_exists = state
716
.user_repo
717
.check_handle_exists(&handle_typed, user_id)
718
.await
719
+
.map_err(|_| ApiError::InternalError(None))?;
720
if handle_exists {
721
+
return Err(ApiError::HandleTaken);
722
}
723
+
state
724
+
.user_repo
725
+
.update_handle(user_id, &handle_typed)
726
+
.await
727
+
.map_err(|e| {
728
error!("DB error updating handle: {:?}", e);
729
+
ApiError::InternalError(None)
730
+
})?;
731
+
732
+
if !current_handle.is_empty() {
733
+
let _ = state
734
+
.cache
735
+
.delete(&format!("handle:{}", current_handle))
736
+
.await;
737
+
}
738
+
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
739
+
if let Err(e) =
740
+
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed)).await
741
+
{
742
+
warn!("Failed to sequence identity event for handle update: {}", e);
743
+
}
744
+
if let Err(e) = update_plc_handle(&state, &did, &handle_typed).await {
745
+
warn!("Failed to update PLC handle: {}", e);
746
}
747
+
Ok(EmptyResponse::ok().into_response())
748
}
749
750
pub async fn update_plc_handle(
+23
-24
crates/tranquil-pds/src/api/identity/plc/request.rs
+23
-24
crates/tranquil-pds/src/api/identity/plc/request.rs
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
-
use crate::auth::BearerAuthAllowDeactivated;
4
use crate::state::AppState;
5
use axum::{
6
extract::State,
···
15
16
pub async fn request_plc_operation_signature(
17
State(state): State<AppState>,
18
-
auth: BearerAuthAllowDeactivated,
19
-
) -> Response {
20
-
let auth_user = auth.0;
21
if let Err(e) = crate::auth::scope_check::check_identity_scope(
22
-
auth_user.is_oauth,
23
-
auth_user.scope.as_deref(),
24
crate::oauth::scopes::IdentityAttr::Wildcard,
25
) {
26
-
return e;
27
}
28
-
let user_id = match state.user_repo.get_id_by_did(&auth_user.did).await {
29
-
Ok(Some(id)) => id,
30
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
31
-
Err(e) => {
32
error!("DB error: {:?}", e);
33
-
return ApiError::InternalError(None).into_response();
34
-
}
35
-
};
36
let _ = state.infra_repo.delete_plc_tokens_for_user(user_id).await;
37
let plc_token = generate_plc_token();
38
let expires_at = Utc::now() + Duration::minutes(10);
39
-
if let Err(e) = state
40
.infra_repo
41
.insert_plc_token(user_id, &plc_token, expires_at)
42
.await
43
-
{
44
-
error!("Failed to create PLC token: {:?}", e);
45
-
return ApiError::InternalError(None).into_response();
46
-
}
47
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
48
if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation(
49
state.user_repo.as_ref(),
···
56
{
57
warn!("Failed to enqueue PLC operation notification: {:?}", e);
58
}
59
-
info!(
60
-
"PLC operation signature requested for user {}",
61
-
auth_user.did
62
-
);
63
-
EmptyResponse::ok().into_response()
64
}
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
+
use crate::auth::{Auth, Permissive};
4
use crate::state::AppState;
5
use axum::{
6
extract::State,
···
15
16
pub async fn request_plc_operation_signature(
17
State(state): State<AppState>,
18
+
auth: Auth<Permissive>,
19
+
) -> Result<Response, ApiError> {
20
if let Err(e) = crate::auth::scope_check::check_identity_scope(
21
+
auth.is_oauth(),
22
+
auth.scope.as_deref(),
23
crate::oauth::scopes::IdentityAttr::Wildcard,
24
) {
25
+
return Ok(e);
26
}
27
+
let user_id = state
28
+
.user_repo
29
+
.get_id_by_did(&auth.did)
30
+
.await
31
+
.map_err(|e| {
32
error!("DB error: {:?}", e);
33
+
ApiError::InternalError(None)
34
+
})?
35
+
.ok_or(ApiError::AccountNotFound)?;
36
+
37
let _ = state.infra_repo.delete_plc_tokens_for_user(user_id).await;
38
let plc_token = generate_plc_token();
39
let expires_at = Utc::now() + Duration::minutes(10);
40
+
state
41
.infra_repo
42
.insert_plc_token(user_id, &plc_token, expires_at)
43
.await
44
+
.map_err(|e| {
45
+
error!("Failed to create PLC token: {:?}", e);
46
+
ApiError::InternalError(None)
47
+
})?;
48
+
49
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
50
if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation(
51
state.user_repo.as_ref(),
···
58
{
59
warn!("Failed to enqueue PLC operation notification: {:?}", e);
60
}
61
+
info!("PLC operation signature requested for user {}", auth.did);
62
+
Ok(EmptyResponse::ok().into_response())
63
}
+70
-85
crates/tranquil-pds/src/api/identity/plc/sign.rs
+70
-85
crates/tranquil-pds/src/api/identity/plc/sign.rs
···
1
use crate::api::ApiError;
2
-
use crate::auth::BearerAuthAllowDeactivated;
3
use crate::circuit_breaker::with_circuit_breaker;
4
use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
5
use crate::state::AppState;
···
40
41
pub async fn sign_plc_operation(
42
State(state): State<AppState>,
43
-
auth: BearerAuthAllowDeactivated,
44
Json(input): Json<SignPlcOperationInput>,
45
-
) -> Response {
46
-
let auth_user = auth.0;
47
if let Err(e) = crate::auth::scope_check::check_identity_scope(
48
-
auth_user.is_oauth,
49
-
auth_user.scope.as_deref(),
50
crate::oauth::scopes::IdentityAttr::Wildcard,
51
) {
52
-
return e;
53
}
54
-
let did = &auth_user.did;
55
if did.starts_with("did:web:") {
56
-
return ApiError::InvalidRequest(
57
"PLC operations are only valid for did:plc identities".into(),
58
-
)
59
-
.into_response();
60
}
61
-
let token = match &input.token {
62
-
Some(t) => t,
63
-
None => {
64
-
return ApiError::InvalidRequest(
65
-
"Email confirmation token required to sign PLC operations".into(),
66
-
)
67
-
.into_response();
68
-
}
69
-
};
70
-
let user_id = match state.user_repo.get_id_by_did(did).await {
71
-
Ok(Some(id)) => id,
72
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
73
-
Err(e) => {
74
error!("DB error: {:?}", e);
75
-
return ApiError::InternalError(None).into_response();
76
-
}
77
-
};
78
-
let token_expiry = match state.infra_repo.get_plc_token_expiry(user_id, token).await {
79
-
Ok(Some(expiry)) => expiry,
80
-
Ok(None) => {
81
-
return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response();
82
-
}
83
-
Err(e) => {
84
error!("DB error: {:?}", e);
85
-
return ApiError::InternalError(None).into_response();
86
-
}
87
-
};
88
if Utc::now() > token_expiry {
89
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
90
-
return ApiError::ExpiredToken(Some("Token has expired".into())).into_response();
91
}
92
-
let key_row = match state.user_repo.get_user_key_by_id(user_id).await {
93
-
Ok(Some(row)) => row,
94
-
Ok(None) => {
95
-
return ApiError::InternalError(Some("User signing key not found".into()))
96
-
.into_response();
97
-
}
98
-
Err(e) => {
99
error!("DB error: {:?}", e);
100
-
return ApiError::InternalError(None).into_response();
101
-
}
102
-
};
103
-
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
104
-
{
105
-
Ok(k) => k,
106
-
Err(e) => {
107
error!("Failed to decrypt user key: {}", e);
108
-
return ApiError::InternalError(None).into_response();
109
-
}
110
-
};
111
-
let signing_key = match SigningKey::from_slice(&key_bytes) {
112
-
Ok(k) => k,
113
-
Err(e) => {
114
-
error!("Failed to create signing key: {:?}", e);
115
-
return ApiError::InternalError(None).into_response();
116
-
}
117
-
};
118
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
119
let did_clone = did.clone();
120
-
let last_op = match with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
121
plc_client.get_last_op(&did_clone).await
122
})
123
.await
124
-
{
125
-
Ok(op) => op,
126
-
Err(e) => return ApiError::from(e).into_response(),
127
-
};
128
if last_op.is_tombstone() {
129
-
return ApiError::from(PlcError::Tombstoned).into_response();
130
}
131
let services = input.services.map(|s| {
132
s.into_iter()
···
141
})
142
.collect()
143
});
144
-
let unsigned_op = match create_update_op(
145
&last_op,
146
input.rotation_keys,
147
input.verification_methods,
148
input.also_known_as,
149
services,
150
-
) {
151
-
Ok(op) => op,
152
-
Err(PlcError::Tombstoned) => {
153
-
return ApiError::InvalidRequest("Cannot update tombstoned DID".into()).into_response();
154
-
}
155
-
Err(e) => {
156
error!("Failed to create PLC operation: {:?}", e);
157
-
return ApiError::InternalError(None).into_response();
158
-
}
159
-
};
160
-
let signed_op = match sign_operation(&unsigned_op, &signing_key) {
161
-
Ok(op) => op,
162
-
Err(e) => {
163
-
error!("Failed to sign PLC operation: {:?}", e);
164
-
return ApiError::InternalError(None).into_response();
165
}
166
-
};
167
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
168
info!("Signed PLC operation for user {}", did);
169
-
(
170
StatusCode::OK,
171
Json(SignPlcOperationOutput {
172
operation: signed_op,
173
}),
174
)
175
-
.into_response()
176
}
···
1
use crate::api::ApiError;
2
+
use crate::auth::{Auth, Permissive};
3
use crate::circuit_breaker::with_circuit_breaker;
4
use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
5
use crate::state::AppState;
···
40
41
pub async fn sign_plc_operation(
42
State(state): State<AppState>,
43
+
auth: Auth<Permissive>,
44
Json(input): Json<SignPlcOperationInput>,
45
+
) -> Result<Response, ApiError> {
46
if let Err(e) = crate::auth::scope_check::check_identity_scope(
47
+
auth.is_oauth(),
48
+
auth.scope.as_deref(),
49
crate::oauth::scopes::IdentityAttr::Wildcard,
50
) {
51
+
return Ok(e);
52
}
53
+
let did = &auth.did;
54
if did.starts_with("did:web:") {
55
+
return Err(ApiError::InvalidRequest(
56
"PLC operations are only valid for did:plc identities".into(),
57
+
));
58
}
59
+
let token = input.token.as_ref().ok_or_else(|| {
60
+
ApiError::InvalidRequest("Email confirmation token required to sign PLC operations".into())
61
+
})?;
62
+
63
+
let user_id = state
64
+
.user_repo
65
+
.get_id_by_did(did)
66
+
.await
67
+
.map_err(|e| {
68
error!("DB error: {:?}", e);
69
+
ApiError::InternalError(None)
70
+
})?
71
+
.ok_or(ApiError::AccountNotFound)?;
72
+
73
+
let token_expiry = state
74
+
.infra_repo
75
+
.get_plc_token_expiry(user_id, token)
76
+
.await
77
+
.map_err(|e| {
78
error!("DB error: {:?}", e);
79
+
ApiError::InternalError(None)
80
+
})?
81
+
.ok_or_else(|| ApiError::InvalidToken(Some("Invalid or expired token".into())))?;
82
+
83
if Utc::now() > token_expiry {
84
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
85
+
return Err(ApiError::ExpiredToken(Some("Token has expired".into())));
86
}
87
+
let key_row = state
88
+
.user_repo
89
+
.get_user_key_by_id(user_id)
90
+
.await
91
+
.map_err(|e| {
92
error!("DB error: {:?}", e);
93
+
ApiError::InternalError(None)
94
+
})?
95
+
.ok_or_else(|| ApiError::InternalError(Some("User signing key not found".into())))?;
96
+
97
+
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
98
+
.map_err(|e| {
99
error!("Failed to decrypt user key: {}", e);
100
+
ApiError::InternalError(None)
101
+
})?;
102
+
103
+
let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| {
104
+
error!("Failed to create signing key: {:?}", e);
105
+
ApiError::InternalError(None)
106
+
})?;
107
+
108
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
109
let did_clone = did.clone();
110
+
let last_op = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
111
plc_client.get_last_op(&did_clone).await
112
})
113
.await
114
+
.map_err(ApiError::from)?;
115
+
116
if last_op.is_tombstone() {
117
+
return Err(ApiError::from(PlcError::Tombstoned));
118
}
119
let services = input.services.map(|s| {
120
s.into_iter()
···
129
})
130
.collect()
131
});
132
+
let unsigned_op = create_update_op(
133
&last_op,
134
input.rotation_keys,
135
input.verification_methods,
136
input.also_known_as,
137
services,
138
+
)
139
+
.map_err(|e| match e {
140
+
PlcError::Tombstoned => ApiError::InvalidRequest("Cannot update tombstoned DID".into()),
141
+
_ => {
142
error!("Failed to create PLC operation: {:?}", e);
143
+
ApiError::InternalError(None)
144
}
145
+
})?;
146
+
147
+
let signed_op = sign_operation(&unsigned_op, &signing_key).map_err(|e| {
148
+
error!("Failed to sign PLC operation: {:?}", e);
149
+
ApiError::InternalError(None)
150
+
})?;
151
+
152
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
153
info!("Signed PLC operation for user {}", did);
154
+
Ok((
155
StatusCode::OK,
156
Json(SignPlcOperationOutput {
157
operation: signed_op,
158
}),
159
)
160
+
.into_response())
161
}
+58
-61
crates/tranquil-pds/src/api/identity/plc/submit.rs
+58
-61
crates/tranquil-pds/src/api/identity/plc/submit.rs
···
1
use crate::api::{ApiError, EmptyResponse};
2
-
use crate::auth::BearerAuthAllowDeactivated;
3
use crate::circuit_breaker::with_circuit_breaker;
4
use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
5
use crate::state::AppState;
···
20
21
pub async fn submit_plc_operation(
22
State(state): State<AppState>,
23
-
auth: BearerAuthAllowDeactivated,
24
Json(input): Json<SubmitPlcOperationInput>,
25
-
) -> Response {
26
-
let auth_user = auth.0;
27
if let Err(e) = crate::auth::scope_check::check_identity_scope(
28
-
auth_user.is_oauth,
29
-
auth_user.scope.as_deref(),
30
crate::oauth::scopes::IdentityAttr::Wildcard,
31
) {
32
-
return e;
33
}
34
-
let did = &auth_user.did;
35
if did.starts_with("did:web:") {
36
-
return ApiError::InvalidRequest(
37
"PLC operations are only valid for did:plc identities".into(),
38
-
)
39
-
.into_response();
40
}
41
-
if let Err(e) = validate_plc_operation(&input.operation) {
42
-
return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response();
43
-
}
44
let op = &input.operation;
45
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
46
let public_url = format!("https://{}", hostname);
47
-
let user = match state.user_repo.get_id_and_handle_by_did(did).await {
48
-
Ok(Some(u)) => u,
49
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
50
-
Err(e) => {
51
error!("DB error: {:?}", e);
52
-
return ApiError::InternalError(None).into_response();
53
-
}
54
-
};
55
-
let key_row = match state.user_repo.get_user_key_by_id(user.id).await {
56
-
Ok(Some(row)) => row,
57
-
Ok(None) => {
58
-
return ApiError::InternalError(Some("User signing key not found".into()))
59
-
.into_response();
60
-
}
61
-
Err(e) => {
62
error!("DB error: {:?}", e);
63
-
return ApiError::InternalError(None).into_response();
64
-
}
65
-
};
66
-
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
67
-
{
68
-
Ok(k) => k,
69
-
Err(e) => {
70
error!("Failed to decrypt user key: {}", e);
71
-
return ApiError::InternalError(None).into_response();
72
-
}
73
-
};
74
-
let signing_key = match SigningKey::from_slice(&key_bytes) {
75
-
Ok(k) => k,
76
-
Err(e) => {
77
-
error!("Failed to create signing key: {:?}", e);
78
-
return ApiError::InternalError(None).into_response();
79
-
}
80
-
};
81
let user_did_key = signing_key_to_did_key(&signing_key);
82
let server_rotation_key =
83
std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone());
···
86
.iter()
87
.any(|k| k.as_str() == Some(&server_rotation_key));
88
if !has_server_key {
89
-
return ApiError::InvalidRequest(
90
"Rotation keys do not include server's rotation key".into(),
91
-
)
92
-
.into_response();
93
}
94
}
95
if let Some(services) = op.get("services").and_then(|v| v.as_object())
···
98
let service_type = pds.get("type").and_then(|v| v.as_str());
99
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
100
if service_type != Some("AtprotoPersonalDataServer") {
101
-
return ApiError::InvalidRequest("Incorrect type on atproto_pds service".into())
102
-
.into_response();
103
}
104
if endpoint != Some(&public_url) {
105
-
return ApiError::InvalidRequest("Incorrect endpoint on atproto_pds service".into())
106
-
.into_response();
107
}
108
}
109
if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object())
110
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
111
&& atproto_key != user_did_key
112
{
113
-
return ApiError::InvalidRequest("Incorrect signing key in verificationMethods".into())
114
-
.into_response();
115
}
116
if let Some(also_known_as) = (!user.handle.is_empty())
117
.then(|| op.get("alsoKnownAs").and_then(|v| v.as_array()))
···
120
let expected_handle = format!("at://{}", user.handle);
121
let first_aka = also_known_as.first().and_then(|v| v.as_str());
122
if first_aka != Some(&expected_handle) {
123
-
return ApiError::InvalidRequest("Incorrect handle in alsoKnownAs".into())
124
-
.into_response();
125
}
126
}
127
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
128
let operation_clone = input.operation.clone();
129
let did_clone = did.clone();
130
-
if let Err(e) = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
131
plc_client
132
.send_operation(&did_clone, &operation_clone)
133
.await
134
})
135
.await
136
-
{
137
-
return ApiError::from(e).into_response();
138
-
}
139
match state
140
.repo_repo
141
.insert_identity_event(did, Some(&user.handle))
···
157
warn!(did = %did, "Failed to refresh DID cache after PLC update");
158
}
159
info!(did = %did, "PLC operation submitted successfully");
160
-
EmptyResponse::ok().into_response()
161
}
···
1
use crate::api::{ApiError, EmptyResponse};
2
+
use crate::auth::{Auth, Permissive};
3
use crate::circuit_breaker::with_circuit_breaker;
4
use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
5
use crate::state::AppState;
···
20
21
pub async fn submit_plc_operation(
22
State(state): State<AppState>,
23
+
auth: Auth<Permissive>,
24
Json(input): Json<SubmitPlcOperationInput>,
25
+
) -> Result<Response, ApiError> {
26
if let Err(e) = crate::auth::scope_check::check_identity_scope(
27
+
auth.is_oauth(),
28
+
auth.scope.as_deref(),
29
crate::oauth::scopes::IdentityAttr::Wildcard,
30
) {
31
+
return Ok(e);
32
}
33
+
let did = &auth.did;
34
if did.starts_with("did:web:") {
35
+
return Err(ApiError::InvalidRequest(
36
"PLC operations are only valid for did:plc identities".into(),
37
+
));
38
}
39
+
validate_plc_operation(&input.operation)
40
+
.map_err(|e| ApiError::InvalidRequest(format!("Invalid operation: {}", e)))?;
41
+
42
let op = &input.operation;
43
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
44
let public_url = format!("https://{}", hostname);
45
+
let user = state
46
+
.user_repo
47
+
.get_id_and_handle_by_did(did)
48
+
.await
49
+
.map_err(|e| {
50
error!("DB error: {:?}", e);
51
+
ApiError::InternalError(None)
52
+
})?
53
+
.ok_or(ApiError::AccountNotFound)?;
54
+
55
+
let key_row = state
56
+
.user_repo
57
+
.get_user_key_by_id(user.id)
58
+
.await
59
+
.map_err(|e| {
60
error!("DB error: {:?}", e);
61
+
ApiError::InternalError(None)
62
+
})?
63
+
.ok_or_else(|| ApiError::InternalError(Some("User signing key not found".into())))?;
64
+
65
+
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
66
+
.map_err(|e| {
67
error!("Failed to decrypt user key: {}", e);
68
+
ApiError::InternalError(None)
69
+
})?;
70
+
71
+
let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| {
72
+
error!("Failed to create signing key: {:?}", e);
73
+
ApiError::InternalError(None)
74
+
})?;
75
+
76
let user_did_key = signing_key_to_did_key(&signing_key);
77
let server_rotation_key =
78
std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone());
···
81
.iter()
82
.any(|k| k.as_str() == Some(&server_rotation_key));
83
if !has_server_key {
84
+
return Err(ApiError::InvalidRequest(
85
"Rotation keys do not include server's rotation key".into(),
86
+
));
87
}
88
}
89
if let Some(services) = op.get("services").and_then(|v| v.as_object())
···
92
let service_type = pds.get("type").and_then(|v| v.as_str());
93
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
94
if service_type != Some("AtprotoPersonalDataServer") {
95
+
return Err(ApiError::InvalidRequest(
96
+
"Incorrect type on atproto_pds service".into(),
97
+
));
98
}
99
if endpoint != Some(&public_url) {
100
+
return Err(ApiError::InvalidRequest(
101
+
"Incorrect endpoint on atproto_pds service".into(),
102
+
));
103
}
104
}
105
if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object())
106
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
107
&& atproto_key != user_did_key
108
{
109
+
return Err(ApiError::InvalidRequest(
110
+
"Incorrect signing key in verificationMethods".into(),
111
+
));
112
}
113
if let Some(also_known_as) = (!user.handle.is_empty())
114
.then(|| op.get("alsoKnownAs").and_then(|v| v.as_array()))
···
117
let expected_handle = format!("at://{}", user.handle);
118
let first_aka = also_known_as.first().and_then(|v| v.as_str());
119
if first_aka != Some(&expected_handle) {
120
+
return Err(ApiError::InvalidRequest(
121
+
"Incorrect handle in alsoKnownAs".into(),
122
+
));
123
}
124
}
125
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
126
let operation_clone = input.operation.clone();
127
let did_clone = did.clone();
128
+
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
129
plc_client
130
.send_operation(&did_clone, &operation_clone)
131
.await
132
})
133
.await
134
+
.map_err(ApiError::from)?;
135
+
136
match state
137
.repo_repo
138
.insert_identity_event(did, Some(&user.handle))
···
154
warn!(did = %did, "Failed to refresh DID cache after PLC update");
155
}
156
info!(did = %did, "PLC operation submitted successfully");
157
+
Ok(EmptyResponse::ok().into_response())
158
}
+5
-7
crates/tranquil-pds/src/api/moderation/mod.rs
+5
-7
crates/tranquil-pds/src/api/moderation/mod.rs
···
1
use crate::api::ApiError;
2
use crate::api::proxy_client::{is_ssrf_safe, proxy_client};
3
-
use crate::auth::extractor::BearerAuthAllowTakendown;
4
use crate::state::AppState;
5
use axum::{
6
Json,
···
42
43
pub async fn create_report(
44
State(state): State<AppState>,
45
-
auth: BearerAuthAllowTakendown,
46
Json(input): Json<CreateReportInput>,
47
) -> Response {
48
-
let auth_user = auth.0;
49
-
let did = &auth_user.did;
50
51
if let Some((service_url, service_did)) = get_report_service_config() {
52
-
return proxy_to_report_service(&state, &auth_user, &service_url, &service_did, &input)
53
-
.await;
54
}
55
56
-
create_report_locally(&state, did, auth_user.is_takendown(), input).await
57
}
58
59
async fn proxy_to_report_service(
···
1
use crate::api::ApiError;
2
use crate::api::proxy_client::{is_ssrf_safe, proxy_client};
3
+
use crate::auth::{AnyUser, Auth};
4
use crate::state::AppState;
5
use axum::{
6
Json,
···
42
43
pub async fn create_report(
44
State(state): State<AppState>,
45
+
auth: Auth<AnyUser>,
46
Json(input): Json<CreateReportInput>,
47
) -> Response {
48
+
let did = &auth.did;
49
50
if let Some((service_url, service_did)) = get_report_service_config() {
51
+
return proxy_to_report_service(&state, &auth, &service_url, &service_did, &input).await;
52
}
53
54
+
create_report_locally(&state, did, auth.status.is_takendown(), input).await
55
}
56
57
async fn proxy_to_report_service(
+82
-98
crates/tranquil-pds/src/api/notification_prefs.rs
+82
-98
crates/tranquil-pds/src/api/notification_prefs.rs
···
1
use crate::api::error::ApiError;
2
-
use crate::auth::BearerAuth;
3
use crate::state::AppState;
4
use axum::{
5
Json,
···
23
pub signal_verified: bool,
24
}
25
26
-
pub async fn get_notification_prefs(State(state): State<AppState>, auth: BearerAuth) -> Response {
27
-
let user = auth.0;
28
-
let prefs = match state.user_repo.get_notification_prefs(&user.did).await {
29
-
Ok(Some(p)) => p,
30
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
31
-
Err(e) => {
32
-
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
33
-
}
34
-
};
35
-
Json(NotificationPrefsResponse {
36
preferred_channel: prefs.preferred_channel,
37
email: prefs.email,
38
discord_id: prefs.discord_id,
···
42
signal_number: prefs.signal_number,
43
signal_verified: prefs.signal_verified,
44
})
45
-
.into_response()
46
}
47
48
#[derive(Serialize)]
···
62
pub notifications: Vec<NotificationHistoryEntry>,
63
}
64
65
-
pub async fn get_notification_history(State(state): State<AppState>, auth: BearerAuth) -> Response {
66
-
let user = auth.0;
67
-
68
-
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&user.did).await {
69
-
Ok(Some(id)) => id,
70
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
71
-
Err(e) => {
72
-
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
73
-
}
74
-
};
75
76
-
let rows = match state.infra_repo.get_notification_history(user_id, 50).await {
77
-
Ok(r) => r,
78
-
Err(e) => {
79
-
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
80
-
}
81
-
};
82
83
let sensitive_types = [
84
"email_verification",
···
111
})
112
.collect();
113
114
-
Json(GetNotificationHistoryResponse { notifications }).into_response()
115
}
116
117
#[derive(Deserialize)]
···
184
185
pub async fn update_notification_prefs(
186
State(state): State<AppState>,
187
-
auth: BearerAuth,
188
Json(input): Json<UpdateNotificationPrefsInput>,
189
-
) -> Response {
190
-
let user = auth.0;
191
-
192
-
let user_row = match state.user_repo.get_id_handle_email_by_did(&user.did).await {
193
-
Ok(Some(row)) => row,
194
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
195
-
Err(e) => {
196
-
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
197
-
}
198
-
};
199
200
let user_id = user_row.id;
201
let handle = user_row.handle;
···
206
if let Some(ref channel) = input.preferred_channel {
207
let valid_channels = ["email", "discord", "telegram", "signal"];
208
if !valid_channels.contains(&channel.as_str()) {
209
-
return ApiError::InvalidRequest(
210
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
211
-
)
212
-
.into_response();
213
}
214
-
if let Err(e) = state
215
.user_repo
216
-
.update_preferred_comms_channel(&user.did, channel)
217
.await
218
-
{
219
-
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
220
-
}
221
-
info!(did = %user.did, channel = %channel, "Updated preferred notification channel");
222
}
223
224
if let Some(ref new_email) = input.email {
225
let email_clean = new_email.trim().to_lowercase();
226
if email_clean.is_empty() {
227
-
return ApiError::InvalidRequest("Email cannot be empty".into()).into_response();
228
}
229
230
if !crate::api::validation::is_valid_email(&email_clean) {
231
-
return ApiError::InvalidEmail.into_response();
232
}
233
234
-
if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) {
235
-
info!(did = %user.did, "Email unchanged, skipping");
236
-
} else {
237
-
if let Err(e) = request_channel_verification(
238
&state,
239
user_id,
240
-
&user.did,
241
"email",
242
&email_clean,
243
Some(&handle),
244
)
245
.await
246
-
{
247
-
return ApiError::InternalError(Some(e)).into_response();
248
-
}
249
verification_required.push("email".to_string());
250
-
info!(did = %user.did, "Requested email verification");
251
}
252
}
253
254
if let Some(ref discord_id) = input.discord_id {
255
if discord_id.is_empty() {
256
-
if let Err(e) = state.user_repo.clear_discord(user_id).await {
257
-
return ApiError::InternalError(Some(format!("Database error: {}", e)))
258
-
.into_response();
259
-
}
260
-
info!(did = %user.did, "Cleared Discord ID");
261
} else {
262
-
if let Err(e) = request_channel_verification(
263
-
&state, user_id, &user.did, "discord", discord_id, None,
264
-
)
265
-
.await
266
-
{
267
-
return ApiError::InternalError(Some(e)).into_response();
268
-
}
269
verification_required.push("discord".to_string());
270
-
info!(did = %user.did, "Requested Discord verification");
271
}
272
}
273
274
if let Some(ref telegram) = input.telegram_username {
275
let telegram_clean = telegram.trim_start_matches('@');
276
if telegram_clean.is_empty() {
277
-
if let Err(e) = state.user_repo.clear_telegram(user_id).await {
278
-
return ApiError::InternalError(Some(format!("Database error: {}", e)))
279
-
.into_response();
280
-
}
281
-
info!(did = %user.did, "Cleared Telegram username");
282
} else {
283
-
if let Err(e) = request_channel_verification(
284
&state,
285
user_id,
286
-
&user.did,
287
"telegram",
288
telegram_clean,
289
None,
290
)
291
.await
292
-
{
293
-
return ApiError::InternalError(Some(e)).into_response();
294
-
}
295
verification_required.push("telegram".to_string());
296
-
info!(did = %user.did, "Requested Telegram verification");
297
}
298
}
299
300
if let Some(ref signal) = input.signal_number {
301
if signal.is_empty() {
302
-
if let Err(e) = state.user_repo.clear_signal(user_id).await {
303
-
return ApiError::InternalError(Some(format!("Database error: {}", e)))
304
-
.into_response();
305
-
}
306
-
info!(did = %user.did, "Cleared Signal number");
307
} else {
308
-
if let Err(e) =
309
-
request_channel_verification(&state, user_id, &user.did, "signal", signal, None)
310
-
.await
311
-
{
312
-
return ApiError::InternalError(Some(e)).into_response();
313
-
}
314
verification_required.push("signal".to_string());
315
-
info!(did = %user.did, "Requested Signal verification");
316
}
317
}
318
319
-
Json(UpdateNotificationPrefsResponse {
320
success: true,
321
verification_required,
322
})
323
-
.into_response()
324
}
···
1
use crate::api::error::ApiError;
2
+
use crate::auth::{Active, Auth};
3
use crate::state::AppState;
4
use axum::{
5
Json,
···
23
pub signal_verified: bool,
24
}
25
26
+
pub async fn get_notification_prefs(
27
+
State(state): State<AppState>,
28
+
auth: Auth<Active>,
29
+
) -> Result<Response, ApiError> {
30
+
let prefs = state
31
+
.user_repo
32
+
.get_notification_prefs(&auth.did)
33
+
.await
34
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
35
+
.ok_or(ApiError::AccountNotFound)?;
36
+
Ok(Json(NotificationPrefsResponse {
37
preferred_channel: prefs.preferred_channel,
38
email: prefs.email,
39
discord_id: prefs.discord_id,
···
43
signal_number: prefs.signal_number,
44
signal_verified: prefs.signal_verified,
45
})
46
+
.into_response())
47
}
48
49
#[derive(Serialize)]
···
63
pub notifications: Vec<NotificationHistoryEntry>,
64
}
65
66
+
pub async fn get_notification_history(
67
+
State(state): State<AppState>,
68
+
auth: Auth<Active>,
69
+
) -> Result<Response, ApiError> {
70
+
let user_id = state
71
+
.user_repo
72
+
.get_id_by_did(&auth.did)
73
+
.await
74
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
75
+
.ok_or(ApiError::AccountNotFound)?;
76
77
+
let rows = state
78
+
.infra_repo
79
+
.get_notification_history(user_id, 50)
80
+
.await
81
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
82
83
let sensitive_types = [
84
"email_verification",
···
111
})
112
.collect();
113
114
+
Ok(Json(GetNotificationHistoryResponse { notifications }).into_response())
115
}
116
117
#[derive(Deserialize)]
···
184
185
pub async fn update_notification_prefs(
186
State(state): State<AppState>,
187
+
auth: Auth<Active>,
188
Json(input): Json<UpdateNotificationPrefsInput>,
189
+
) -> Result<Response, ApiError> {
190
+
let user_row = state
191
+
.user_repo
192
+
.get_id_handle_email_by_did(&auth.did)
193
+
.await
194
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
195
+
.ok_or(ApiError::AccountNotFound)?;
196
197
let user_id = user_row.id;
198
let handle = user_row.handle;
···
203
if let Some(ref channel) = input.preferred_channel {
204
let valid_channels = ["email", "discord", "telegram", "signal"];
205
if !valid_channels.contains(&channel.as_str()) {
206
+
return Err(ApiError::InvalidRequest(
207
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
208
+
));
209
}
210
+
state
211
.user_repo
212
+
.update_preferred_comms_channel(&auth.did, channel)
213
.await
214
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
215
+
info!(did = %auth.did, channel = %channel, "Updated preferred notification channel");
216
}
217
218
if let Some(ref new_email) = input.email {
219
let email_clean = new_email.trim().to_lowercase();
220
if email_clean.is_empty() {
221
+
return Err(ApiError::InvalidRequest("Email cannot be empty".into()));
222
}
223
224
if !crate::api::validation::is_valid_email(&email_clean) {
225
+
return Err(ApiError::InvalidEmail);
226
}
227
228
+
if current_email.as_ref().map(|e| e.to_lowercase()) != Some(email_clean.clone()) {
229
+
request_channel_verification(
230
&state,
231
user_id,
232
+
&auth.did,
233
"email",
234
&email_clean,
235
Some(&handle),
236
)
237
.await
238
+
.map_err(|e| ApiError::InternalError(Some(e)))?;
239
verification_required.push("email".to_string());
240
+
info!(did = %auth.did, "Requested email verification");
241
}
242
}
243
244
if let Some(ref discord_id) = input.discord_id {
245
if discord_id.is_empty() {
246
+
state
247
+
.user_repo
248
+
.clear_discord(user_id)
249
+
.await
250
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
251
+
info!(did = %auth.did, "Cleared Discord ID");
252
} else {
253
+
request_channel_verification(&state, user_id, &auth.did, "discord", discord_id, None)
254
+
.await
255
+
.map_err(|e| ApiError::InternalError(Some(e)))?;
256
verification_required.push("discord".to_string());
257
+
info!(did = %auth.did, "Requested Discord verification");
258
}
259
}
260
261
if let Some(ref telegram) = input.telegram_username {
262
let telegram_clean = telegram.trim_start_matches('@');
263
if telegram_clean.is_empty() {
264
+
state
265
+
.user_repo
266
+
.clear_telegram(user_id)
267
+
.await
268
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
269
+
info!(did = %auth.did, "Cleared Telegram username");
270
} else {
271
+
request_channel_verification(
272
&state,
273
user_id,
274
+
&auth.did,
275
"telegram",
276
telegram_clean,
277
None,
278
)
279
.await
280
+
.map_err(|e| ApiError::InternalError(Some(e)))?;
281
verification_required.push("telegram".to_string());
282
+
info!(did = %auth.did, "Requested Telegram verification");
283
}
284
}
285
286
if let Some(ref signal) = input.signal_number {
287
if signal.is_empty() {
288
+
state
289
+
.user_repo
290
+
.clear_signal(user_id)
291
+
.await
292
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
293
+
info!(did = %auth.did, "Cleared Signal number");
294
} else {
295
+
request_channel_verification(&state, user_id, &auth.did, "signal", signal, None)
296
+
.await
297
+
.map_err(|e| ApiError::InternalError(Some(e)))?;
298
verification_required.push("signal".to_string());
299
+
info!(did = %auth.did, "Requested Signal verification");
300
}
301
}
302
303
+
Ok(Json(UpdateNotificationPrefsResponse {
304
success: true,
305
verification_required,
306
})
307
+
.into_response())
308
}
+13
-4
crates/tranquil-pds/src/api/proxy.rs
+13
-4
crates/tranquil-pds/src/api/proxy.rs
···
238
{
239
Ok(auth_user) => {
240
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
241
-
auth_user.is_oauth,
242
auth_user.scope.as_deref(),
243
&resolved.did,
244
method,
···
267
}
268
}
269
Err(e) => {
270
-
warn!("Token validation failed: {:?}", e);
271
-
if matches!(e, crate::auth::TokenValidationError::OAuthTokenExpired) {
272
-
return ApiError::from(e).into_response();
273
}
274
}
275
}
···
238
{
239
Ok(auth_user) => {
240
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
241
+
auth_user.is_oauth(),
242
auth_user.scope.as_deref(),
243
&resolved.did,
244
method,
···
267
}
268
}
269
Err(e) => {
270
+
info!(error = ?e, "Proxy token validation failed, returning error to client");
271
+
if matches!(
272
+
e,
273
+
crate::auth::TokenValidationError::OAuthTokenExpired
274
+
| crate::auth::TokenValidationError::TokenExpired
275
+
) {
276
+
let mut response = ApiError::from(e).into_response();
277
+
let nonce = crate::oauth::verify::generate_dpop_nonce();
278
+
if let Ok(nonce_val) = nonce.parse() {
279
+
response.headers_mut().insert("DPoP-Nonce", nonce_val);
280
+
}
281
+
return response;
282
}
283
}
284
}
+62
-120
crates/tranquil-pds/src/api/repo/blob.rs
+62
-120
crates/tranquil-pds/src/api/repo/blob.rs
···
1
use crate::api::error::ApiError;
2
-
use crate::auth::{BearerAuthAllowDeactivated, ServiceTokenVerifier, is_service_token};
3
use crate::delegation::DelegationActionType;
4
use crate::state::AppState;
5
use crate::types::{CidLink, Did};
···
44
pub async fn upload_blob(
45
State(state): State<AppState>,
46
headers: axum::http::HeaderMap,
47
body: Body,
48
-
) -> Response {
49
-
let extracted = match crate::auth::extract_auth_token_from_header(
50
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
51
-
) {
52
-
Some(t) => t,
53
-
None => return ApiError::AuthenticationRequired.into_response(),
54
-
};
55
-
let token = extracted.token;
56
-
57
-
let is_service_auth = is_service_token(&token);
58
-
59
-
let (did, _is_migration, controller_did): (Did, bool, Option<Did>) = if is_service_auth {
60
-
debug!("Verifying service token for blob upload");
61
-
let verifier = ServiceTokenVerifier::new();
62
-
match verifier
63
-
.verify_service_token(&token, Some("com.atproto.repo.uploadBlob"))
64
-
.await
65
-
{
66
-
Ok(claims) => {
67
-
debug!("Service token verified for DID: {}", claims.iss);
68
-
let did: Did = match claims.iss.parse() {
69
-
Ok(d) => d,
70
-
Err(_) => {
71
-
return ApiError::InvalidDid("Invalid DID format".into()).into_response();
72
-
}
73
-
};
74
-
(did, false, None)
75
-
}
76
-
Err(e) => {
77
-
error!("Service token verification failed: {:?}", e);
78
-
return ApiError::AuthenticationFailed(Some(format!(
79
-
"Service token verification failed: {}",
80
-
e
81
-
)))
82
-
.into_response();
83
-
}
84
}
85
-
} else {
86
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
87
-
let http_uri = format!(
88
-
"https://{}/xrpc/com.atproto.repo.uploadBlob",
89
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
90
-
);
91
-
match crate::auth::validate_token_with_dpop(
92
-
state.user_repo.as_ref(),
93
-
state.oauth_repo.as_ref(),
94
-
&token,
95
-
extracted.is_dpop,
96
-
dpop_proof,
97
-
"POST",
98
-
&http_uri,
99
-
true,
100
-
false,
101
-
)
102
-
.await
103
-
{
104
-
Ok(user) => {
105
-
let mime_type_for_check = headers
106
-
.get("content-type")
107
-
.and_then(|h| h.to_str().ok())
108
-
.unwrap_or("application/octet-stream");
109
-
if let Err(e) = crate::auth::scope_check::check_blob_scope(
110
-
user.is_oauth,
111
-
user.scope.as_deref(),
112
-
mime_type_for_check,
113
-
) {
114
-
return e;
115
-
}
116
-
let deactivated = state
117
-
.user_repo
118
-
.get_status_by_did(&user.did)
119
-
.await
120
-
.ok()
121
-
.flatten()
122
-
.and_then(|s| s.deactivated_at);
123
-
let ctrl_did = user.controller_did.clone();
124
-
(user.did, deactivated.is_some(), ctrl_did)
125
}
126
-
Err(_) => {
127
-
return ApiError::AuthenticationFailed(None).into_response();
128
}
129
}
130
};
131
···
135
.await
136
.unwrap_or(false)
137
{
138
-
return ApiError::Forbidden.into_response();
139
}
140
141
let client_mime_hint = headers
···
143
.and_then(|h| h.to_str().ok())
144
.unwrap_or("application/octet-stream");
145
146
-
let user_id = match state.user_repo.get_id_by_did(&did).await {
147
-
Ok(Some(id)) => id,
148
-
_ => {
149
-
return ApiError::InternalError(None).into_response();
150
-
}
151
-
};
152
153
let temp_key = format!("temp/{}", uuid::Uuid::new_v4());
154
let max_size = get_max_blob_size() as u64;
···
161
162
info!("Starting streaming blob upload to temp key: {}", temp_key);
163
164
-
let upload_result = match state.blob_store.put_stream(&temp_key, pinned_stream).await {
165
-
Ok(result) => result,
166
-
Err(e) => {
167
error!("Failed to stream blob to storage: {:?}", e);
168
-
return ApiError::InternalError(Some("Failed to store blob".into())).into_response();
169
-
}
170
-
};
171
172
let size = upload_result.size;
173
if size > max_size {
174
let _ = state.blob_store.delete(&temp_key).await;
175
-
return ApiError::InvalidRequest(format!(
176
"Blob size {} exceeds maximum of {} bytes",
177
size, max_size
178
-
))
179
-
.into_response();
180
}
181
182
let mime_type = match state.blob_store.get_head(&temp_key, 8192).await {
···
192
Err(e) => {
193
let _ = state.blob_store.delete(&temp_key).await;
194
error!("Failed to create multihash for blob: {:?}", e);
195
-
return ApiError::InternalError(Some("Failed to hash blob".into())).into_response();
196
}
197
};
198
let cid = Cid::new_v1(0x55, multihash);
···
215
Err(e) => {
216
let _ = state.blob_store.delete(&temp_key).await;
217
error!("Failed to insert blob record: {:?}", e);
218
-
return ApiError::InternalError(None).into_response();
219
}
220
};
221
222
if was_inserted && let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
223
let _ = state.blob_store.delete(&temp_key).await;
224
error!("Failed to copy blob to final location: {:?}", e);
225
-
return ApiError::InternalError(Some("Failed to store blob".into())).into_response();
226
}
227
228
let _ = state.blob_store.delete(&temp_key).await;
···
246
.await;
247
}
248
249
-
Json(json!({
250
"blob": {
251
"$type": "blob",
252
"ref": {
···
256
"size": size
257
}
258
}))
259
-
.into_response()
260
}
261
262
#[derive(Deserialize)]
···
281
282
pub async fn list_missing_blobs(
283
State(state): State<AppState>,
284
-
auth: BearerAuthAllowDeactivated,
285
Query(params): Query<ListMissingBlobsParams>,
286
-
) -> Response {
287
-
let auth_user = auth.0;
288
-
let did = &auth_user.did;
289
-
let user = match state.user_repo.get_by_did(did).await {
290
-
Ok(Some(u)) => u,
291
-
Ok(None) => return ApiError::InternalError(None).into_response(),
292
-
Err(e) => {
293
error!("DB error fetching user: {:?}", e);
294
-
return ApiError::InternalError(None).into_response();
295
-
}
296
-
};
297
let limit = params.limit.unwrap_or(500).clamp(1, 1000);
298
let cursor = params.cursor.as_deref();
299
-
let missing = match state
300
.blob_repo
301
.list_missing_blobs(user.id, cursor, limit + 1)
302
.await
303
-
{
304
-
Ok(m) => m,
305
-
Err(e) => {
306
error!("DB error fetching missing blobs: {:?}", e);
307
-
return ApiError::InternalError(None).into_response();
308
-
}
309
-
};
310
let has_more = missing.len() > limit as usize;
311
let blobs: Vec<RecordBlob> = missing
312
.into_iter()
···
321
} else {
322
None
323
};
324
-
(
325
StatusCode::OK,
326
Json(ListMissingBlobsOutput {
327
cursor: next_cursor,
328
blobs,
329
}),
330
)
331
-
.into_response()
332
}
···
1
use crate::api::error::ApiError;
2
+
use crate::auth::{Auth, AuthAny, NotTakendown, Permissive};
3
use crate::delegation::DelegationActionType;
4
use crate::state::AppState;
5
use crate::types::{CidLink, Did};
···
44
pub async fn upload_blob(
45
State(state): State<AppState>,
46
headers: axum::http::HeaderMap,
47
+
auth: AuthAny<Permissive>,
48
body: Body,
49
+
) -> Result<Response, ApiError> {
50
+
let (did, controller_did): (Did, Option<Did>) = match &auth {
51
+
AuthAny::Service(service) => {
52
+
service.require_lxm("com.atproto.repo.uploadBlob")?;
53
+
(service.did.clone(), None)
54
}
55
+
AuthAny::User(user) => {
56
+
if user.status.is_takendown() {
57
+
return Err(ApiError::AccountTakedown);
58
}
59
+
let mime_type_for_check = headers
60
+
.get("content-type")
61
+
.and_then(|h| h.to_str().ok())
62
+
.unwrap_or("application/octet-stream");
63
+
if let Err(e) = crate::auth::scope_check::check_blob_scope(
64
+
user.is_oauth(),
65
+
user.scope.as_deref(),
66
+
mime_type_for_check,
67
+
) {
68
+
return Ok(e);
69
}
70
+
(user.did.clone(), user.controller_did.clone())
71
}
72
};
73
···
77
.await
78
.unwrap_or(false)
79
{
80
+
return Err(ApiError::Forbidden);
81
}
82
83
let client_mime_hint = headers
···
85
.and_then(|h| h.to_str().ok())
86
.unwrap_or("application/octet-stream");
87
88
+
let user_id = state
89
+
.user_repo
90
+
.get_id_by_did(&did)
91
+
.await
92
+
.ok()
93
+
.flatten()
94
+
.ok_or(ApiError::InternalError(None))?;
95
96
let temp_key = format!("temp/{}", uuid::Uuid::new_v4());
97
let max_size = get_max_blob_size() as u64;
···
104
105
info!("Starting streaming blob upload to temp key: {}", temp_key);
106
107
+
let upload_result = state
108
+
.blob_store
109
+
.put_stream(&temp_key, pinned_stream)
110
+
.await
111
+
.map_err(|e| {
112
error!("Failed to stream blob to storage: {:?}", e);
113
+
ApiError::InternalError(Some("Failed to store blob".into()))
114
+
})?;
115
116
let size = upload_result.size;
117
if size > max_size {
118
let _ = state.blob_store.delete(&temp_key).await;
119
+
return Err(ApiError::InvalidRequest(format!(
120
"Blob size {} exceeds maximum of {} bytes",
121
size, max_size
122
+
)));
123
}
124
125
let mime_type = match state.blob_store.get_head(&temp_key, 8192).await {
···
135
Err(e) => {
136
let _ = state.blob_store.delete(&temp_key).await;
137
error!("Failed to create multihash for blob: {:?}", e);
138
+
return Err(ApiError::InternalError(Some("Failed to hash blob".into())));
139
}
140
};
141
let cid = Cid::new_v1(0x55, multihash);
···
158
Err(e) => {
159
let _ = state.blob_store.delete(&temp_key).await;
160
error!("Failed to insert blob record: {:?}", e);
161
+
return Err(ApiError::InternalError(None));
162
}
163
};
164
165
if was_inserted && let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
166
let _ = state.blob_store.delete(&temp_key).await;
167
error!("Failed to copy blob to final location: {:?}", e);
168
+
return Err(ApiError::InternalError(Some("Failed to store blob".into())));
169
}
170
171
let _ = state.blob_store.delete(&temp_key).await;
···
189
.await;
190
}
191
192
+
Ok(Json(json!({
193
"blob": {
194
"$type": "blob",
195
"ref": {
···
199
"size": size
200
}
201
}))
202
+
.into_response())
203
}
204
205
#[derive(Deserialize)]
···
224
225
pub async fn list_missing_blobs(
226
State(state): State<AppState>,
227
+
auth: Auth<NotTakendown>,
228
Query(params): Query<ListMissingBlobsParams>,
229
+
) -> Result<Response, ApiError> {
230
+
let did = &auth.did;
231
+
let user = state
232
+
.user_repo
233
+
.get_by_did(did)
234
+
.await
235
+
.map_err(|e| {
236
error!("DB error fetching user: {:?}", e);
237
+
ApiError::InternalError(None)
238
+
})?
239
+
.ok_or(ApiError::InternalError(None))?;
240
+
241
let limit = params.limit.unwrap_or(500).clamp(1, 1000);
242
let cursor = params.cursor.as_deref();
243
+
let missing = state
244
.blob_repo
245
.list_missing_blobs(user.id, cursor, limit + 1)
246
.await
247
+
.map_err(|e| {
248
error!("DB error fetching missing blobs: {:?}", e);
249
+
ApiError::InternalError(None)
250
+
})?;
251
+
252
let has_more = missing.len() > limit as usize;
253
let blobs: Vec<RecordBlob> = missing
254
.into_iter()
···
263
} else {
264
None
265
};
266
+
Ok((
267
StatusCode::OK,
268
Json(ListMissingBlobsOutput {
269
cursor: next_cursor,
270
blobs,
271
}),
272
)
273
+
.into_response())
274
}
+129
-131
crates/tranquil-pds/src/api/repo/import.rs
+129
-131
crates/tranquil-pds/src/api/repo/import.rs
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
use crate::api::repo::record::create_signed_commit;
4
-
use crate::auth::BearerAuthAllowDeactivated;
5
use crate::state::AppState;
6
use crate::sync::import::{ImportError, apply_import, parse_car};
7
use crate::sync::verify::CarVerifier;
···
23
24
pub async fn import_repo(
25
State(state): State<AppState>,
26
-
auth: BearerAuthAllowDeactivated,
27
body: Bytes,
28
-
) -> Response {
29
let accepting_imports = std::env::var("ACCEPTING_REPO_IMPORTS")
30
.map(|v| v != "false" && v != "0")
31
.unwrap_or(true);
32
if !accepting_imports {
33
-
return ApiError::InvalidRequest("Service is not accepting repo imports".into())
34
-
.into_response();
35
}
36
let max_size: usize = std::env::var("MAX_IMPORT_SIZE")
37
.ok()
38
.and_then(|s| s.parse().ok())
39
.unwrap_or(DEFAULT_MAX_IMPORT_SIZE);
40
if body.len() > max_size {
41
-
return ApiError::PayloadTooLarge(format!(
42
"Import size exceeds limit of {} bytes",
43
max_size
44
-
))
45
-
.into_response();
46
}
47
-
let auth_user = auth.0;
48
-
let did = &auth_user.did;
49
-
let user = match state.user_repo.get_by_did(did).await {
50
-
Ok(Some(row)) => row,
51
-
Ok(None) => {
52
-
return ApiError::AccountNotFound.into_response();
53
-
}
54
-
Err(e) => {
55
error!("DB error fetching user: {:?}", e);
56
-
return ApiError::InternalError(None).into_response();
57
-
}
58
-
};
59
if user.takedown_ref.is_some() {
60
-
return ApiError::AccountTakedown.into_response();
61
}
62
let user_id = user.id;
63
let (root, blocks) = match parse_car(&body).await {
64
Ok((r, b)) => (r, b),
65
Err(ImportError::InvalidRootCount) => {
66
-
return ApiError::InvalidRequest("Expected exactly one root in CAR file".into())
67
-
.into_response();
68
}
69
Err(ImportError::CarParse(msg)) => {
70
-
return ApiError::InvalidRequest(format!("Failed to parse CAR file: {}", msg))
71
-
.into_response();
72
}
73
Err(e) => {
74
error!("CAR parsing error: {:?}", e);
75
-
return ApiError::InvalidRequest(format!("Invalid CAR file: {}", e)).into_response();
76
}
77
};
78
info!(
···
82
root
83
);
84
let Some(root_block) = blocks.get(&root) else {
85
-
return ApiError::InvalidRequest("Root block not found in CAR file".into()).into_response();
86
};
87
let commit_did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
88
Ok(commit) => commit.did().to_string(),
89
Err(e) => {
90
-
return ApiError::InvalidRequest(format!("Invalid commit: {}", e)).into_response();
91
}
92
};
93
if commit_did != *did {
94
-
return ApiError::InvalidRepo(format!(
95
"CAR file is for DID {} but you are authenticated as {}",
96
commit_did, did
97
-
))
98
-
.into_response();
99
}
100
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
101
.map(|v| v == "true" || v == "1")
···
117
commit_did,
118
expected_did,
119
}) => {
120
-
return ApiError::InvalidRepo(format!(
121
"CAR file is for DID {} but you are authenticated as {}",
122
commit_did, expected_did
123
-
))
124
-
.into_response();
125
}
126
Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => {
127
-
return ApiError::InvalidRequest(format!("MST validation failed: {}", msg))
128
-
.into_response();
129
}
130
Err(e) => {
131
error!("CAR structure verification error: {:?}", e);
132
-
return ApiError::InvalidRequest(format!("CAR verification failed: {}", e))
133
-
.into_response();
134
}
135
}
136
} else {
···
147
commit_did,
148
expected_did,
149
}) => {
150
-
return ApiError::InvalidRepo(format!(
151
"CAR file is for DID {} but you are authenticated as {}",
152
commit_did, expected_did
153
-
))
154
-
.into_response();
155
}
156
Err(crate::sync::verify::VerifyError::InvalidSignature) => {
157
-
return ApiError::InvalidRequest(
158
"CAR file commit signature verification failed".into(),
159
-
)
160
-
.into_response();
161
}
162
Err(crate::sync::verify::VerifyError::DidResolutionFailed(msg)) => {
163
warn!("DID resolution failed during import verification: {}", msg);
164
-
return ApiError::InvalidRequest(format!("Failed to verify DID: {}", msg))
165
-
.into_response();
166
}
167
Err(crate::sync::verify::VerifyError::NoSigningKey) => {
168
-
return ApiError::InvalidRequest(
169
"DID document does not contain a signing key".into(),
170
-
)
171
-
.into_response();
172
}
173
Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => {
174
-
return ApiError::InvalidRequest(format!("MST validation failed: {}", msg))
175
-
.into_response();
176
}
177
Err(e) => {
178
error!("CAR verification error: {:?}", e);
179
-
return ApiError::InvalidRequest(format!("CAR verification failed: {}", e))
180
-
.into_response();
181
}
182
}
183
}
···
227
}
228
}
229
}
230
-
let key_row = match state.user_repo.get_user_with_key_by_did(did).await {
231
-
Ok(Some(row)) => row,
232
-
Ok(None) => {
233
-
error!("No signing key found for user {}", did);
234
-
return ApiError::InternalError(Some("Signing key not found".into()))
235
-
.into_response();
236
-
}
237
-
Err(e) => {
238
error!("DB error fetching signing key: {:?}", e);
239
-
return ApiError::InternalError(None).into_response();
240
-
}
241
-
};
242
let key_bytes =
243
-
match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) {
244
-
Ok(k) => k,
245
-
Err(e) => {
246
error!("Failed to decrypt signing key: {}", e);
247
-
return ApiError::InternalError(None).into_response();
248
-
}
249
-
};
250
-
let signing_key = match SigningKey::from_slice(&key_bytes) {
251
-
Ok(k) => k,
252
-
Err(e) => {
253
-
error!("Invalid signing key: {:?}", e);
254
-
return ApiError::InternalError(None).into_response();
255
-
}
256
-
};
257
let new_rev = Tid::now(LimitedU32::MIN);
258
let new_rev_str = new_rev.to_string();
259
-
let (commit_bytes, _sig) = match create_signed_commit(
260
did,
261
import_result.data_cid,
262
&new_rev_str,
263
None,
264
&signing_key,
265
-
) {
266
-
Ok(result) => result,
267
-
Err(e) => {
268
-
error!("Failed to create new commit: {}", e);
269
-
return ApiError::InternalError(None).into_response();
270
-
}
271
-
};
272
-
let new_root_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
273
-
Ok(cid) => cid,
274
-
Err(e) => {
275
error!("Failed to store new commit block: {:?}", e);
276
-
return ApiError::InternalError(None).into_response();
277
-
}
278
-
};
279
let new_root_cid_link = CidLink::new_unchecked(new_root_cid.to_string());
280
-
if let Err(e) = state
281
.repo_repo
282
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
283
.await
284
-
{
285
-
error!("Failed to update repo root: {:?}", e);
286
-
return ApiError::InternalError(None).into_response();
287
-
}
288
let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
289
all_block_cids.push(new_root_cid.to_bytes());
290
-
if let Err(e) = state
291
.repo_repo
292
.insert_user_blocks(user_id, &all_block_cids, &new_rev_str)
293
.await
294
-
{
295
-
error!("Failed to insert user_blocks: {:?}", e);
296
-
return ApiError::InternalError(None).into_response();
297
-
}
298
let new_root_str = new_root_cid.to_string();
299
info!(
300
"Created new commit for imported repo: cid={}, rev={}",
···
324
);
325
}
326
}
327
-
EmptyResponse::ok().into_response()
328
}
329
-
Err(ImportError::SizeLimitExceeded) => {
330
-
ApiError::PayloadTooLarge(format!("Import exceeds block limit of {}", max_blocks))
331
-
.into_response()
332
-
}
333
-
Err(ImportError::RepoNotFound) => {
334
-
ApiError::RepoNotFound(Some("Repository not initialized for this account".into()))
335
-
.into_response()
336
-
}
337
-
Err(ImportError::InvalidCbor(msg)) => {
338
-
ApiError::InvalidRequest(format!("Invalid CBOR data: {}", msg)).into_response()
339
-
}
340
-
Err(ImportError::InvalidCommit(msg)) => {
341
-
ApiError::InvalidRequest(format!("Invalid commit structure: {}", msg)).into_response()
342
-
}
343
-
Err(ImportError::BlockNotFound(cid)) => {
344
-
ApiError::InvalidRequest(format!("Referenced block not found in CAR: {}", cid))
345
-
.into_response()
346
-
}
347
-
Err(ImportError::ConcurrentModification) => ApiError::InvalidSwap(Some(
348
"Repository is being modified by another operation, please retry".into(),
349
-
))
350
-
.into_response(),
351
-
Err(ImportError::VerificationFailed(ve)) => {
352
-
ApiError::InvalidRequest(format!("CAR verification failed: {}", ve)).into_response()
353
-
}
354
-
Err(ImportError::DidMismatch { car_did, auth_did }) => ApiError::InvalidRequest(format!(
355
-
"CAR is for {} but authenticated as {}",
356
-
car_did, auth_did
357
-
))
358
-
.into_response(),
359
Err(e) => {
360
error!("Import error: {:?}", e);
361
-
ApiError::InternalError(None).into_response()
362
}
363
}
364
}
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
use crate::api::repo::record::create_signed_commit;
4
+
use crate::auth::{Auth, NotTakendown};
5
use crate::state::AppState;
6
use crate::sync::import::{ImportError, apply_import, parse_car};
7
use crate::sync::verify::CarVerifier;
···
23
24
pub async fn import_repo(
25
State(state): State<AppState>,
26
+
auth: Auth<NotTakendown>,
27
body: Bytes,
28
+
) -> Result<Response, ApiError> {
29
let accepting_imports = std::env::var("ACCEPTING_REPO_IMPORTS")
30
.map(|v| v != "false" && v != "0")
31
.unwrap_or(true);
32
if !accepting_imports {
33
+
return Err(ApiError::InvalidRequest(
34
+
"Service is not accepting repo imports".into(),
35
+
));
36
}
37
let max_size: usize = std::env::var("MAX_IMPORT_SIZE")
38
.ok()
39
.and_then(|s| s.parse().ok())
40
.unwrap_or(DEFAULT_MAX_IMPORT_SIZE);
41
if body.len() > max_size {
42
+
return Err(ApiError::PayloadTooLarge(format!(
43
"Import size exceeds limit of {} bytes",
44
max_size
45
+
)));
46
}
47
+
let did = &auth.did;
48
+
let user = state
49
+
.user_repo
50
+
.get_by_did(did)
51
+
.await
52
+
.map_err(|e| {
53
error!("DB error fetching user: {:?}", e);
54
+
ApiError::InternalError(None)
55
+
})?
56
+
.ok_or(ApiError::AccountNotFound)?;
57
if user.takedown_ref.is_some() {
58
+
return Err(ApiError::AccountTakedown);
59
}
60
let user_id = user.id;
61
let (root, blocks) = match parse_car(&body).await {
62
Ok((r, b)) => (r, b),
63
Err(ImportError::InvalidRootCount) => {
64
+
return Err(ApiError::InvalidRequest(
65
+
"Expected exactly one root in CAR file".into(),
66
+
));
67
}
68
Err(ImportError::CarParse(msg)) => {
69
+
return Err(ApiError::InvalidRequest(format!(
70
+
"Failed to parse CAR file: {}",
71
+
msg
72
+
)));
73
}
74
Err(e) => {
75
error!("CAR parsing error: {:?}", e);
76
+
return Err(ApiError::InvalidRequest(format!("Invalid CAR file: {}", e)));
77
}
78
};
79
info!(
···
83
root
84
);
85
let Some(root_block) = blocks.get(&root) else {
86
+
return Err(ApiError::InvalidRequest(
87
+
"Root block not found in CAR file".into(),
88
+
));
89
};
90
let commit_did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
91
Ok(commit) => commit.did().to_string(),
92
Err(e) => {
93
+
return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e)));
94
}
95
};
96
if commit_did != *did {
97
+
return Err(ApiError::InvalidRepo(format!(
98
"CAR file is for DID {} but you are authenticated as {}",
99
commit_did, did
100
+
)));
101
}
102
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
103
.map(|v| v == "true" || v == "1")
···
119
commit_did,
120
expected_did,
121
}) => {
122
+
return Err(ApiError::InvalidRepo(format!(
123
"CAR file is for DID {} but you are authenticated as {}",
124
commit_did, expected_did
125
+
)));
126
}
127
Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => {
128
+
return Err(ApiError::InvalidRequest(format!(
129
+
"MST validation failed: {}",
130
+
msg
131
+
)));
132
}
133
Err(e) => {
134
error!("CAR structure verification error: {:?}", e);
135
+
return Err(ApiError::InvalidRequest(format!(
136
+
"CAR verification failed: {}",
137
+
e
138
+
)));
139
}
140
}
141
} else {
···
152
commit_did,
153
expected_did,
154
}) => {
155
+
return Err(ApiError::InvalidRepo(format!(
156
"CAR file is for DID {} but you are authenticated as {}",
157
commit_did, expected_did
158
+
)));
159
}
160
Err(crate::sync::verify::VerifyError::InvalidSignature) => {
161
+
return Err(ApiError::InvalidRequest(
162
"CAR file commit signature verification failed".into(),
163
+
));
164
}
165
Err(crate::sync::verify::VerifyError::DidResolutionFailed(msg)) => {
166
warn!("DID resolution failed during import verification: {}", msg);
167
+
return Err(ApiError::InvalidRequest(format!(
168
+
"Failed to verify DID: {}",
169
+
msg
170
+
)));
171
}
172
Err(crate::sync::verify::VerifyError::NoSigningKey) => {
173
+
return Err(ApiError::InvalidRequest(
174
"DID document does not contain a signing key".into(),
175
+
));
176
}
177
Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => {
178
+
return Err(ApiError::InvalidRequest(format!(
179
+
"MST validation failed: {}",
180
+
msg
181
+
)));
182
}
183
Err(e) => {
184
error!("CAR verification error: {:?}", e);
185
+
return Err(ApiError::InvalidRequest(format!(
186
+
"CAR verification failed: {}",
187
+
e
188
+
)));
189
}
190
}
191
}
···
235
}
236
}
237
}
238
+
let key_row = state
239
+
.user_repo
240
+
.get_user_with_key_by_did(did)
241
+
.await
242
+
.map_err(|e| {
243
error!("DB error fetching signing key: {:?}", e);
244
+
ApiError::InternalError(None)
245
+
})?
246
+
.ok_or_else(|| {
247
+
error!("No signing key found for user {}", did);
248
+
ApiError::InternalError(Some("Signing key not found".into()))
249
+
})?;
250
let key_bytes =
251
+
crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
252
+
.map_err(|e| {
253
error!("Failed to decrypt signing key: {}", e);
254
+
ApiError::InternalError(None)
255
+
})?;
256
+
let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| {
257
+
error!("Invalid signing key: {:?}", e);
258
+
ApiError::InternalError(None)
259
+
})?;
260
let new_rev = Tid::now(LimitedU32::MIN);
261
let new_rev_str = new_rev.to_string();
262
+
let (commit_bytes, _sig) = create_signed_commit(
263
did,
264
import_result.data_cid,
265
&new_rev_str,
266
None,
267
&signing_key,
268
+
)
269
+
.map_err(|e| {
270
+
error!("Failed to create new commit: {}", e);
271
+
ApiError::InternalError(None)
272
+
})?;
273
+
let new_root_cid: cid::Cid =
274
+
state.block_store.put(&commit_bytes).await.map_err(|e| {
275
error!("Failed to store new commit block: {:?}", e);
276
+
ApiError::InternalError(None)
277
+
})?;
278
let new_root_cid_link = CidLink::new_unchecked(new_root_cid.to_string());
279
+
state
280
.repo_repo
281
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
282
.await
283
+
.map_err(|e| {
284
+
error!("Failed to update repo root: {:?}", e);
285
+
ApiError::InternalError(None)
286
+
})?;
287
let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
288
all_block_cids.push(new_root_cid.to_bytes());
289
+
state
290
.repo_repo
291
.insert_user_blocks(user_id, &all_block_cids, &new_rev_str)
292
.await
293
+
.map_err(|e| {
294
+
error!("Failed to insert user_blocks: {:?}", e);
295
+
ApiError::InternalError(None)
296
+
})?;
297
let new_root_str = new_root_cid.to_string();
298
info!(
299
"Created new commit for imported repo: cid={}, rev={}",
···
323
);
324
}
325
}
326
+
Ok(EmptyResponse::ok().into_response())
327
}
328
+
Err(ImportError::SizeLimitExceeded) => Err(ApiError::PayloadTooLarge(format!(
329
+
"Import exceeds block limit of {}",
330
+
max_blocks
331
+
))),
332
+
Err(ImportError::RepoNotFound) => Err(ApiError::RepoNotFound(Some(
333
+
"Repository not initialized for this account".into(),
334
+
))),
335
+
Err(ImportError::InvalidCbor(msg)) => Err(ApiError::InvalidRequest(format!(
336
+
"Invalid CBOR data: {}",
337
+
msg
338
+
))),
339
+
Err(ImportError::InvalidCommit(msg)) => Err(ApiError::InvalidRequest(format!(
340
+
"Invalid commit structure: {}",
341
+
msg
342
+
))),
343
+
Err(ImportError::BlockNotFound(cid)) => Err(ApiError::InvalidRequest(format!(
344
+
"Referenced block not found in CAR: {}",
345
+
cid
346
+
))),
347
+
Err(ImportError::ConcurrentModification) => Err(ApiError::InvalidSwap(Some(
348
"Repository is being modified by another operation, please retry".into(),
349
+
))),
350
+
Err(ImportError::VerificationFailed(ve)) => Err(ApiError::InvalidRequest(format!(
351
+
"CAR verification failed: {}",
352
+
ve
353
+
))),
354
+
Err(ImportError::DidMismatch { car_did, auth_did }) => Err(ApiError::InvalidRequest(
355
+
format!("CAR is for {} but authenticated as {}", car_did, auth_did),
356
+
)),
357
Err(e) => {
358
error!("Import error: {:?}", e);
359
+
Err(ApiError::InternalError(None))
360
}
361
}
362
}
+63
-65
crates/tranquil-pds/src/api/repo/record/batch.rs
+63
-65
crates/tranquil-pds/src/api/repo/record/batch.rs
···
1
use super::validation::validate_record_with_status;
2
use crate::api::error::ApiError;
3
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
4
-
use crate::auth::BearerAuth;
5
use crate::delegation::DelegationActionType;
6
use crate::repo::tracking::TrackingBlockStore;
7
use crate::state::AppState;
···
262
263
pub async fn apply_writes(
264
State(state): State<AppState>,
265
-
auth: BearerAuth,
266
Json(input): Json<ApplyWritesInput>,
267
-
) -> Response {
268
info!(
269
"apply_writes called: repo={}, writes={}",
270
input.repo,
271
input.writes.len()
272
);
273
-
let auth_user = auth.0;
274
-
let did = auth_user.did.clone();
275
-
let is_oauth = auth_user.is_oauth;
276
-
let scope = auth_user.scope;
277
-
let controller_did = auth_user.controller_did.clone();
278
if input.repo.as_str() != did {
279
-
return ApiError::InvalidRepo("Repo does not match authenticated user".into())
280
-
.into_response();
281
}
282
if state
283
.user_repo
···
285
.await
286
.unwrap_or(false)
287
{
288
-
return ApiError::AccountMigrated.into_response();
289
}
290
let is_verified = state
291
.user_repo
···
298
.await
299
.unwrap_or(false);
300
if !is_verified && !is_delegated {
301
-
return ApiError::AccountNotVerified.into_response();
302
}
303
if input.writes.is_empty() {
304
-
return ApiError::InvalidRequest("writes array is empty".into()).into_response();
305
}
306
if input.writes.len() > MAX_BATCH_WRITES {
307
-
return ApiError::InvalidRequest(format!("Too many writes (max {})", MAX_BATCH_WRITES))
308
-
.into_response();
309
}
310
311
let has_custom_scope = scope
···
374
})
375
.next()
376
{
377
-
return err;
378
}
379
}
380
381
-
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&did).await {
382
-
Ok(Some(id)) => id,
383
-
_ => return ApiError::InternalError(Some("User not found".into())).into_response(),
384
-
};
385
-
let root_cid_str = match state.repo_repo.get_repo_root_cid_by_user_id(user_id).await {
386
-
Ok(Some(cid_str)) => cid_str,
387
-
_ => return ApiError::InternalError(Some("Repo root not found".into())).into_response(),
388
-
};
389
-
let current_root_cid = match Cid::from_str(&root_cid_str) {
390
-
Ok(c) => c,
391
-
Err(_) => {
392
-
return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response();
393
-
}
394
-
};
395
if let Some(swap_commit) = &input.swap_commit
396
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
397
{
398
-
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
399
}
400
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
401
-
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
402
-
Ok(Some(b)) => b,
403
-
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
404
-
};
405
-
let commit = match Commit::from_cbor(&commit_bytes) {
406
-
Ok(c) => c,
407
-
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
408
-
};
409
let original_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
410
let initial_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
411
let WriteAccumulator {
···
424
.await
425
{
426
Ok(acc) => acc,
427
-
Err(response) => return response,
428
-
};
429
-
let new_mst_root = match mst.persist().await {
430
-
Ok(c) => c,
431
-
Err(_) => {
432
-
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response();
433
-
}
434
};
435
let (new_mst_blocks, old_mst_blocks) = {
436
let mut new_blocks = std::collections::BTreeMap::new();
437
let mut old_blocks = std::collections::BTreeMap::new();
438
for key in &modified_keys {
439
-
if mst.blocks_for_path(key, &mut new_blocks).await.is_err() {
440
-
return ApiError::InternalError(Some(
441
-
"Failed to get new MST blocks for path".into(),
442
-
))
443
-
.into_response();
444
-
}
445
-
if original_mst
446
.blocks_for_path(key, &mut old_blocks)
447
.await
448
-
.is_err()
449
-
{
450
-
return ApiError::InternalError(Some(
451
-
"Failed to get old MST blocks for path".into(),
452
-
))
453
-
.into_response();
454
-
}
455
}
456
(new_blocks, old_blocks)
457
};
···
503
{
504
Ok(res) => res,
505
Err(e) if e.contains("ConcurrentModification") => {
506
-
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
507
}
508
Err(e) => {
509
error!("Commit failed: {}", e);
510
-
return ApiError::InternalError(Some("Failed to commit changes".into()))
511
-
.into_response();
512
}
513
};
514
···
557
.await;
558
}
559
560
-
(
561
StatusCode::OK,
562
Json(ApplyWritesOutput {
563
commit: CommitInfo {
···
567
results,
568
}),
569
)
570
-
.into_response()
571
}
···
1
use super::validation::validate_record_with_status;
2
use crate::api::error::ApiError;
3
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
4
+
use crate::auth::{Active, Auth};
5
use crate::delegation::DelegationActionType;
6
use crate::repo::tracking::TrackingBlockStore;
7
use crate::state::AppState;
···
262
263
pub async fn apply_writes(
264
State(state): State<AppState>,
265
+
auth: Auth<Active>,
266
Json(input): Json<ApplyWritesInput>,
267
+
) -> Result<Response, ApiError> {
268
info!(
269
"apply_writes called: repo={}, writes={}",
270
input.repo,
271
input.writes.len()
272
);
273
+
let did = auth.did.clone();
274
+
let is_oauth = auth.is_oauth();
275
+
let scope = auth.scope.clone();
276
+
let controller_did = auth.controller_did.clone();
277
if input.repo.as_str() != did {
278
+
return Err(ApiError::InvalidRepo(
279
+
"Repo does not match authenticated user".into(),
280
+
));
281
}
282
if state
283
.user_repo
···
285
.await
286
.unwrap_or(false)
287
{
288
+
return Err(ApiError::AccountMigrated);
289
}
290
let is_verified = state
291
.user_repo
···
298
.await
299
.unwrap_or(false);
300
if !is_verified && !is_delegated {
301
+
return Err(ApiError::AccountNotVerified);
302
}
303
if input.writes.is_empty() {
304
+
return Err(ApiError::InvalidRequest("writes array is empty".into()));
305
}
306
if input.writes.len() > MAX_BATCH_WRITES {
307
+
return Err(ApiError::InvalidRequest(format!(
308
+
"Too many writes (max {})",
309
+
MAX_BATCH_WRITES
310
+
)));
311
}
312
313
let has_custom_scope = scope
···
376
})
377
.next()
378
{
379
+
return Ok(err);
380
}
381
}
382
383
+
let user_id: uuid::Uuid = state
384
+
.user_repo
385
+
.get_id_by_did(&did)
386
+
.await
387
+
.ok()
388
+
.flatten()
389
+
.ok_or_else(|| ApiError::InternalError(Some("User not found".into())))?;
390
+
let root_cid_str = state
391
+
.repo_repo
392
+
.get_repo_root_cid_by_user_id(user_id)
393
+
.await
394
+
.ok()
395
+
.flatten()
396
+
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?;
397
+
let current_root_cid = Cid::from_str(&root_cid_str)
398
+
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?;
399
if let Some(swap_commit) = &input.swap_commit
400
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
401
{
402
+
return Err(ApiError::InvalidSwap(Some("Repo has been modified".into())));
403
}
404
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
405
+
let commit_bytes = tracking_store
406
+
.get(¤t_root_cid)
407
+
.await
408
+
.ok()
409
+
.flatten()
410
+
.ok_or_else(|| ApiError::InternalError(Some("Commit block not found".into())))?;
411
+
let commit = Commit::from_cbor(&commit_bytes)
412
+
.map_err(|_| ApiError::InternalError(Some("Failed to parse commit".into())))?;
413
let original_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
414
let initial_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
415
let WriteAccumulator {
···
428
.await
429
{
430
Ok(acc) => acc,
431
+
Err(response) => return Ok(response),
432
};
433
+
let new_mst_root = mst
434
+
.persist()
435
+
.await
436
+
.map_err(|_| ApiError::InternalError(Some("Failed to persist MST".into())))?;
437
let (new_mst_blocks, old_mst_blocks) = {
438
let mut new_blocks = std::collections::BTreeMap::new();
439
let mut old_blocks = std::collections::BTreeMap::new();
440
for key in &modified_keys {
441
+
mst.blocks_for_path(key, &mut new_blocks)
442
+
.await
443
+
.map_err(|_| {
444
+
ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
445
+
})?;
446
+
original_mst
447
.blocks_for_path(key, &mut old_blocks)
448
.await
449
+
.map_err(|_| {
450
+
ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
451
+
})?;
452
}
453
(new_blocks, old_blocks)
454
};
···
500
{
501
Ok(res) => res,
502
Err(e) if e.contains("ConcurrentModification") => {
503
+
return Err(ApiError::InvalidSwap(Some("Repo has been modified".into())));
504
}
505
Err(e) => {
506
error!("Commit failed: {}", e);
507
+
return Err(ApiError::InternalError(Some(
508
+
"Failed to commit changes".into(),
509
+
)));
510
}
511
};
512
···
555
.await;
556
}
557
558
+
Ok((
559
StatusCode::OK,
560
Json(ApplyWritesOutput {
561
commit: CommitInfo {
···
565
results,
566
}),
567
)
568
+
.into_response())
569
}
+47
-38
crates/tranquil-pds/src/api/repo/record/delete.rs
+47
-38
crates/tranquil-pds/src/api/repo/record/delete.rs
···
1
use crate::api::error::ApiError;
2
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
3
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
4
use crate::delegation::DelegationActionType;
5
use crate::repo::tracking::TrackingBlockStore;
6
use crate::state::AppState;
···
8
use axum::{
9
Json,
10
extract::State,
11
-
http::{HeaderMap, StatusCode},
12
response::{IntoResponse, Response},
13
};
14
use cid::Cid;
···
39
40
pub async fn delete_record(
41
State(state): State<AppState>,
42
-
headers: HeaderMap,
43
-
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
44
Json(input): Json<DeleteRecordInput>,
45
-
) -> Response {
46
-
let auth = match prepare_repo_write(
47
-
&state,
48
-
&headers,
49
-
&input.repo,
50
-
"POST",
51
-
&crate::util::build_full_url(&uri.to_string()),
52
-
)
53
-
.await
54
-
{
55
Ok(res) => res,
56
-
Err(err_res) => return err_res,
57
};
58
59
if let Err(e) = crate::auth::scope_check::check_repo_scope(
60
-
auth.is_oauth,
61
-
auth.scope.as_deref(),
62
crate::oauth::RepoAction::Delete,
63
&input.collection,
64
) {
65
-
return e;
66
}
67
68
-
let did = auth.did;
69
-
let user_id = auth.user_id;
70
-
let current_root_cid = auth.current_root_cid;
71
-
let controller_did = auth.controller_did;
72
73
if let Some(swap_commit) = &input.swap_commit
74
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
75
{
76
-
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
77
}
78
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
79
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
80
Ok(Some(b)) => b,
81
-
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
82
};
83
let commit = match Commit::from_cbor(&commit_bytes) {
84
Ok(c) => c,
85
-
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
86
};
87
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
88
let key = format!("{}/{}", input.collection, input.rkey);
···
90
let expected_cid = Cid::from_str(swap_record_str).ok();
91
let actual_cid = mst.get(&key).await.ok().flatten();
92
if expected_cid != actual_cid {
93
-
return ApiError::InvalidSwap(Some(
94
"Record has been modified or does not exist".into(),
95
))
96
-
.into_response();
97
}
98
}
99
let prev_record_cid = mst.get(&key).await.ok().flatten();
100
if prev_record_cid.is_none() {
101
-
return (StatusCode::OK, Json(DeleteRecordOutput { commit: None })).into_response();
102
}
103
let new_mst = match mst.delete(&key).await {
104
Ok(m) => m,
105
Err(e) => {
106
error!("Failed to delete from MST: {:?}", e);
107
-
return ApiError::InternalError(Some(format!("Failed to delete from MST: {:?}", e)))
108
-
.into_response();
109
}
110
};
111
let new_mst_root = match new_mst.persist().await {
112
Ok(c) => c,
113
Err(e) => {
114
error!("Failed to persist MST: {:?}", e);
115
-
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response();
116
}
117
};
118
let collection_for_audit = input.collection.to_string();
···
129
.await
130
.is_err()
131
{
132
-
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
133
-
.into_response();
134
}
135
if mst
136
.blocks_for_path(&key, &mut old_mst_blocks)
137
.await
138
.is_err()
139
{
140
-
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
141
-
.into_response();
142
}
143
let mut relevant_blocks = new_mst_blocks.clone();
144
relevant_blocks.extend(old_mst_blocks.iter().map(|(k, v)| (*k, v.clone())));
···
177
{
178
Ok(res) => res,
179
Err(e) if e.contains("ConcurrentModification") => {
180
-
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
181
}
182
-
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
183
};
184
185
if let Some(ref controller) = controller_did {
···
210
error!("Failed to remove backlinks for {}: {}", deleted_uri, e);
211
}
212
213
-
(
214
StatusCode::OK,
215
Json(DeleteRecordOutput {
216
commit: Some(CommitInfo {
···
219
}),
220
}),
221
)
222
-
.into_response()
223
}
224
225
use crate::types::Did;
···
1
use crate::api::error::ApiError;
2
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
3
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
4
+
use crate::auth::{Active, Auth};
5
use crate::delegation::DelegationActionType;
6
use crate::repo::tracking::TrackingBlockStore;
7
use crate::state::AppState;
···
9
use axum::{
10
Json,
11
extract::State,
12
+
http::StatusCode,
13
response::{IntoResponse, Response},
14
};
15
use cid::Cid;
···
40
41
pub async fn delete_record(
42
State(state): State<AppState>,
43
+
auth: Auth<Active>,
44
Json(input): Json<DeleteRecordInput>,
45
+
) -> Result<Response, crate::api::error::ApiError> {
46
+
let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await {
47
Ok(res) => res,
48
+
Err(err_res) => return Ok(err_res),
49
};
50
51
if let Err(e) = crate::auth::scope_check::check_repo_scope(
52
+
repo_auth.is_oauth,
53
+
repo_auth.scope.as_deref(),
54
crate::oauth::RepoAction::Delete,
55
&input.collection,
56
) {
57
+
return Ok(e);
58
}
59
60
+
let did = repo_auth.did;
61
+
let user_id = repo_auth.user_id;
62
+
let current_root_cid = repo_auth.current_root_cid;
63
+
let controller_did = repo_auth.controller_did;
64
65
if let Some(swap_commit) = &input.swap_commit
66
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
67
{
68
+
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
69
}
70
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
71
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
72
Ok(Some(b)) => b,
73
+
_ => {
74
+
return Ok(
75
+
ApiError::InternalError(Some("Commit block not found".into())).into_response(),
76
+
);
77
+
}
78
};
79
let commit = match Commit::from_cbor(&commit_bytes) {
80
Ok(c) => c,
81
+
_ => {
82
+
return Ok(
83
+
ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
84
+
);
85
+
}
86
};
87
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
88
let key = format!("{}/{}", input.collection, input.rkey);
···
90
let expected_cid = Cid::from_str(swap_record_str).ok();
91
let actual_cid = mst.get(&key).await.ok().flatten();
92
if expected_cid != actual_cid {
93
+
return Ok(ApiError::InvalidSwap(Some(
94
"Record has been modified or does not exist".into(),
95
))
96
+
.into_response());
97
}
98
}
99
let prev_record_cid = mst.get(&key).await.ok().flatten();
100
if prev_record_cid.is_none() {
101
+
return Ok((StatusCode::OK, Json(DeleteRecordOutput { commit: None })).into_response());
102
}
103
let new_mst = match mst.delete(&key).await {
104
Ok(m) => m,
105
Err(e) => {
106
error!("Failed to delete from MST: {:?}", e);
107
+
return Ok(ApiError::InternalError(Some(format!(
108
+
"Failed to delete from MST: {:?}",
109
+
e
110
+
)))
111
+
.into_response());
112
}
113
};
114
let new_mst_root = match new_mst.persist().await {
115
Ok(c) => c,
116
Err(e) => {
117
error!("Failed to persist MST: {:?}", e);
118
+
return Ok(
119
+
ApiError::InternalError(Some("Failed to persist MST".into())).into_response(),
120
+
);
121
}
122
};
123
let collection_for_audit = input.collection.to_string();
···
134
.await
135
.is_err()
136
{
137
+
return Ok(
138
+
ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
139
+
.into_response(),
140
+
);
141
}
142
if mst
143
.blocks_for_path(&key, &mut old_mst_blocks)
144
.await
145
.is_err()
146
{
147
+
return Ok(
148
+
ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
149
+
.into_response(),
150
+
);
151
}
152
let mut relevant_blocks = new_mst_blocks.clone();
153
relevant_blocks.extend(old_mst_blocks.iter().map(|(k, v)| (*k, v.clone())));
···
186
{
187
Ok(res) => res,
188
Err(e) if e.contains("ConcurrentModification") => {
189
+
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
190
}
191
+
Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()),
192
};
193
194
if let Some(ref controller) = controller_did {
···
219
error!("Failed to remove backlinks for {}: {}", deleted_uri, e);
220
}
221
222
+
Ok((
223
StatusCode::OK,
224
Json(DeleteRecordOutput {
225
commit: Some(CommitInfo {
···
228
}),
229
}),
230
)
231
+
.into_response())
232
}
233
234
use crate::types::Did;
+114
-121
crates/tranquil-pds/src/api/repo/record/write.rs
+114
-121
crates/tranquil-pds/src/api/repo/record/write.rs
···
3
use crate::api::repo::record::utils::{
4
CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids,
5
};
6
use crate::delegation::DelegationActionType;
7
use crate::repo::tracking::TrackingBlockStore;
8
use crate::state::AppState;
···
10
use axum::{
11
Json,
12
extract::State,
13
-
http::{HeaderMap, StatusCode},
14
response::{IntoResponse, Response},
15
};
16
use cid::Cid;
···
33
34
pub async fn prepare_repo_write(
35
state: &AppState,
36
-
headers: &HeaderMap,
37
repo: &AtIdentifier,
38
-
http_method: &str,
39
-
http_uri: &str,
40
) -> Result<RepoWriteAuth, Response> {
41
-
let extracted = crate::auth::extract_auth_token_from_header(
42
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
43
-
)
44
-
.ok_or_else(|| ApiError::AuthenticationRequired.into_response())?;
45
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
46
-
let auth_user = crate::auth::validate_token_with_dpop(
47
-
state.user_repo.as_ref(),
48
-
state.oauth_repo.as_ref(),
49
-
&extracted.token,
50
-
extracted.is_dpop,
51
-
dpop_proof,
52
-
http_method,
53
-
http_uri,
54
-
false,
55
-
false,
56
-
)
57
-
.await
58
-
.map_err(|e| {
59
-
tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write");
60
-
ApiError::from(e).into_response()
61
-
})?;
62
if repo.as_str() != auth_user.did.as_str() {
63
return Err(
64
ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(),
···
112
did: auth_user.did.clone(),
113
user_id,
114
current_root_cid,
115
-
is_oauth: auth_user.is_oauth,
116
-
scope: auth_user.scope,
117
controller_did: auth_user.controller_did.clone(),
118
})
119
}
···
146
}
147
pub async fn create_record(
148
State(state): State<AppState>,
149
-
headers: HeaderMap,
150
-
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
151
Json(input): Json<CreateRecordInput>,
152
-
) -> Response {
153
-
let auth = match prepare_repo_write(
154
-
&state,
155
-
&headers,
156
-
&input.repo,
157
-
"POST",
158
-
&crate::util::build_full_url(&uri.to_string()),
159
-
)
160
-
.await
161
-
{
162
Ok(res) => res,
163
-
Err(err_res) => return err_res,
164
};
165
166
if let Err(e) = crate::auth::scope_check::check_repo_scope(
167
-
auth.is_oauth,
168
-
auth.scope.as_deref(),
169
crate::oauth::RepoAction::Create,
170
&input.collection,
171
) {
172
-
return e;
173
}
174
175
-
let did = auth.did;
176
-
let user_id = auth.user_id;
177
-
let current_root_cid = auth.current_root_cid;
178
-
let controller_did = auth.controller_did;
179
180
if let Some(swap_commit) = &input.swap_commit
181
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
182
{
183
-
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
184
}
185
186
let validation_status = if input.validate == Some(false) {
···
194
require_lexicon,
195
) {
196
Ok(status) => Some(status),
197
-
Err(err_response) => return *err_response,
198
}
199
};
200
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
···
202
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
203
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
204
Ok(Some(b)) => b,
205
-
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
206
};
207
let commit = match Commit::from_cbor(&commit_bytes) {
208
Ok(c) => c,
209
-
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
210
};
211
let mut mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
212
let initial_mst_root = commit.data;
···
228
Ok(c) => c,
229
Err(e) => {
230
error!("Failed to check backlink conflicts: {}", e);
231
-
return ApiError::InternalError(None).into_response();
232
}
233
};
234
···
281
let record_ipld = crate::util::json_to_ipld(&input.record);
282
let mut record_bytes = Vec::new();
283
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
284
-
return ApiError::InvalidRecord("Failed to serialize record".into()).into_response();
285
}
286
let record_cid = match tracking_store.put(&record_bytes).await {
287
Ok(c) => c,
288
_ => {
289
-
return ApiError::InternalError(Some("Failed to save record block".into()))
290
-
.into_response();
291
}
292
};
293
let key = format!("{}/{}", input.collection, rkey);
···
302
303
let new_mst = match mst.add(&key, record_cid).await {
304
Ok(m) => m,
305
-
_ => return ApiError::InternalError(Some("Failed to add to MST".into())).into_response(),
306
};
307
let new_mst_root = match new_mst.persist().await {
308
Ok(c) => c,
309
-
_ => return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(),
310
};
311
312
ops.push(RecordOp::Create {
···
321
.await
322
.is_err()
323
{
324
-
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
325
-
.into_response();
326
}
327
328
let mut relevant_blocks = new_mst_blocks.clone();
···
364
{
365
Ok(res) => res,
366
Err(e) if e.contains("ConcurrentModification") => {
367
-
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
368
}
369
-
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
370
};
371
372
for conflict_uri in conflict_uris_to_cleanup {
···
406
error!("Failed to add backlinks for {}: {}", created_uri, e);
407
}
408
409
-
(
410
StatusCode::OK,
411
Json(CreateRecordOutput {
412
uri: created_uri,
···
418
validation_status: validation_status.map(|s| s.to_string()),
419
}),
420
)
421
-
.into_response()
422
}
423
#[derive(Deserialize)]
424
#[allow(dead_code)]
···
445
}
446
pub async fn put_record(
447
State(state): State<AppState>,
448
-
headers: HeaderMap,
449
-
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
450
Json(input): Json<PutRecordInput>,
451
-
) -> Response {
452
-
let auth = match prepare_repo_write(
453
-
&state,
454
-
&headers,
455
-
&input.repo,
456
-
"POST",
457
-
&crate::util::build_full_url(&uri.to_string()),
458
-
)
459
-
.await
460
-
{
461
Ok(res) => res,
462
-
Err(err_res) => return err_res,
463
};
464
465
if let Err(e) = crate::auth::scope_check::check_repo_scope(
466
-
auth.is_oauth,
467
-
auth.scope.as_deref(),
468
crate::oauth::RepoAction::Create,
469
&input.collection,
470
) {
471
-
return e;
472
}
473
if let Err(e) = crate::auth::scope_check::check_repo_scope(
474
-
auth.is_oauth,
475
-
auth.scope.as_deref(),
476
crate::oauth::RepoAction::Update,
477
&input.collection,
478
) {
479
-
return e;
480
}
481
482
-
let did = auth.did;
483
-
let user_id = auth.user_id;
484
-
let current_root_cid = auth.current_root_cid;
485
-
let controller_did = auth.controller_did;
486
487
if let Some(swap_commit) = &input.swap_commit
488
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
489
{
490
-
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
491
}
492
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
493
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
494
Ok(Some(b)) => b,
495
-
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
496
};
497
let commit = match Commit::from_cbor(&commit_bytes) {
498
Ok(c) => c,
499
-
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
500
};
501
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
502
let key = format!("{}/{}", input.collection, input.rkey);
···
511
require_lexicon,
512
) {
513
Ok(status) => Some(status),
514
-
Err(err_response) => return *err_response,
515
}
516
};
517
if let Some(swap_record_str) = &input.swap_record {
518
let expected_cid = Cid::from_str(swap_record_str).ok();
519
let actual_cid = mst.get(&key).await.ok().flatten();
520
if expected_cid != actual_cid {
521
-
return ApiError::InvalidSwap(Some(
522
"Record has been modified or does not exist".into(),
523
))
524
-
.into_response();
525
}
526
}
527
let existing_cid = mst.get(&key).await.ok().flatten();
528
let record_ipld = crate::util::json_to_ipld(&input.record);
529
let mut record_bytes = Vec::new();
530
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
531
-
return ApiError::InvalidRecord("Failed to serialize record".into()).into_response();
532
}
533
let record_cid = match tracking_store.put(&record_bytes).await {
534
Ok(c) => c,
535
_ => {
536
-
return ApiError::InternalError(Some("Failed to save record block".into()))
537
-
.into_response();
538
}
539
};
540
if existing_cid == Some(record_cid) {
541
-
return (
542
StatusCode::OK,
543
Json(PutRecordOutput {
544
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
···
547
validation_status: validation_status.map(|s| s.to_string()),
548
}),
549
)
550
-
.into_response();
551
}
552
-
let new_mst = if existing_cid.is_some() {
553
-
match mst.update(&key, record_cid).await {
554
-
Ok(m) => m,
555
-
Err(_) => {
556
-
return ApiError::InternalError(Some("Failed to update MST".into()))
557
-
.into_response();
558
}
559
-
}
560
-
} else {
561
-
match mst.add(&key, record_cid).await {
562
-
Ok(m) => m,
563
-
Err(_) => {
564
-
return ApiError::InternalError(Some("Failed to add to MST".into()))
565
-
.into_response();
566
}
567
-
}
568
-
};
569
let new_mst_root = match new_mst.persist().await {
570
Ok(c) => c,
571
Err(_) => {
572
-
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response();
573
}
574
};
575
let op = if existing_cid.is_some() {
···
593
.await
594
.is_err()
595
{
596
-
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
597
-
.into_response();
598
}
599
if mst
600
.blocks_for_path(&key, &mut old_mst_blocks)
601
.await
602
.is_err()
603
{
604
-
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
605
-
.into_response();
606
}
607
let mut relevant_blocks = new_mst_blocks.clone();
608
relevant_blocks.extend(old_mst_blocks.iter().map(|(k, v)| (*k, v.clone())));
···
644
{
645
Ok(res) => res,
646
Err(e) if e.contains("ConcurrentModification") => {
647
-
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
648
}
649
-
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
650
};
651
652
if let Some(ref controller) = controller_did {
···
668
.await;
669
}
670
671
-
(
672
StatusCode::OK,
673
Json(PutRecordOutput {
674
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
···
680
validation_status: validation_status.map(|s| s.to_string()),
681
}),
682
)
683
-
.into_response()
684
}
···
3
use crate::api::repo::record::utils::{
4
CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids,
5
};
6
+
use crate::auth::{Active, Auth};
7
use crate::delegation::DelegationActionType;
8
use crate::repo::tracking::TrackingBlockStore;
9
use crate::state::AppState;
···
11
use axum::{
12
Json,
13
extract::State,
14
+
http::StatusCode,
15
response::{IntoResponse, Response},
16
};
17
use cid::Cid;
···
34
35
pub async fn prepare_repo_write(
36
state: &AppState,
37
+
auth_user: &crate::auth::AuthenticatedUser,
38
repo: &AtIdentifier,
39
) -> Result<RepoWriteAuth, Response> {
40
if repo.as_str() != auth_user.did.as_str() {
41
return Err(
42
ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(),
···
90
did: auth_user.did.clone(),
91
user_id,
92
current_root_cid,
93
+
is_oauth: auth_user.is_oauth(),
94
+
scope: auth_user.scope.clone(),
95
controller_did: auth_user.controller_did.clone(),
96
})
97
}
···
124
}
125
pub async fn create_record(
126
State(state): State<AppState>,
127
+
auth: Auth<Active>,
128
Json(input): Json<CreateRecordInput>,
129
+
) -> Result<Response, crate::api::error::ApiError> {
130
+
let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await {
131
Ok(res) => res,
132
+
Err(err_res) => return Ok(err_res),
133
};
134
135
if let Err(e) = crate::auth::scope_check::check_repo_scope(
136
+
repo_auth.is_oauth,
137
+
repo_auth.scope.as_deref(),
138
crate::oauth::RepoAction::Create,
139
&input.collection,
140
) {
141
+
return Ok(e);
142
}
143
144
+
let did = repo_auth.did;
145
+
let user_id = repo_auth.user_id;
146
+
let current_root_cid = repo_auth.current_root_cid;
147
+
let controller_did = repo_auth.controller_did;
148
149
if let Some(swap_commit) = &input.swap_commit
150
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
151
{
152
+
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
153
}
154
155
let validation_status = if input.validate == Some(false) {
···
163
require_lexicon,
164
) {
165
Ok(status) => Some(status),
166
+
Err(err_response) => return Ok(*err_response),
167
}
168
};
169
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
···
171
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
172
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
173
Ok(Some(b)) => b,
174
+
_ => {
175
+
return Ok(
176
+
ApiError::InternalError(Some("Commit block not found".into())).into_response(),
177
+
);
178
+
}
179
};
180
let commit = match Commit::from_cbor(&commit_bytes) {
181
Ok(c) => c,
182
+
_ => {
183
+
return Ok(
184
+
ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
185
+
);
186
+
}
187
};
188
let mut mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
189
let initial_mst_root = commit.data;
···
205
Ok(c) => c,
206
Err(e) => {
207
error!("Failed to check backlink conflicts: {}", e);
208
+
return Ok(ApiError::InternalError(None).into_response());
209
}
210
};
211
···
258
let record_ipld = crate::util::json_to_ipld(&input.record);
259
let mut record_bytes = Vec::new();
260
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
261
+
return Ok(ApiError::InvalidRecord("Failed to serialize record".into()).into_response());
262
}
263
let record_cid = match tracking_store.put(&record_bytes).await {
264
Ok(c) => c,
265
_ => {
266
+
return Ok(
267
+
ApiError::InternalError(Some("Failed to save record block".into())).into_response(),
268
+
);
269
}
270
};
271
let key = format!("{}/{}", input.collection, rkey);
···
280
281
let new_mst = match mst.add(&key, record_cid).await {
282
Ok(m) => m,
283
+
_ => {
284
+
return Ok(ApiError::InternalError(Some("Failed to add to MST".into())).into_response());
285
+
}
286
};
287
let new_mst_root = match new_mst.persist().await {
288
Ok(c) => c,
289
+
_ => {
290
+
return Ok(
291
+
ApiError::InternalError(Some("Failed to persist MST".into())).into_response(),
292
+
);
293
+
}
294
};
295
296
ops.push(RecordOp::Create {
···
305
.await
306
.is_err()
307
{
308
+
return Ok(
309
+
ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
310
+
.into_response(),
311
+
);
312
}
313
314
let mut relevant_blocks = new_mst_blocks.clone();
···
350
{
351
Ok(res) => res,
352
Err(e) if e.contains("ConcurrentModification") => {
353
+
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
354
}
355
+
Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()),
356
};
357
358
for conflict_uri in conflict_uris_to_cleanup {
···
392
error!("Failed to add backlinks for {}: {}", created_uri, e);
393
}
394
395
+
Ok((
396
StatusCode::OK,
397
Json(CreateRecordOutput {
398
uri: created_uri,
···
404
validation_status: validation_status.map(|s| s.to_string()),
405
}),
406
)
407
+
.into_response())
408
}
409
#[derive(Deserialize)]
410
#[allow(dead_code)]
···
431
}
432
pub async fn put_record(
433
State(state): State<AppState>,
434
+
auth: Auth<Active>,
435
Json(input): Json<PutRecordInput>,
436
+
) -> Result<Response, crate::api::error::ApiError> {
437
+
let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await {
438
Ok(res) => res,
439
+
Err(err_res) => return Ok(err_res),
440
};
441
442
if let Err(e) = crate::auth::scope_check::check_repo_scope(
443
+
repo_auth.is_oauth,
444
+
repo_auth.scope.as_deref(),
445
crate::oauth::RepoAction::Create,
446
&input.collection,
447
) {
448
+
return Ok(e);
449
}
450
if let Err(e) = crate::auth::scope_check::check_repo_scope(
451
+
repo_auth.is_oauth,
452
+
repo_auth.scope.as_deref(),
453
crate::oauth::RepoAction::Update,
454
&input.collection,
455
) {
456
+
return Ok(e);
457
}
458
459
+
let did = repo_auth.did;
460
+
let user_id = repo_auth.user_id;
461
+
let current_root_cid = repo_auth.current_root_cid;
462
+
let controller_did = repo_auth.controller_did;
463
464
if let Some(swap_commit) = &input.swap_commit
465
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
466
{
467
+
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
468
}
469
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
470
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
471
Ok(Some(b)) => b,
472
+
_ => {
473
+
return Ok(
474
+
ApiError::InternalError(Some("Commit block not found".into())).into_response(),
475
+
);
476
+
}
477
};
478
let commit = match Commit::from_cbor(&commit_bytes) {
479
Ok(c) => c,
480
+
_ => {
481
+
return Ok(
482
+
ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
483
+
);
484
+
}
485
};
486
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
487
let key = format!("{}/{}", input.collection, input.rkey);
···
496
require_lexicon,
497
) {
498
Ok(status) => Some(status),
499
+
Err(err_response) => return Ok(*err_response),
500
}
501
};
502
if let Some(swap_record_str) = &input.swap_record {
503
let expected_cid = Cid::from_str(swap_record_str).ok();
504
let actual_cid = mst.get(&key).await.ok().flatten();
505
if expected_cid != actual_cid {
506
+
return Ok(ApiError::InvalidSwap(Some(
507
"Record has been modified or does not exist".into(),
508
))
509
+
.into_response());
510
}
511
}
512
let existing_cid = mst.get(&key).await.ok().flatten();
513
let record_ipld = crate::util::json_to_ipld(&input.record);
514
let mut record_bytes = Vec::new();
515
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
516
+
return Ok(ApiError::InvalidRecord("Failed to serialize record".into()).into_response());
517
}
518
let record_cid = match tracking_store.put(&record_bytes).await {
519
Ok(c) => c,
520
_ => {
521
+
return Ok(
522
+
ApiError::InternalError(Some("Failed to save record block".into())).into_response(),
523
+
);
524
}
525
};
526
if existing_cid == Some(record_cid) {
527
+
return Ok((
528
StatusCode::OK,
529
Json(PutRecordOutput {
530
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
···
533
validation_status: validation_status.map(|s| s.to_string()),
534
}),
535
)
536
+
.into_response());
537
}
538
+
let new_mst =
539
+
if existing_cid.is_some() {
540
+
match mst.update(&key, record_cid).await {
541
+
Ok(m) => m,
542
+
Err(_) => {
543
+
return Ok(ApiError::InternalError(Some("Failed to update MST".into()))
544
+
.into_response());
545
+
}
546
}
547
+
} else {
548
+
match mst.add(&key, record_cid).await {
549
+
Ok(m) => m,
550
+
Err(_) => {
551
+
return Ok(ApiError::InternalError(Some("Failed to add to MST".into()))
552
+
.into_response());
553
+
}
554
}
555
+
};
556
let new_mst_root = match new_mst.persist().await {
557
Ok(c) => c,
558
Err(_) => {
559
+
return Ok(
560
+
ApiError::InternalError(Some("Failed to persist MST".into())).into_response(),
561
+
);
562
}
563
};
564
let op = if existing_cid.is_some() {
···
582
.await
583
.is_err()
584
{
585
+
return Ok(
586
+
ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
587
+
.into_response(),
588
+
);
589
}
590
if mst
591
.blocks_for_path(&key, &mut old_mst_blocks)
592
.await
593
.is_err()
594
{
595
+
return Ok(
596
+
ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
597
+
.into_response(),
598
+
);
599
}
600
let mut relevant_blocks = new_mst_blocks.clone();
601
relevant_blocks.extend(old_mst_blocks.iter().map(|(k, v)| (*k, v.clone())));
···
637
{
638
Ok(res) => res,
639
Err(e) if e.contains("ConcurrentModification") => {
640
+
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
641
}
642
+
Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()),
643
};
644
645
if let Some(ref controller) = controller_did {
···
661
.await;
662
}
663
664
+
Ok((
665
StatusCode::OK,
666
Json(PutRecordOutput {
667
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
···
673
validation_status: validation_status.map(|s| s.to_string()),
674
}),
675
)
676
+
.into_response())
677
}
+54
-166
crates/tranquil-pds/src/api/server/account_status.rs
+54
-166
crates/tranquil-pds/src/api/server/account_status.rs
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
use crate::cache::Cache;
4
use crate::plc::PlcClient;
5
use crate::state::AppState;
···
40
41
pub async fn check_account_status(
42
State(state): State<AppState>,
43
-
headers: axum::http::HeaderMap,
44
-
) -> Response {
45
-
let extracted = match crate::auth::extract_auth_token_from_header(
46
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
47
-
) {
48
-
Some(t) => t,
49
-
None => return ApiError::AuthenticationRequired.into_response(),
50
-
};
51
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
52
-
let http_uri = format!(
53
-
"https://{}/xrpc/com.atproto.server.checkAccountStatus",
54
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
55
-
);
56
-
let did = match crate::auth::validate_token_with_dpop(
57
-
state.user_repo.as_ref(),
58
-
state.oauth_repo.as_ref(),
59
-
&extracted.token,
60
-
extracted.is_dpop,
61
-
dpop_proof,
62
-
"GET",
63
-
&http_uri,
64
-
true,
65
-
false,
66
-
)
67
-
.await
68
-
{
69
-
Ok(user) => user.did,
70
-
Err(e) => return ApiError::from(e).into_response(),
71
-
};
72
-
let user_id = match state.user_repo.get_id_by_did(&did).await {
73
-
Ok(Some(id)) => id,
74
-
_ => {
75
-
return ApiError::InternalError(None).into_response();
76
-
}
77
-
};
78
let is_active = state
79
.user_repo
80
-
.is_account_active_by_did(&did)
81
.await
82
.ok()
83
.flatten()
···
121
.await
122
.unwrap_or(0);
123
let valid_did =
124
-
is_valid_did_for_service(state.user_repo.as_ref(), state.cache.clone(), &did).await;
125
-
(
126
StatusCode::OK,
127
Json(CheckAccountStatusOutput {
128
activated: is_active,
···
136
imported_blobs,
137
}),
138
)
139
-
.into_response()
140
}
141
142
async fn is_valid_did_for_service(
···
331
332
pub async fn activate_account(
333
State(state): State<AppState>,
334
-
headers: axum::http::HeaderMap,
335
-
) -> Response {
336
info!("[MIGRATION] activateAccount called");
337
-
let extracted = match crate::auth::extract_auth_token_from_header(
338
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
339
-
) {
340
-
Some(t) => t,
341
-
None => {
342
-
info!("[MIGRATION] activateAccount: No auth token");
343
-
return ApiError::AuthenticationRequired.into_response();
344
-
}
345
-
};
346
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
347
-
let http_uri = format!(
348
-
"https://{}/xrpc/com.atproto.server.activateAccount",
349
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
350
-
);
351
-
let auth_user = match crate::auth::validate_token_with_dpop(
352
-
state.user_repo.as_ref(),
353
-
state.oauth_repo.as_ref(),
354
-
&extracted.token,
355
-
extracted.is_dpop,
356
-
dpop_proof,
357
-
"POST",
358
-
&http_uri,
359
-
true,
360
-
false,
361
-
)
362
-
.await
363
-
{
364
-
Ok(user) => user,
365
-
Err(e) => {
366
-
info!("[MIGRATION] activateAccount: Auth failed: {:?}", e);
367
-
return ApiError::from(e).into_response();
368
-
}
369
-
};
370
info!(
371
"[MIGRATION] activateAccount: Authenticated user did={}",
372
-
auth_user.did
373
);
374
375
if let Err(e) = crate::auth::scope_check::check_account_scope(
376
-
auth_user.is_oauth,
377
-
auth_user.scope.as_deref(),
378
crate::oauth::scopes::AccountAttr::Repo,
379
crate::oauth::scopes::AccountAction::Manage,
380
) {
381
info!("[MIGRATION] activateAccount: Scope check failed");
382
-
return e;
383
}
384
385
-
let did = auth_user.did;
386
387
info!(
388
"[MIGRATION] activateAccount: Validating DID document for did={}",
···
402
did,
403
did_validation_start.elapsed()
404
);
405
-
return e.into_response();
406
}
407
info!(
408
"[MIGRATION] activateAccount: DID document validation SUCCESS for {} (took {:?})",
···
508
);
509
}
510
info!("[MIGRATION] activateAccount: SUCCESS for did={}", did);
511
-
EmptyResponse::ok().into_response()
512
}
513
Err(e) => {
514
error!(
515
"[MIGRATION] activateAccount: DB error activating account: {:?}",
516
e
517
);
518
-
ApiError::InternalError(None).into_response()
519
}
520
}
521
}
···
528
529
pub async fn deactivate_account(
530
State(state): State<AppState>,
531
-
headers: axum::http::HeaderMap,
532
Json(input): Json<DeactivateAccountInput>,
533
-
) -> Response {
534
-
let extracted = match crate::auth::extract_auth_token_from_header(
535
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
536
-
) {
537
-
Some(t) => t,
538
-
None => return ApiError::AuthenticationRequired.into_response(),
539
-
};
540
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
541
-
let http_uri = format!(
542
-
"https://{}/xrpc/com.atproto.server.deactivateAccount",
543
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
544
-
);
545
-
let auth_user = match crate::auth::validate_token_with_dpop(
546
-
state.user_repo.as_ref(),
547
-
state.oauth_repo.as_ref(),
548
-
&extracted.token,
549
-
extracted.is_dpop,
550
-
dpop_proof,
551
-
"POST",
552
-
&http_uri,
553
-
false,
554
-
false,
555
-
)
556
-
.await
557
-
{
558
-
Ok(user) => user,
559
-
Err(e) => return ApiError::from(e).into_response(),
560
-
};
561
-
562
if let Err(e) = crate::auth::scope_check::check_account_scope(
563
-
auth_user.is_oauth,
564
-
auth_user.scope.as_deref(),
565
crate::oauth::scopes::AccountAttr::Repo,
566
crate::oauth::scopes::AccountAction::Manage,
567
) {
568
-
return e;
569
}
570
571
let delete_after: Option<chrono::DateTime<chrono::Utc>> = input
···
574
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
575
.map(|dt| dt.with_timezone(&chrono::Utc));
576
577
-
let did = auth_user.did;
578
579
let handle = state.user_repo.get_handle_by_did(&did).await.ok().flatten();
580
···
595
{
596
warn!("Failed to sequence account deactivated event: {}", e);
597
}
598
-
EmptyResponse::ok().into_response()
599
}
600
-
Ok(false) => EmptyResponse::ok().into_response(),
601
Err(e) => {
602
error!("DB error deactivating account: {:?}", e);
603
-
ApiError::InternalError(None).into_response()
604
}
605
}
606
}
607
608
pub async fn request_account_delete(
609
State(state): State<AppState>,
610
-
headers: axum::http::HeaderMap,
611
-
) -> Response {
612
-
let extracted = match crate::auth::extract_auth_token_from_header(
613
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
614
-
) {
615
-
Some(t) => t,
616
-
None => return ApiError::AuthenticationRequired.into_response(),
617
-
};
618
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
619
-
let http_uri = format!(
620
-
"https://{}/xrpc/com.atproto.server.requestAccountDelete",
621
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
622
-
);
623
-
let validated = match crate::auth::validate_token_with_dpop(
624
-
state.user_repo.as_ref(),
625
-
state.oauth_repo.as_ref(),
626
-
&extracted.token,
627
-
extracted.is_dpop,
628
-
dpop_proof,
629
-
"POST",
630
-
&http_uri,
631
-
true,
632
-
false,
633
-
)
634
-
.await
635
-
{
636
-
Ok(user) => user,
637
-
Err(e) => return ApiError::from(e).into_response(),
638
-
};
639
-
let did = validated.did.clone();
640
641
-
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &did).await {
642
-
return crate::api::server::reauth::legacy_mfa_required_response(
643
&*state.user_repo,
644
&*state.session_repo,
645
-
&did,
646
)
647
-
.await;
648
}
649
650
-
let user_id = match state.user_repo.get_id_by_did(&did).await {
651
-
Ok(Some(id)) => id,
652
-
_ => {
653
-
return ApiError::InternalError(None).into_response();
654
-
}
655
-
};
656
let confirmation_token = Uuid::new_v4().to_string();
657
let expires_at = Utc::now() + Duration::minutes(15);
658
-
if let Err(e) = state
659
.infra_repo
660
-
.create_deletion_request(&confirmation_token, &did, expires_at)
661
.await
662
-
{
663
-
error!("DB error creating deletion token: {:?}", e);
664
-
return ApiError::InternalError(None).into_response();
665
-
}
666
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
667
if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion(
668
state.user_repo.as_ref(),
···
676
warn!("Failed to enqueue account deletion notification: {:?}", e);
677
}
678
info!("Account deletion requested for user {}", did);
679
-
EmptyResponse::ok().into_response()
680
}
681
682
#[derive(Deserialize)]
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
+
use crate::auth::{Auth, NotTakendown, Permissive};
4
use crate::cache::Cache;
5
use crate::plc::PlcClient;
6
use crate::state::AppState;
···
41
42
pub async fn check_account_status(
43
State(state): State<AppState>,
44
+
auth: Auth<Permissive>,
45
+
) -> Result<Response, ApiError> {
46
+
let did = &auth.did;
47
+
let user_id = state
48
+
.user_repo
49
+
.get_id_by_did(did)
50
+
.await
51
+
.map_err(|_| ApiError::InternalError(None))?
52
+
.ok_or(ApiError::InternalError(None))?;
53
let is_active = state
54
.user_repo
55
+
.is_account_active_by_did(did)
56
.await
57
.ok()
58
.flatten()
···
96
.await
97
.unwrap_or(0);
98
let valid_did =
99
+
is_valid_did_for_service(state.user_repo.as_ref(), state.cache.clone(), did).await;
100
+
Ok((
101
StatusCode::OK,
102
Json(CheckAccountStatusOutput {
103
activated: is_active,
···
111
imported_blobs,
112
}),
113
)
114
+
.into_response())
115
}
116
117
async fn is_valid_did_for_service(
···
306
307
pub async fn activate_account(
308
State(state): State<AppState>,
309
+
auth: Auth<Permissive>,
310
+
) -> Result<Response, ApiError> {
311
info!("[MIGRATION] activateAccount called");
312
info!(
313
"[MIGRATION] activateAccount: Authenticated user did={}",
314
+
auth.did
315
);
316
317
if let Err(e) = crate::auth::scope_check::check_account_scope(
318
+
auth.is_oauth(),
319
+
auth.scope.as_deref(),
320
crate::oauth::scopes::AccountAttr::Repo,
321
crate::oauth::scopes::AccountAction::Manage,
322
) {
323
info!("[MIGRATION] activateAccount: Scope check failed");
324
+
return Ok(e);
325
}
326
327
+
let did = auth.did.clone();
328
329
info!(
330
"[MIGRATION] activateAccount: Validating DID document for did={}",
···
344
did,
345
did_validation_start.elapsed()
346
);
347
+
return Err(e);
348
}
349
info!(
350
"[MIGRATION] activateAccount: DID document validation SUCCESS for {} (took {:?})",
···
450
);
451
}
452
info!("[MIGRATION] activateAccount: SUCCESS for did={}", did);
453
+
Ok(EmptyResponse::ok().into_response())
454
}
455
Err(e) => {
456
error!(
457
"[MIGRATION] activateAccount: DB error activating account: {:?}",
458
e
459
);
460
+
Err(ApiError::InternalError(None))
461
}
462
}
463
}
···
470
471
pub async fn deactivate_account(
472
State(state): State<AppState>,
473
+
auth: Auth<Permissive>,
474
Json(input): Json<DeactivateAccountInput>,
475
+
) -> Result<Response, ApiError> {
476
if let Err(e) = crate::auth::scope_check::check_account_scope(
477
+
auth.is_oauth(),
478
+
auth.scope.as_deref(),
479
crate::oauth::scopes::AccountAttr::Repo,
480
crate::oauth::scopes::AccountAction::Manage,
481
) {
482
+
return Ok(e);
483
}
484
485
let delete_after: Option<chrono::DateTime<chrono::Utc>> = input
···
488
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
489
.map(|dt| dt.with_timezone(&chrono::Utc));
490
491
+
let did = auth.did.clone();
492
493
let handle = state.user_repo.get_handle_by_did(&did).await.ok().flatten();
494
···
509
{
510
warn!("Failed to sequence account deactivated event: {}", e);
511
}
512
+
Ok(EmptyResponse::ok().into_response())
513
}
514
+
Ok(false) => Ok(EmptyResponse::ok().into_response()),
515
Err(e) => {
516
error!("DB error deactivating account: {:?}", e);
517
+
Err(ApiError::InternalError(None))
518
}
519
}
520
}
521
522
pub async fn request_account_delete(
523
State(state): State<AppState>,
524
+
auth: Auth<NotTakendown>,
525
+
) -> Result<Response, ApiError> {
526
+
let did = &auth.did;
527
528
+
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, did).await {
529
+
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
530
&*state.user_repo,
531
&*state.session_repo,
532
+
did,
533
)
534
+
.await);
535
}
536
537
+
let user_id = state
538
+
.user_repo
539
+
.get_id_by_did(did)
540
+
.await
541
+
.ok()
542
+
.flatten()
543
+
.ok_or(ApiError::InternalError(None))?;
544
let confirmation_token = Uuid::new_v4().to_string();
545
let expires_at = Utc::now() + Duration::minutes(15);
546
+
state
547
.infra_repo
548
+
.create_deletion_request(&confirmation_token, did, expires_at)
549
.await
550
+
.map_err(|e| {
551
+
error!("DB error creating deletion token: {:?}", e);
552
+
ApiError::InternalError(None)
553
+
})?;
554
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
555
if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion(
556
state.user_repo.as_ref(),
···
564
warn!("Failed to enqueue account deletion notification: {:?}", e);
565
}
566
info!("Account deletion requested for user {}", did);
567
+
Ok(EmptyResponse::ok().into_response())
568
}
569
570
#[derive(Deserialize)]
+126
-122
crates/tranquil-pds/src/api/server/app_password.rs
+126
-122
crates/tranquil-pds/src/api/server/app_password.rs
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
-
use crate::auth::{BearerAuth, generate_app_password};
4
use crate::delegation::{DelegationActionType, intersect_scopes};
5
use crate::state::{AppState, RateLimitKind};
6
use axum::{
···
33
34
pub async fn list_app_passwords(
35
State(state): State<AppState>,
36
-
BearerAuth(auth_user): BearerAuth,
37
-
) -> Response {
38
-
let user = match state.user_repo.get_by_did(&auth_user.did).await {
39
-
Ok(Some(u)) => u,
40
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
41
-
Err(e) => {
42
error!("DB error getting user: {:?}", e);
43
-
return ApiError::InternalError(None).into_response();
44
-
}
45
-
};
46
47
-
match state.session_repo.list_app_passwords(user.id).await {
48
-
Ok(rows) => {
49
-
let passwords: Vec<AppPassword> = rows
50
-
.iter()
51
-
.map(|row| AppPassword {
52
-
name: row.name.clone(),
53
-
created_at: row.created_at.to_rfc3339(),
54
-
privileged: row.privileged,
55
-
scopes: row.scopes.clone(),
56
-
created_by_controller: row
57
-
.created_by_controller_did
58
-
.as_ref()
59
-
.map(|d| d.to_string()),
60
-
})
61
-
.collect();
62
-
Json(ListAppPasswordsOutput { passwords }).into_response()
63
-
}
64
-
Err(e) => {
65
error!("DB error listing app passwords: {:?}", e);
66
-
ApiError::InternalError(None).into_response()
67
-
}
68
-
}
69
}
70
71
#[derive(Deserialize)]
···
89
pub async fn create_app_password(
90
State(state): State<AppState>,
91
headers: HeaderMap,
92
-
BearerAuth(auth_user): BearerAuth,
93
Json(input): Json<CreateAppPasswordInput>,
94
-
) -> Response {
95
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
96
if !state
97
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
98
.await
99
{
100
warn!(ip = %client_ip, "App password creation rate limit exceeded");
101
-
return ApiError::RateLimitExceeded(None).into_response();
102
}
103
104
-
let user = match state.user_repo.get_by_did(&auth_user.did).await {
105
-
Ok(Some(u)) => u,
106
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
107
-
Err(e) => {
108
error!("DB error getting user: {:?}", e);
109
-
return ApiError::InternalError(None).into_response();
110
-
}
111
-
};
112
113
let name = input.name.trim();
114
if name.is_empty() {
115
-
return ApiError::InvalidRequest("name is required".into()).into_response();
116
}
117
118
-
match state
119
.session_repo
120
.get_app_password_by_name(user.id, name)
121
.await
122
-
{
123
-
Ok(Some(_)) => return ApiError::DuplicateAppPassword.into_response(),
124
-
Err(e) => {
125
error!("DB error checking app password: {:?}", e);
126
-
return ApiError::InternalError(None).into_response();
127
-
}
128
-
Ok(None) => {}
129
}
130
131
-
let (final_scopes, controller_did) = if let Some(ref controller) = auth_user.controller_did {
132
let grant = state
133
.delegation_repo
134
-
.get_delegation(&auth_user.did, controller)
135
.await
136
.ok()
137
.flatten();
···
141
let intersected = intersect_scopes(requested, &granted_scopes);
142
143
if intersected.is_empty() && !granted_scopes.is_empty() {
144
-
return ApiError::InsufficientScope(None).into_response();
145
}
146
147
let scope_result = if intersected.is_empty() {
···
157
let password = generate_app_password();
158
159
let password_clone = password.clone();
160
-
let password_hash = match tokio::task::spawn_blocking(move || {
161
-
bcrypt::hash(&password_clone, bcrypt::DEFAULT_COST)
162
-
})
163
-
.await
164
-
{
165
-
Ok(Ok(h)) => h,
166
-
Ok(Err(e)) => {
167
-
error!("Failed to hash password: {:?}", e);
168
-
return ApiError::InternalError(None).into_response();
169
-
}
170
-
Err(e) => {
171
-
error!("Failed to spawn blocking task: {:?}", e);
172
-
return ApiError::InternalError(None).into_response();
173
-
}
174
-
};
175
176
let privileged = input.privileged.unwrap_or(false);
177
let created_at = chrono::Utc::now();
···
185
created_by_controller_did: controller_did.clone(),
186
};
187
188
-
match state.session_repo.create_app_password(&create_data).await {
189
-
Ok(_) => {
190
-
if let Some(ref controller) = controller_did {
191
-
let _ = state
192
-
.delegation_repo
193
-
.log_delegation_action(
194
-
&auth_user.did,
195
-
controller,
196
-
Some(controller),
197
-
DelegationActionType::AccountAction,
198
-
Some(json!({
199
-
"action": "create_app_password",
200
-
"name": name,
201
-
"scopes": final_scopes
202
-
})),
203
-
None,
204
-
None,
205
-
)
206
-
.await;
207
-
}
208
-
Json(CreateAppPasswordOutput {
209
-
name: name.to_string(),
210
-
password,
211
-
created_at: created_at.to_rfc3339(),
212
-
privileged,
213
-
scopes: final_scopes,
214
-
})
215
-
.into_response()
216
-
}
217
-
Err(e) => {
218
error!("DB error creating app password: {:?}", e);
219
-
ApiError::InternalError(None).into_response()
220
-
}
221
}
222
}
223
224
#[derive(Deserialize)]
···
228
229
pub async fn revoke_app_password(
230
State(state): State<AppState>,
231
-
BearerAuth(auth_user): BearerAuth,
232
Json(input): Json<RevokeAppPasswordInput>,
233
-
) -> Response {
234
-
let user = match state.user_repo.get_by_did(&auth_user.did).await {
235
-
Ok(Some(u)) => u,
236
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
237
-
Err(e) => {
238
error!("DB error getting user: {:?}", e);
239
-
return ApiError::InternalError(None).into_response();
240
-
}
241
-
};
242
243
let name = input.name.trim();
244
if name.is_empty() {
245
-
return ApiError::InvalidRequest("name is required".into()).into_response();
246
}
247
248
let sessions_to_invalidate = state
249
.session_repo
250
-
.get_session_jtis_by_app_password(&auth_user.did, name)
251
.await
252
.unwrap_or_default();
253
254
-
if let Err(e) = state
255
.session_repo
256
-
.delete_sessions_by_app_password(&auth_user.did, name)
257
.await
258
-
{
259
-
error!("DB error revoking sessions for app password: {:?}", e);
260
-
return ApiError::InternalError(None).into_response();
261
-
}
262
263
futures::future::join_all(sessions_to_invalidate.iter().map(|jti| {
264
-
let cache_key = format!("auth:session:{}:{}", &auth_user.did, jti);
265
let cache = state.cache.clone();
266
async move {
267
let _ = cache.delete(&cache_key).await;
···
269
}))
270
.await;
271
272
-
if let Err(e) = state.session_repo.delete_app_password(user.id, name).await {
273
-
error!("DB error revoking app password: {:?}", e);
274
-
return ApiError::InternalError(None).into_response();
275
-
}
276
277
-
EmptyResponse::ok().into_response()
278
}
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
+
use crate::auth::{Auth, NotTakendown, Permissive, generate_app_password};
4
use crate::delegation::{DelegationActionType, intersect_scopes};
5
use crate::state::{AppState, RateLimitKind};
6
use axum::{
···
33
34
pub async fn list_app_passwords(
35
State(state): State<AppState>,
36
+
auth: Auth<Permissive>,
37
+
) -> Result<Response, ApiError> {
38
+
let user = state
39
+
.user_repo
40
+
.get_by_did(&auth.did)
41
+
.await
42
+
.map_err(|e| {
43
error!("DB error getting user: {:?}", e);
44
+
ApiError::InternalError(None)
45
+
})?
46
+
.ok_or(ApiError::AccountNotFound)?;
47
48
+
let rows = state
49
+
.session_repo
50
+
.list_app_passwords(user.id)
51
+
.await
52
+
.map_err(|e| {
53
error!("DB error listing app passwords: {:?}", e);
54
+
ApiError::InternalError(None)
55
+
})?;
56
+
let passwords: Vec<AppPassword> = rows
57
+
.iter()
58
+
.map(|row| AppPassword {
59
+
name: row.name.clone(),
60
+
created_at: row.created_at.to_rfc3339(),
61
+
privileged: row.privileged,
62
+
scopes: row.scopes.clone(),
63
+
created_by_controller: row
64
+
.created_by_controller_did
65
+
.as_ref()
66
+
.map(|d| d.to_string()),
67
+
})
68
+
.collect();
69
+
Ok(Json(ListAppPasswordsOutput { passwords }).into_response())
70
}
71
72
#[derive(Deserialize)]
···
90
pub async fn create_app_password(
91
State(state): State<AppState>,
92
headers: HeaderMap,
93
+
auth: Auth<NotTakendown>,
94
Json(input): Json<CreateAppPasswordInput>,
95
+
) -> Result<Response, ApiError> {
96
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
97
if !state
98
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
99
.await
100
{
101
warn!(ip = %client_ip, "App password creation rate limit exceeded");
102
+
return Err(ApiError::RateLimitExceeded(None));
103
}
104
105
+
let user = state
106
+
.user_repo
107
+
.get_by_did(&auth.did)
108
+
.await
109
+
.map_err(|e| {
110
error!("DB error getting user: {:?}", e);
111
+
ApiError::InternalError(None)
112
+
})?
113
+
.ok_or(ApiError::AccountNotFound)?;
114
115
let name = input.name.trim();
116
if name.is_empty() {
117
+
return Err(ApiError::InvalidRequest("name is required".into()));
118
}
119
120
+
if state
121
.session_repo
122
.get_app_password_by_name(user.id, name)
123
.await
124
+
.map_err(|e| {
125
error!("DB error checking app password: {:?}", e);
126
+
ApiError::InternalError(None)
127
+
})?
128
+
.is_some()
129
+
{
130
+
return Err(ApiError::DuplicateAppPassword);
131
}
132
133
+
let (final_scopes, controller_did) = if let Some(ref controller) = auth.controller_did {
134
let grant = state
135
.delegation_repo
136
+
.get_delegation(&auth.did, controller)
137
.await
138
.ok()
139
.flatten();
···
143
let intersected = intersect_scopes(requested, &granted_scopes);
144
145
if intersected.is_empty() && !granted_scopes.is_empty() {
146
+
return Err(ApiError::InsufficientScope(None));
147
}
148
149
let scope_result = if intersected.is_empty() {
···
159
let password = generate_app_password();
160
161
let password_clone = password.clone();
162
+
let password_hash =
163
+
tokio::task::spawn_blocking(move || bcrypt::hash(&password_clone, bcrypt::DEFAULT_COST))
164
+
.await
165
+
.map_err(|e| {
166
+
error!("Failed to spawn blocking task: {:?}", e);
167
+
ApiError::InternalError(None)
168
+
})?
169
+
.map_err(|e| {
170
+
error!("Failed to hash password: {:?}", e);
171
+
ApiError::InternalError(None)
172
+
})?;
173
174
let privileged = input.privileged.unwrap_or(false);
175
let created_at = chrono::Utc::now();
···
183
created_by_controller_did: controller_did.clone(),
184
};
185
186
+
state
187
+
.session_repo
188
+
.create_app_password(&create_data)
189
+
.await
190
+
.map_err(|e| {
191
error!("DB error creating app password: {:?}", e);
192
+
ApiError::InternalError(None)
193
+
})?;
194
+
195
+
if let Some(ref controller) = controller_did {
196
+
let _ = state
197
+
.delegation_repo
198
+
.log_delegation_action(
199
+
&auth.did,
200
+
controller,
201
+
Some(controller),
202
+
DelegationActionType::AccountAction,
203
+
Some(json!({
204
+
"action": "create_app_password",
205
+
"name": name,
206
+
"scopes": final_scopes
207
+
})),
208
+
None,
209
+
None,
210
+
)
211
+
.await;
212
}
213
+
Ok(Json(CreateAppPasswordOutput {
214
+
name: name.to_string(),
215
+
password,
216
+
created_at: created_at.to_rfc3339(),
217
+
privileged,
218
+
scopes: final_scopes,
219
+
})
220
+
.into_response())
221
}
222
223
#[derive(Deserialize)]
···
227
228
pub async fn revoke_app_password(
229
State(state): State<AppState>,
230
+
auth: Auth<Permissive>,
231
Json(input): Json<RevokeAppPasswordInput>,
232
+
) -> Result<Response, ApiError> {
233
+
let user = state
234
+
.user_repo
235
+
.get_by_did(&auth.did)
236
+
.await
237
+
.map_err(|e| {
238
error!("DB error getting user: {:?}", e);
239
+
ApiError::InternalError(None)
240
+
})?
241
+
.ok_or(ApiError::AccountNotFound)?;
242
243
let name = input.name.trim();
244
if name.is_empty() {
245
+
return Err(ApiError::InvalidRequest("name is required".into()));
246
}
247
248
let sessions_to_invalidate = state
249
.session_repo
250
+
.get_session_jtis_by_app_password(&auth.did, name)
251
.await
252
.unwrap_or_default();
253
254
+
state
255
.session_repo
256
+
.delete_sessions_by_app_password(&auth.did, name)
257
.await
258
+
.map_err(|e| {
259
+
error!("DB error revoking sessions for app password: {:?}", e);
260
+
ApiError::InternalError(None)
261
+
})?;
262
263
futures::future::join_all(sessions_to_invalidate.iter().map(|jti| {
264
+
let cache_key = format!("auth:session:{}:{}", &auth.did, jti);
265
let cache = state.cache.clone();
266
async move {
267
let _ = cache.delete(&cache_key).await;
···
269
}))
270
.await;
271
272
+
state
273
+
.session_repo
274
+
.delete_app_password(user.id, name)
275
+
.await
276
+
.map_err(|e| {
277
+
error!("DB error revoking app password: {:?}", e);
278
+
ApiError::InternalError(None)
279
+
})?;
280
281
+
Ok(EmptyResponse::ok().into_response())
282
}
+92
-89
crates/tranquil-pds/src/api/server/email.rs
+92
-89
crates/tranquil-pds/src/api/server/email.rs
···
1
use crate::api::error::ApiError;
2
use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
3
-
use crate::auth::BearerAuth;
4
use crate::state::{AppState, RateLimitKind};
5
use axum::{
6
Json,
···
45
pub async fn request_email_update(
46
State(state): State<AppState>,
47
headers: axum::http::HeaderMap,
48
-
auth: BearerAuth,
49
input: Option<Json<RequestEmailUpdateInput>>,
50
-
) -> Response {
51
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
52
if !state
53
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
54
.await
55
{
56
warn!(ip = %client_ip, "Email update rate limit exceeded");
57
-
return ApiError::RateLimitExceeded(None).into_response();
58
}
59
60
if let Err(e) = crate::auth::scope_check::check_account_scope(
61
-
auth.0.is_oauth,
62
-
auth.0.scope.as_deref(),
63
crate::oauth::scopes::AccountAttr::Email,
64
crate::oauth::scopes::AccountAction::Manage,
65
) {
66
-
return e;
67
}
68
69
-
let user = match state.user_repo.get_email_info_by_did(&auth.0.did).await {
70
-
Ok(Some(row)) => row,
71
-
Ok(None) => {
72
-
return ApiError::AccountNotFound.into_response();
73
-
}
74
-
Err(e) => {
75
error!("DB error: {:?}", e);
76
-
return ApiError::InternalError(None).into_response();
77
-
}
78
-
};
79
80
let Some(current_email) = user.email else {
81
-
return ApiError::InvalidRequest("account does not have an email address".into())
82
-
.into_response();
83
};
84
85
let token_required = user.email_verified;
86
87
if token_required {
88
let code = crate::auth::verification_token::generate_channel_update_token(
89
-
&auth.0.did,
90
"email_update",
91
¤t_email.to_lowercase(),
92
);
···
103
authorized: false,
104
};
105
if let Ok(json) = serde_json::to_string(&pending) {
106
-
let cache_key = email_update_cache_key(&auth.0.did);
107
if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
108
warn!("Failed to cache pending email update: {:?}", e);
109
}
···
127
}
128
129
info!("Email update requested for user {}", user.id);
130
-
TokenRequiredResponse::response(token_required).into_response()
131
}
132
133
#[derive(Deserialize)]
···
140
pub async fn confirm_email(
141
State(state): State<AppState>,
142
headers: axum::http::HeaderMap,
143
-
auth: BearerAuth,
144
Json(input): Json<ConfirmEmailInput>,
145
-
) -> Response {
146
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
147
if !state
148
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
149
.await
150
{
151
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
152
-
return ApiError::RateLimitExceeded(None).into_response();
153
}
154
155
if let Err(e) = crate::auth::scope_check::check_account_scope(
156
-
auth.0.is_oauth,
157
-
auth.0.scope.as_deref(),
158
crate::oauth::scopes::AccountAttr::Email,
159
crate::oauth::scopes::AccountAction::Manage,
160
) {
161
-
return e;
162
}
163
164
-
let did = &auth.0.did;
165
-
let user = match state.user_repo.get_email_info_by_did(did).await {
166
-
Ok(Some(row)) => row,
167
-
Ok(None) => {
168
-
return ApiError::AccountNotFound.into_response();
169
-
}
170
-
Err(e) => {
171
error!("DB error: {:?}", e);
172
-
return ApiError::InternalError(None).into_response();
173
-
}
174
-
};
175
176
let Some(ref email) = user.email else {
177
-
return ApiError::InvalidEmail.into_response();
178
};
179
let current_email = email.to_lowercase();
180
181
let provided_email = input.email.trim().to_lowercase();
182
if provided_email != current_email {
183
-
return ApiError::InvalidEmail.into_response();
184
}
185
186
if user.email_verified {
187
-
return EmptyResponse::ok().into_response();
188
}
189
190
let confirmation_code =
···
199
match verified {
200
Ok(token_data) => {
201
if token_data.did != did.as_str() {
202
-
return ApiError::InvalidToken(None).into_response();
203
}
204
}
205
Err(crate::auth::verification_token::VerifyError::Expired) => {
206
-
return ApiError::ExpiredToken(None).into_response();
207
}
208
Err(_) => {
209
-
return ApiError::InvalidToken(None).into_response();
210
}
211
}
212
213
-
if let Err(e) = state.user_repo.set_email_verified(user.id, true).await {
214
-
error!("DB error confirming email: {:?}", e);
215
-
return ApiError::InternalError(None).into_response();
216
-
}
217
218
info!("Email confirmed for user {}", user.id);
219
-
EmptyResponse::ok().into_response()
220
}
221
222
#[derive(Deserialize)]
···
230
231
pub async fn update_email(
232
State(state): State<AppState>,
233
-
auth: BearerAuth,
234
Json(input): Json<UpdateEmailInput>,
235
-
) -> Response {
236
-
let auth_user = auth.0;
237
-
238
if let Err(e) = crate::auth::scope_check::check_account_scope(
239
-
auth_user.is_oauth,
240
-
auth_user.scope.as_deref(),
241
crate::oauth::scopes::AccountAttr::Email,
242
crate::oauth::scopes::AccountAction::Manage,
243
) {
244
-
return e;
245
}
246
247
-
let did = &auth_user.did;
248
-
let user = match state.user_repo.get_email_info_by_did(did).await {
249
-
Ok(Some(row)) => row,
250
-
Ok(None) => {
251
-
return ApiError::AccountNotFound.into_response();
252
-
}
253
-
Err(e) => {
254
error!("DB error: {:?}", e);
255
-
return ApiError::InternalError(None).into_response();
256
-
}
257
-
};
258
259
let user_id = user.id;
260
let current_email = user.email.clone();
···
262
let new_email = input.email.trim().to_lowercase();
263
264
if !crate::api::validation::is_valid_email(&new_email) {
265
-
return ApiError::InvalidRequest(
266
"This email address is not supported, please use a different email.".into(),
267
-
)
268
-
.into_response();
269
}
270
271
if let Some(ref current) = current_email
272
&& new_email == current.to_lowercase()
273
{
274
-
return EmptyResponse::ok().into_response();
275
}
276
277
if email_verified {
···
290
291
if !authorized_via_link {
292
let Some(ref t) = input.token else {
293
-
return ApiError::TokenRequired.into_response();
294
};
295
let confirmation_token =
296
crate::auth::verification_token::normalize_token_input(t.trim());
···
309
match verified {
310
Ok(token_data) => {
311
if token_data.did != did.as_str() {
312
-
return ApiError::InvalidToken(None).into_response();
313
}
314
}
315
Err(crate::auth::verification_token::VerifyError::Expired) => {
316
-
return ApiError::ExpiredToken(None).into_response();
317
}
318
Err(_) => {
319
-
return ApiError::InvalidToken(None).into_response();
320
}
321
}
322
}
323
}
324
325
-
if let Err(e) = state.user_repo.update_email(user_id, &new_email).await {
326
-
error!("DB error updating email: {:?}", e);
327
-
return ApiError::InternalError(None).into_response();
328
-
}
329
330
let verification_token =
331
crate::auth::verification_token::generate_signup_token(did, "email", &new_email);
···
358
}
359
360
info!("Email updated for user {}", user_id);
361
-
EmptyResponse::ok().into_response()
362
}
363
364
#[derive(Deserialize)]
···
497
pub async fn check_email_update_status(
498
State(state): State<AppState>,
499
headers: axum::http::HeaderMap,
500
-
auth: BearerAuth,
501
-
) -> Response {
502
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
503
if !state
504
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
505
.await
506
{
507
-
return ApiError::RateLimitExceeded(None).into_response();
508
}
509
510
if let Err(e) = crate::auth::scope_check::check_account_scope(
511
-
auth.0.is_oauth,
512
-
auth.0.scope.as_deref(),
513
crate::oauth::scopes::AccountAttr::Email,
514
crate::oauth::scopes::AccountAction::Read,
515
) {
516
-
return e;
517
}
518
519
-
let cache_key = email_update_cache_key(&auth.0.did);
520
let pending_json = match state.cache.get(&cache_key).await {
521
Some(json) => json,
522
None => {
523
-
return Json(json!({ "pending": false, "authorized": false })).into_response();
524
}
525
};
526
527
let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
528
Ok(p) => p,
529
Err(_) => {
530
-
return Json(json!({ "pending": false, "authorized": false })).into_response();
531
}
532
};
533
534
-
Json(json!({
535
"pending": true,
536
"authorized": pending.authorized,
537
"newEmail": pending.new_email,
538
}))
539
-
.into_response()
540
}
541
542
#[derive(Deserialize)]
···
1
use crate::api::error::ApiError;
2
use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
3
+
use crate::auth::{Auth, NotTakendown};
4
use crate::state::{AppState, RateLimitKind};
5
use axum::{
6
Json,
···
45
pub async fn request_email_update(
46
State(state): State<AppState>,
47
headers: axum::http::HeaderMap,
48
+
auth: Auth<NotTakendown>,
49
input: Option<Json<RequestEmailUpdateInput>>,
50
+
) -> Result<Response, ApiError> {
51
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
52
if !state
53
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
54
.await
55
{
56
warn!(ip = %client_ip, "Email update rate limit exceeded");
57
+
return Err(ApiError::RateLimitExceeded(None));
58
}
59
60
if let Err(e) = crate::auth::scope_check::check_account_scope(
61
+
auth.is_oauth(),
62
+
auth.scope.as_deref(),
63
crate::oauth::scopes::AccountAttr::Email,
64
crate::oauth::scopes::AccountAction::Manage,
65
) {
66
+
return Ok(e);
67
}
68
69
+
let user = state
70
+
.user_repo
71
+
.get_email_info_by_did(&auth.did)
72
+
.await
73
+
.map_err(|e| {
74
error!("DB error: {:?}", e);
75
+
ApiError::InternalError(None)
76
+
})?
77
+
.ok_or(ApiError::AccountNotFound)?;
78
79
let Some(current_email) = user.email else {
80
+
return Err(ApiError::InvalidRequest(
81
+
"account does not have an email address".into(),
82
+
));
83
};
84
85
let token_required = user.email_verified;
86
87
if token_required {
88
let code = crate::auth::verification_token::generate_channel_update_token(
89
+
&auth.did,
90
"email_update",
91
¤t_email.to_lowercase(),
92
);
···
103
authorized: false,
104
};
105
if let Ok(json) = serde_json::to_string(&pending) {
106
+
let cache_key = email_update_cache_key(&auth.did);
107
if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
108
warn!("Failed to cache pending email update: {:?}", e);
109
}
···
127
}
128
129
info!("Email update requested for user {}", user.id);
130
+
Ok(TokenRequiredResponse::response(token_required).into_response())
131
}
132
133
#[derive(Deserialize)]
···
140
pub async fn confirm_email(
141
State(state): State<AppState>,
142
headers: axum::http::HeaderMap,
143
+
auth: Auth<NotTakendown>,
144
Json(input): Json<ConfirmEmailInput>,
145
+
) -> Result<Response, ApiError> {
146
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
147
if !state
148
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
149
.await
150
{
151
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
152
+
return Err(ApiError::RateLimitExceeded(None));
153
}
154
155
if let Err(e) = crate::auth::scope_check::check_account_scope(
156
+
auth.is_oauth(),
157
+
auth.scope.as_deref(),
158
crate::oauth::scopes::AccountAttr::Email,
159
crate::oauth::scopes::AccountAction::Manage,
160
) {
161
+
return Ok(e);
162
}
163
164
+
let did = &auth.did;
165
+
let user = state
166
+
.user_repo
167
+
.get_email_info_by_did(did)
168
+
.await
169
+
.map_err(|e| {
170
error!("DB error: {:?}", e);
171
+
ApiError::InternalError(None)
172
+
})?
173
+
.ok_or(ApiError::AccountNotFound)?;
174
175
let Some(ref email) = user.email else {
176
+
return Err(ApiError::InvalidEmail);
177
};
178
let current_email = email.to_lowercase();
179
180
let provided_email = input.email.trim().to_lowercase();
181
if provided_email != current_email {
182
+
return Err(ApiError::InvalidEmail);
183
}
184
185
if user.email_verified {
186
+
return Ok(EmptyResponse::ok().into_response());
187
}
188
189
let confirmation_code =
···
198
match verified {
199
Ok(token_data) => {
200
if token_data.did != did.as_str() {
201
+
return Err(ApiError::InvalidToken(None));
202
}
203
}
204
Err(crate::auth::verification_token::VerifyError::Expired) => {
205
+
return Err(ApiError::ExpiredToken(None));
206
}
207
Err(_) => {
208
+
return Err(ApiError::InvalidToken(None));
209
}
210
}
211
212
+
state
213
+
.user_repo
214
+
.set_email_verified(user.id, true)
215
+
.await
216
+
.map_err(|e| {
217
+
error!("DB error confirming email: {:?}", e);
218
+
ApiError::InternalError(None)
219
+
})?;
220
221
info!("Email confirmed for user {}", user.id);
222
+
Ok(EmptyResponse::ok().into_response())
223
}
224
225
#[derive(Deserialize)]
···
233
234
pub async fn update_email(
235
State(state): State<AppState>,
236
+
auth: Auth<NotTakendown>,
237
Json(input): Json<UpdateEmailInput>,
238
+
) -> Result<Response, ApiError> {
239
if let Err(e) = crate::auth::scope_check::check_account_scope(
240
+
auth.is_oauth(),
241
+
auth.scope.as_deref(),
242
crate::oauth::scopes::AccountAttr::Email,
243
crate::oauth::scopes::AccountAction::Manage,
244
) {
245
+
return Ok(e);
246
}
247
248
+
let did = &auth.did;
249
+
let user = state
250
+
.user_repo
251
+
.get_email_info_by_did(did)
252
+
.await
253
+
.map_err(|e| {
254
error!("DB error: {:?}", e);
255
+
ApiError::InternalError(None)
256
+
})?
257
+
.ok_or(ApiError::AccountNotFound)?;
258
259
let user_id = user.id;
260
let current_email = user.email.clone();
···
262
let new_email = input.email.trim().to_lowercase();
263
264
if !crate::api::validation::is_valid_email(&new_email) {
265
+
return Err(ApiError::InvalidRequest(
266
"This email address is not supported, please use a different email.".into(),
267
+
));
268
}
269
270
if let Some(ref current) = current_email
271
&& new_email == current.to_lowercase()
272
{
273
+
return Ok(EmptyResponse::ok().into_response());
274
}
275
276
if email_verified {
···
289
290
if !authorized_via_link {
291
let Some(ref t) = input.token else {
292
+
return Err(ApiError::TokenRequired);
293
};
294
let confirmation_token =
295
crate::auth::verification_token::normalize_token_input(t.trim());
···
308
match verified {
309
Ok(token_data) => {
310
if token_data.did != did.as_str() {
311
+
return Err(ApiError::InvalidToken(None));
312
}
313
}
314
Err(crate::auth::verification_token::VerifyError::Expired) => {
315
+
return Err(ApiError::ExpiredToken(None));
316
}
317
Err(_) => {
318
+
return Err(ApiError::InvalidToken(None));
319
}
320
}
321
}
322
}
323
324
+
state
325
+
.user_repo
326
+
.update_email(user_id, &new_email)
327
+
.await
328
+
.map_err(|e| {
329
+
error!("DB error updating email: {:?}", e);
330
+
ApiError::InternalError(None)
331
+
})?;
332
333
let verification_token =
334
crate::auth::verification_token::generate_signup_token(did, "email", &new_email);
···
361
}
362
363
info!("Email updated for user {}", user_id);
364
+
Ok(EmptyResponse::ok().into_response())
365
}
366
367
#[derive(Deserialize)]
···
500
pub async fn check_email_update_status(
501
State(state): State<AppState>,
502
headers: axum::http::HeaderMap,
503
+
auth: Auth<NotTakendown>,
504
+
) -> Result<Response, ApiError> {
505
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
506
if !state
507
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
508
.await
509
{
510
+
return Err(ApiError::RateLimitExceeded(None));
511
}
512
513
if let Err(e) = crate::auth::scope_check::check_account_scope(
514
+
auth.is_oauth(),
515
+
auth.scope.as_deref(),
516
crate::oauth::scopes::AccountAttr::Email,
517
crate::oauth::scopes::AccountAction::Read,
518
) {
519
+
return Ok(e);
520
}
521
522
+
let cache_key = email_update_cache_key(&auth.did);
523
let pending_json = match state.cache.get(&cache_key).await {
524
Some(json) => json,
525
None => {
526
+
return Ok(Json(json!({ "pending": false, "authorized": false })).into_response());
527
}
528
};
529
530
let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
531
Ok(p) => p,
532
Err(_) => {
533
+
return Ok(Json(json!({ "pending": false, "authorized": false })).into_response());
534
}
535
};
536
537
+
Ok(Json(json!({
538
"pending": true,
539
"authorized": pending.authorized,
540
"newEmail": pending.new_email,
541
}))
542
+
.into_response())
543
}
544
545
#[derive(Deserialize)]
+46
-48
crates/tranquil-pds/src/api/server/invite.rs
+46
-48
crates/tranquil-pds/src/api/server/invite.rs
···
1
use crate::api::ApiError;
2
-
use crate::auth::BearerAuth;
3
-
use crate::auth::extractor::BearerAuthAdmin;
4
use crate::state::AppState;
5
use crate::types::Did;
6
use axum::{
···
44
45
pub async fn create_invite_code(
46
State(state): State<AppState>,
47
-
BearerAuthAdmin(auth_user): BearerAuthAdmin,
48
Json(input): Json<CreateInviteCodeInput>,
49
-
) -> Response {
50
if input.use_count < 1 {
51
-
return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response();
52
}
53
54
let for_account: Did = match &input.for_account {
55
-
Some(acct) => match acct.parse() {
56
-
Ok(d) => d,
57
-
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
58
-
},
59
-
None => auth_user.did.clone(),
60
};
61
let code = gen_invite_code();
62
···
65
.create_invite_code(&code, input.use_count, Some(&for_account))
66
.await
67
{
68
-
Ok(true) => Json(CreateInviteCodeOutput { code }).into_response(),
69
Ok(false) => {
70
error!("No admin user found to create invite code");
71
-
ApiError::InternalError(None).into_response()
72
}
73
Err(e) => {
74
error!("DB error creating invite code: {:?}", e);
75
-
ApiError::InternalError(None).into_response()
76
}
77
}
78
}
···
98
99
pub async fn create_invite_codes(
100
State(state): State<AppState>,
101
-
BearerAuthAdmin(auth_user): BearerAuthAdmin,
102
Json(input): Json<CreateInviteCodesInput>,
103
-
) -> Response {
104
if input.use_count < 1 {
105
-
return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response();
106
}
107
108
let code_count = input.code_count.unwrap_or(1).max(1);
109
let for_accounts: Vec<Did> = match &input.for_accounts {
110
-
Some(accounts) if !accounts.is_empty() => {
111
-
let parsed: Result<Vec<Did>, _> = accounts.iter().map(|a| a.parse()).collect();
112
-
match parsed {
113
-
Ok(dids) => dids,
114
-
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
115
-
}
116
-
}
117
-
_ => vec![auth_user.did.clone()],
118
};
119
120
-
let admin_user_id = match state.user_repo.get_any_admin_user_id().await {
121
-
Ok(Some(id)) => id,
122
-
Ok(None) => {
123
-
error!("No admin user found to create invite codes");
124
-
return ApiError::InternalError(None).into_response();
125
-
}
126
-
Err(e) => {
127
error!("DB error looking up admin user: {:?}", e);
128
-
return ApiError::InternalError(None).into_response();
129
-
}
130
-
};
131
132
let result = futures::future::try_join_all(for_accounts.into_iter().map(|account| {
133
let infra_repo = state.infra_repo.clone();
···
146
.await;
147
148
match result {
149
-
Ok(result_codes) => Json(CreateInviteCodesOutput {
150
codes: result_codes,
151
})
152
-
.into_response(),
153
Err(e) => {
154
error!("DB error creating invite codes: {:?}", e);
155
-
ApiError::InternalError(None).into_response()
156
}
157
}
158
}
···
192
193
pub async fn get_account_invite_codes(
194
State(state): State<AppState>,
195
-
BearerAuth(auth_user): BearerAuth,
196
axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>,
197
-
) -> Response {
198
let include_used = params.include_used.unwrap_or(true);
199
200
-
let codes_info = match state
201
.infra_repo
202
-
.get_invite_codes_for_account(&auth_user.did)
203
.await
204
-
{
205
-
Ok(info) => info,
206
-
Err(e) => {
207
error!("DB error fetching invite codes: {:?}", e);
208
-
return ApiError::InternalError(None).into_response();
209
-
}
210
-
};
211
212
let filtered_codes: Vec<_> = codes_info
213
.into_iter()
···
254
.await;
255
256
let codes: Vec<InviteCode> = codes.into_iter().flatten().collect();
257
-
Json(GetAccountInviteCodesOutput { codes }).into_response()
258
}
···
1
use crate::api::ApiError;
2
+
use crate::auth::{Admin, Auth, NotTakendown};
3
use crate::state::AppState;
4
use crate::types::Did;
5
use axum::{
···
43
44
pub async fn create_invite_code(
45
State(state): State<AppState>,
46
+
auth: Auth<Admin>,
47
Json(input): Json<CreateInviteCodeInput>,
48
+
) -> Result<Response, ApiError> {
49
if input.use_count < 1 {
50
+
return Err(ApiError::InvalidRequest(
51
+
"useCount must be at least 1".into(),
52
+
));
53
}
54
55
let for_account: Did = match &input.for_account {
56
+
Some(acct) => acct
57
+
.parse()
58
+
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?,
59
+
None => auth.did.clone(),
60
};
61
let code = gen_invite_code();
62
···
65
.create_invite_code(&code, input.use_count, Some(&for_account))
66
.await
67
{
68
+
Ok(true) => Ok(Json(CreateInviteCodeOutput { code }).into_response()),
69
Ok(false) => {
70
error!("No admin user found to create invite code");
71
+
Err(ApiError::InternalError(None))
72
}
73
Err(e) => {
74
error!("DB error creating invite code: {:?}", e);
75
+
Err(ApiError::InternalError(None))
76
}
77
}
78
}
···
98
99
pub async fn create_invite_codes(
100
State(state): State<AppState>,
101
+
auth: Auth<Admin>,
102
Json(input): Json<CreateInviteCodesInput>,
103
+
) -> Result<Response, ApiError> {
104
if input.use_count < 1 {
105
+
return Err(ApiError::InvalidRequest(
106
+
"useCount must be at least 1".into(),
107
+
));
108
}
109
110
let code_count = input.code_count.unwrap_or(1).max(1);
111
let for_accounts: Vec<Did> = match &input.for_accounts {
112
+
Some(accounts) if !accounts.is_empty() => accounts
113
+
.iter()
114
+
.map(|a| a.parse())
115
+
.collect::<Result<Vec<Did>, _>>()
116
+
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?,
117
+
_ => vec![auth.did.clone()],
118
};
119
120
+
let admin_user_id = state
121
+
.user_repo
122
+
.get_any_admin_user_id()
123
+
.await
124
+
.map_err(|e| {
125
error!("DB error looking up admin user: {:?}", e);
126
+
ApiError::InternalError(None)
127
+
})?
128
+
.ok_or_else(|| {
129
+
error!("No admin user found to create invite codes");
130
+
ApiError::InternalError(None)
131
+
})?;
132
133
let result = futures::future::try_join_all(for_accounts.into_iter().map(|account| {
134
let infra_repo = state.infra_repo.clone();
···
147
.await;
148
149
match result {
150
+
Ok(result_codes) => Ok(Json(CreateInviteCodesOutput {
151
codes: result_codes,
152
})
153
+
.into_response()),
154
Err(e) => {
155
error!("DB error creating invite codes: {:?}", e);
156
+
Err(ApiError::InternalError(None))
157
}
158
}
159
}
···
193
194
pub async fn get_account_invite_codes(
195
State(state): State<AppState>,
196
+
auth: Auth<NotTakendown>,
197
axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>,
198
+
) -> Result<Response, ApiError> {
199
let include_used = params.include_used.unwrap_or(true);
200
201
+
let codes_info = state
202
.infra_repo
203
+
.get_invite_codes_for_account(&auth.did)
204
.await
205
+
.map_err(|e| {
206
error!("DB error fetching invite codes: {:?}", e);
207
+
ApiError::InternalError(None)
208
+
})?;
209
210
let filtered_codes: Vec<_> = codes_info
211
.into_iter()
···
252
.await;
253
254
let codes: Vec<InviteCode> = codes.into_iter().flatten().collect();
255
+
Ok(Json(GetAccountInviteCodesOutput { codes }).into_response())
256
}
+46
-103
crates/tranquil-pds/src/api/server/migration.rs
+46
-103
crates/tranquil-pds/src/api/server/migration.rs
···
1
use crate::api::ApiError;
2
use crate::state::AppState;
3
use axum::{
4
Json,
···
35
36
pub async fn update_did_document(
37
State(state): State<AppState>,
38
-
headers: axum::http::HeaderMap,
39
Json(input): Json<UpdateDidDocumentInput>,
40
-
) -> Response {
41
-
let extracted = match crate::auth::extract_auth_token_from_header(
42
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
43
-
) {
44
-
Some(t) => t,
45
-
None => return ApiError::AuthenticationRequired.into_response(),
46
-
};
47
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
48
-
let http_uri = format!(
49
-
"https://{}/xrpc/_account.updateDidDocument",
50
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
51
-
);
52
-
let auth_user = match crate::auth::validate_token_with_dpop(
53
-
state.user_repo.as_ref(),
54
-
state.oauth_repo.as_ref(),
55
-
&extracted.token,
56
-
extracted.is_dpop,
57
-
dpop_proof,
58
-
"POST",
59
-
&http_uri,
60
-
true,
61
-
false,
62
-
)
63
-
.await
64
-
{
65
-
Ok(user) => user,
66
-
Err(e) => return ApiError::from(e).into_response(),
67
-
};
68
-
69
-
if !auth_user.did.starts_with("did:web:") {
70
-
return ApiError::InvalidRequest(
71
"DID document updates are only available for did:web accounts".into(),
72
-
)
73
-
.into_response();
74
}
75
76
-
let user = match state.user_repo.get_user_for_did_doc(&auth_user.did).await {
77
-
Ok(Some(u)) => u,
78
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
79
-
Err(e) => {
80
tracing::error!("DB error getting user: {:?}", e);
81
-
return ApiError::InternalError(None).into_response();
82
-
}
83
-
};
84
-
85
-
if user.deactivated_at.is_some() {
86
-
return ApiError::AccountDeactivated.into_response();
87
-
}
88
89
if let Some(ref methods) = input.verification_methods {
90
if methods.is_empty() {
91
-
return ApiError::InvalidRequest("verification_methods cannot be empty".into())
92
-
.into_response();
93
}
94
let validation_error = methods.iter().find_map(|method| {
95
if method.id.is_empty() {
···
105
}
106
});
107
if let Some(err) = validation_error {
108
-
return ApiError::InvalidRequest(err.into()).into_response();
109
}
110
}
111
112
if let Some(ref handles) = input.also_known_as
113
&& handles.iter().any(|h| !h.starts_with("at://"))
114
{
115
-
return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into())
116
-
.into_response();
117
}
118
119
if let Some(ref endpoint) = input.service_endpoint {
120
let endpoint = endpoint.trim();
121
if !endpoint.starts_with("https://") {
122
-
return ApiError::InvalidRequest("serviceEndpoint must start with https://".into())
123
-
.into_response();
124
}
125
}
126
···
131
132
let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
133
134
-
if let Err(e) = state
135
.user_repo
136
.upsert_did_web_overrides(user.id, verification_methods_json, also_known_as)
137
.await
138
-
{
139
-
tracing::error!("DB error upserting did_web_overrides: {:?}", e);
140
-
return ApiError::InternalError(None).into_response();
141
-
}
142
143
if let Some(ref endpoint) = input.service_endpoint {
144
let endpoint_clean = endpoint.trim().trim_end_matches('/');
145
-
if let Err(e) = state
146
.user_repo
147
-
.update_migrated_to_pds(&auth_user.did, endpoint_clean)
148
.await
149
-
{
150
-
tracing::error!("DB error updating service endpoint: {:?}", e);
151
-
return ApiError::InternalError(None).into_response();
152
-
}
153
}
154
155
-
let did_doc = build_did_document(&state, &auth_user.did).await;
156
157
-
tracing::info!("Updated DID document for {}", &auth_user.did);
158
159
-
(
160
StatusCode::OK,
161
Json(UpdateDidDocumentOutput {
162
success: true,
163
did_document: did_doc,
164
}),
165
)
166
-
.into_response()
167
}
168
169
pub async fn get_did_document(
170
State(state): State<AppState>,
171
-
headers: axum::http::HeaderMap,
172
-
) -> Response {
173
-
let extracted = match crate::auth::extract_auth_token_from_header(
174
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
175
-
) {
176
-
Some(t) => t,
177
-
None => return ApiError::AuthenticationRequired.into_response(),
178
-
};
179
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
180
-
let http_uri = format!(
181
-
"https://{}/xrpc/_account.getDidDocument",
182
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
183
-
);
184
-
let auth_user = match crate::auth::validate_token_with_dpop(
185
-
state.user_repo.as_ref(),
186
-
state.oauth_repo.as_ref(),
187
-
&extracted.token,
188
-
extracted.is_dpop,
189
-
dpop_proof,
190
-
"GET",
191
-
&http_uri,
192
-
true,
193
-
false,
194
-
)
195
-
.await
196
-
{
197
-
Ok(user) => user,
198
-
Err(e) => return ApiError::from(e).into_response(),
199
-
};
200
-
201
-
if !auth_user.did.starts_with("did:web:") {
202
-
return ApiError::InvalidRequest(
203
"This endpoint is only available for did:web accounts".into(),
204
-
)
205
-
.into_response();
206
}
207
208
-
let did_doc = build_did_document(&state, &auth_user.did).await;
209
210
-
(StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()
211
}
212
213
async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value {
···
1
use crate::api::ApiError;
2
+
use crate::auth::{Active, Auth};
3
use crate::state::AppState;
4
use axum::{
5
Json,
···
36
37
pub async fn update_did_document(
38
State(state): State<AppState>,
39
+
auth: Auth<Active>,
40
Json(input): Json<UpdateDidDocumentInput>,
41
+
) -> Result<Response, ApiError> {
42
+
if !auth.did.starts_with("did:web:") {
43
+
return Err(ApiError::InvalidRequest(
44
"DID document updates are only available for did:web accounts".into(),
45
+
));
46
}
47
48
+
let user = state
49
+
.user_repo
50
+
.get_user_for_did_doc(&auth.did)
51
+
.await
52
+
.map_err(|e| {
53
tracing::error!("DB error getting user: {:?}", e);
54
+
ApiError::InternalError(None)
55
+
})?
56
+
.ok_or(ApiError::AccountNotFound)?;
57
58
if let Some(ref methods) = input.verification_methods {
59
if methods.is_empty() {
60
+
return Err(ApiError::InvalidRequest(
61
+
"verification_methods cannot be empty".into(),
62
+
));
63
}
64
let validation_error = methods.iter().find_map(|method| {
65
if method.id.is_empty() {
···
75
}
76
});
77
if let Some(err) = validation_error {
78
+
return Err(ApiError::InvalidRequest(err.into()));
79
}
80
}
81
82
if let Some(ref handles) = input.also_known_as
83
&& handles.iter().any(|h| !h.starts_with("at://"))
84
{
85
+
return Err(ApiError::InvalidRequest(
86
+
"alsoKnownAs entries must be at:// URIs".into(),
87
+
));
88
}
89
90
if let Some(ref endpoint) = input.service_endpoint {
91
let endpoint = endpoint.trim();
92
if !endpoint.starts_with("https://") {
93
+
return Err(ApiError::InvalidRequest(
94
+
"serviceEndpoint must start with https://".into(),
95
+
));
96
}
97
}
98
···
103
104
let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
105
106
+
state
107
.user_repo
108
.upsert_did_web_overrides(user.id, verification_methods_json, also_known_as)
109
.await
110
+
.map_err(|e| {
111
+
tracing::error!("DB error upserting did_web_overrides: {:?}", e);
112
+
ApiError::InternalError(None)
113
+
})?;
114
115
if let Some(ref endpoint) = input.service_endpoint {
116
let endpoint_clean = endpoint.trim().trim_end_matches('/');
117
+
state
118
.user_repo
119
+
.update_migrated_to_pds(&auth.did, endpoint_clean)
120
.await
121
+
.map_err(|e| {
122
+
tracing::error!("DB error updating service endpoint: {:?}", e);
123
+
ApiError::InternalError(None)
124
+
})?;
125
}
126
127
+
let did_doc = build_did_document(&state, &auth.did).await;
128
129
+
tracing::info!("Updated DID document for {}", &auth.did);
130
131
+
Ok((
132
StatusCode::OK,
133
Json(UpdateDidDocumentOutput {
134
success: true,
135
did_document: did_doc,
136
}),
137
)
138
+
.into_response())
139
}
140
141
pub async fn get_did_document(
142
State(state): State<AppState>,
143
+
auth: Auth<Active>,
144
+
) -> Result<Response, ApiError> {
145
+
if !auth.did.starts_with("did:web:") {
146
+
return Err(ApiError::InvalidRequest(
147
"This endpoint is only available for did:web accounts".into(),
148
+
));
149
}
150
151
+
let did_doc = build_did_document(&state, &auth.did).await;
152
153
+
Ok((StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response())
154
}
155
156
async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value {
+108
-147
crates/tranquil-pds/src/api/server/passkeys.rs
+108
-147
crates/tranquil-pds/src/api/server/passkeys.rs
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
-
use crate::auth::BearerAuth;
4
use crate::auth::webauthn::WebAuthnConfig;
5
use crate::state::AppState;
6
use axum::{
7
Json,
···
34
35
pub async fn start_passkey_registration(
36
State(state): State<AppState>,
37
-
auth: BearerAuth,
38
Json(input): Json<StartRegistrationInput>,
39
-
) -> Response {
40
-
let webauthn = match get_webauthn() {
41
-
Ok(w) => w,
42
-
Err(e) => return e.into_response(),
43
-
};
44
45
-
let handle = match state.user_repo.get_handle_by_did(&auth.0.did).await {
46
-
Ok(Some(h)) => h,
47
-
Ok(None) => {
48
-
return ApiError::AccountNotFound.into_response();
49
-
}
50
-
Err(e) => {
51
error!("DB error fetching user: {:?}", e);
52
-
return ApiError::InternalError(None).into_response();
53
-
}
54
-
};
55
56
-
let existing_passkeys = match state.user_repo.get_passkeys_for_user(&auth.0.did).await {
57
-
Ok(passkeys) => passkeys,
58
-
Err(e) => {
59
error!("DB error fetching existing passkeys: {:?}", e);
60
-
return ApiError::InternalError(None).into_response();
61
-
}
62
-
};
63
64
let exclude_credentials: Vec<CredentialID> = existing_passkeys
65
.iter()
···
68
69
let display_name = input.friendly_name.as_deref().unwrap_or(&handle);
70
71
-
let (ccr, reg_state) = match webauthn.start_registration(
72
-
&auth.0.did,
73
-
&handle,
74
-
display_name,
75
-
exclude_credentials,
76
-
) {
77
-
Ok(result) => result,
78
-
Err(e) => {
79
error!("Failed to start passkey registration: {}", e);
80
-
return ApiError::InternalError(Some("Failed to start registration".into()))
81
-
.into_response();
82
-
}
83
-
};
84
85
-
let state_json = match serde_json::to_string(®_state) {
86
-
Ok(s) => s,
87
-
Err(e) => {
88
-
error!("Failed to serialize registration state: {:?}", e);
89
-
return ApiError::InternalError(None).into_response();
90
-
}
91
-
};
92
93
-
if let Err(e) = state
94
.user_repo
95
-
.save_webauthn_challenge(&auth.0.did, "registration", &state_json)
96
.await
97
-
{
98
-
error!("Failed to save registration state: {:?}", e);
99
-
return ApiError::InternalError(None).into_response();
100
-
}
101
102
let options = serde_json::to_value(&ccr).unwrap_or(serde_json::json!({}));
103
104
-
info!(did = %auth.0.did, "Passkey registration started");
105
106
-
Json(StartRegistrationResponse { options }).into_response()
107
}
108
109
#[derive(Deserialize)]
···
122
123
pub async fn finish_passkey_registration(
124
State(state): State<AppState>,
125
-
auth: BearerAuth,
126
Json(input): Json<FinishRegistrationInput>,
127
-
) -> Response {
128
-
let webauthn = match get_webauthn() {
129
-
Ok(w) => w,
130
-
Err(e) => return e.into_response(),
131
-
};
132
133
-
let reg_state_json = match state
134
.user_repo
135
-
.load_webauthn_challenge(&auth.0.did, "registration")
136
.await
137
-
{
138
-
Ok(Some(json)) => json,
139
-
Ok(None) => {
140
-
return ApiError::NoRegistrationInProgress.into_response();
141
-
}
142
-
Err(e) => {
143
error!("DB error loading registration state: {:?}", e);
144
-
return ApiError::InternalError(None).into_response();
145
-
}
146
-
};
147
148
-
let reg_state: SecurityKeyRegistration = match serde_json::from_str(®_state_json) {
149
-
Ok(s) => s,
150
-
Err(e) => {
151
error!("Failed to deserialize registration state: {:?}", e);
152
-
return ApiError::InternalError(None).into_response();
153
-
}
154
-
};
155
156
-
let credential: RegisterPublicKeyCredential = match serde_json::from_value(input.credential) {
157
-
Ok(c) => c,
158
-
Err(e) => {
159
warn!("Failed to parse credential: {:?}", e);
160
-
return ApiError::InvalidCredential.into_response();
161
-
}
162
-
};
163
164
-
let passkey = match webauthn.finish_registration(&credential, ®_state) {
165
-
Ok(pk) => pk,
166
-
Err(e) => {
167
warn!("Failed to finish passkey registration: {}", e);
168
-
return ApiError::RegistrationFailed.into_response();
169
-
}
170
-
};
171
172
-
let public_key = match serde_json::to_vec(&passkey) {
173
-
Ok(pk) => pk,
174
-
Err(e) => {
175
-
error!("Failed to serialize passkey: {:?}", e);
176
-
return ApiError::InternalError(None).into_response();
177
-
}
178
-
};
179
180
-
let passkey_id = match state
181
.user_repo
182
.save_passkey(
183
-
&auth.0.did,
184
passkey.cred_id(),
185
&public_key,
186
input.friendly_name.as_deref(),
187
)
188
.await
189
-
{
190
-
Ok(id) => id,
191
-
Err(e) => {
192
error!("Failed to save passkey: {:?}", e);
193
-
return ApiError::InternalError(None).into_response();
194
-
}
195
-
};
196
197
if let Err(e) = state
198
.user_repo
199
-
.delete_webauthn_challenge(&auth.0.did, "registration")
200
.await
201
{
202
warn!("Failed to delete registration state: {:?}", e);
···
207
passkey.cred_id(),
208
);
209
210
-
info!(did = %auth.0.did, passkey_id = %passkey_id, "Passkey registered");
211
212
-
Json(FinishRegistrationResponse {
213
id: passkey_id.to_string(),
214
credential_id: credential_id_base64,
215
})
216
-
.into_response()
217
}
218
219
#[derive(Serialize)]
···
232
pub passkeys: Vec<PasskeyInfo>,
233
}
234
235
-
pub async fn list_passkeys(State(state): State<AppState>, auth: BearerAuth) -> Response {
236
-
let passkeys = match state.user_repo.get_passkeys_for_user(&auth.0.did).await {
237
-
Ok(pks) => pks,
238
-
Err(e) => {
239
error!("DB error fetching passkeys: {:?}", e);
240
-
return ApiError::InternalError(None).into_response();
241
-
}
242
-
};
243
244
let passkey_infos: Vec<PasskeyInfo> = passkeys
245
.into_iter()
···
252
})
253
.collect();
254
255
-
Json(ListPasskeysResponse {
256
passkeys: passkey_infos,
257
})
258
-
.into_response()
259
}
260
261
#[derive(Deserialize)]
···
266
267
pub async fn delete_passkey(
268
State(state): State<AppState>,
269
-
auth: BearerAuth,
270
Json(input): Json<DeletePasskeyInput>,
271
-
) -> Response {
272
-
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.0.did)
273
-
.await
274
{
275
-
return crate::api::server::reauth::legacy_mfa_required_response(
276
&*state.user_repo,
277
&*state.session_repo,
278
-
&auth.0.did,
279
)
280
-
.await;
281
}
282
283
-
if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.0.did).await {
284
-
return crate::api::server::reauth::reauth_required_response(
285
&*state.user_repo,
286
&*state.session_repo,
287
-
&auth.0.did,
288
)
289
-
.await;
290
}
291
292
-
let id: uuid::Uuid = match input.id.parse() {
293
-
Ok(id) => id,
294
-
Err(_) => {
295
-
return ApiError::InvalidId.into_response();
296
-
}
297
-
};
298
299
-
match state.user_repo.delete_passkey(id, &auth.0.did).await {
300
Ok(true) => {
301
-
info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted");
302
-
EmptyResponse::ok().into_response()
303
}
304
-
Ok(false) => ApiError::PasskeyNotFound.into_response(),
305
Err(e) => {
306
error!("DB error deleting passkey: {:?}", e);
307
-
ApiError::InternalError(None).into_response()
308
}
309
}
310
}
···
318
319
pub async fn update_passkey(
320
State(state): State<AppState>,
321
-
auth: BearerAuth,
322
Json(input): Json<UpdatePasskeyInput>,
323
-
) -> Response {
324
-
let id: uuid::Uuid = match input.id.parse() {
325
-
Ok(id) => id,
326
-
Err(_) => {
327
-
return ApiError::InvalidId.into_response();
328
-
}
329
-
};
330
331
match state
332
.user_repo
333
-
.update_passkey_name(id, &auth.0.did, &input.friendly_name)
334
.await
335
{
336
Ok(true) => {
337
-
info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed");
338
-
EmptyResponse::ok().into_response()
339
}
340
-
Ok(false) => ApiError::PasskeyNotFound.into_response(),
341
Err(e) => {
342
error!("DB error updating passkey: {:?}", e);
343
-
ApiError::InternalError(None).into_response()
344
}
345
}
346
}
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
use crate::auth::webauthn::WebAuthnConfig;
4
+
use crate::auth::{Active, Auth};
5
use crate::state::AppState;
6
use axum::{
7
Json,
···
34
35
pub async fn start_passkey_registration(
36
State(state): State<AppState>,
37
+
auth: Auth<Active>,
38
Json(input): Json<StartRegistrationInput>,
39
+
) -> Result<Response, ApiError> {
40
+
let webauthn = get_webauthn()?;
41
42
+
let handle = state
43
+
.user_repo
44
+
.get_handle_by_did(&auth.did)
45
+
.await
46
+
.map_err(|e| {
47
error!("DB error fetching user: {:?}", e);
48
+
ApiError::InternalError(None)
49
+
})?
50
+
.ok_or(ApiError::AccountNotFound)?;
51
52
+
let existing_passkeys = state
53
+
.user_repo
54
+
.get_passkeys_for_user(&auth.did)
55
+
.await
56
+
.map_err(|e| {
57
error!("DB error fetching existing passkeys: {:?}", e);
58
+
ApiError::InternalError(None)
59
+
})?;
60
61
let exclude_credentials: Vec<CredentialID> = existing_passkeys
62
.iter()
···
65
66
let display_name = input.friendly_name.as_deref().unwrap_or(&handle);
67
68
+
let (ccr, reg_state) = webauthn
69
+
.start_registration(&auth.did, &handle, display_name, exclude_credentials)
70
+
.map_err(|e| {
71
error!("Failed to start passkey registration: {}", e);
72
+
ApiError::InternalError(Some("Failed to start registration".into()))
73
+
})?;
74
75
+
let state_json = serde_json::to_string(®_state).map_err(|e| {
76
+
error!("Failed to serialize registration state: {:?}", e);
77
+
ApiError::InternalError(None)
78
+
})?;
79
80
+
state
81
.user_repo
82
+
.save_webauthn_challenge(&auth.did, "registration", &state_json)
83
.await
84
+
.map_err(|e| {
85
+
error!("Failed to save registration state: {:?}", e);
86
+
ApiError::InternalError(None)
87
+
})?;
88
89
let options = serde_json::to_value(&ccr).unwrap_or(serde_json::json!({}));
90
91
+
info!(did = %auth.did, "Passkey registration started");
92
93
+
Ok(Json(StartRegistrationResponse { options }).into_response())
94
}
95
96
#[derive(Deserialize)]
···
109
110
pub async fn finish_passkey_registration(
111
State(state): State<AppState>,
112
+
auth: Auth<Active>,
113
Json(input): Json<FinishRegistrationInput>,
114
+
) -> Result<Response, ApiError> {
115
+
let webauthn = get_webauthn()?;
116
117
+
let reg_state_json = state
118
.user_repo
119
+
.load_webauthn_challenge(&auth.did, "registration")
120
.await
121
+
.map_err(|e| {
122
error!("DB error loading registration state: {:?}", e);
123
+
ApiError::InternalError(None)
124
+
})?
125
+
.ok_or(ApiError::NoRegistrationInProgress)?;
126
127
+
let reg_state: SecurityKeyRegistration =
128
+
serde_json::from_str(®_state_json).map_err(|e| {
129
error!("Failed to deserialize registration state: {:?}", e);
130
+
ApiError::InternalError(None)
131
+
})?;
132
133
+
let credential: RegisterPublicKeyCredential = serde_json::from_value(input.credential)
134
+
.map_err(|e| {
135
warn!("Failed to parse credential: {:?}", e);
136
+
ApiError::InvalidCredential
137
+
})?;
138
139
+
let passkey = webauthn
140
+
.finish_registration(&credential, ®_state)
141
+
.map_err(|e| {
142
warn!("Failed to finish passkey registration: {}", e);
143
+
ApiError::RegistrationFailed
144
+
})?;
145
146
+
let public_key = serde_json::to_vec(&passkey).map_err(|e| {
147
+
error!("Failed to serialize passkey: {:?}", e);
148
+
ApiError::InternalError(None)
149
+
})?;
150
151
+
let passkey_id = state
152
.user_repo
153
.save_passkey(
154
+
&auth.did,
155
passkey.cred_id(),
156
&public_key,
157
input.friendly_name.as_deref(),
158
)
159
.await
160
+
.map_err(|e| {
161
error!("Failed to save passkey: {:?}", e);
162
+
ApiError::InternalError(None)
163
+
})?;
164
165
if let Err(e) = state
166
.user_repo
167
+
.delete_webauthn_challenge(&auth.did, "registration")
168
.await
169
{
170
warn!("Failed to delete registration state: {:?}", e);
···
175
passkey.cred_id(),
176
);
177
178
+
info!(did = %auth.did, passkey_id = %passkey_id, "Passkey registered");
179
180
+
Ok(Json(FinishRegistrationResponse {
181
id: passkey_id.to_string(),
182
credential_id: credential_id_base64,
183
})
184
+
.into_response())
185
}
186
187
#[derive(Serialize)]
···
200
pub passkeys: Vec<PasskeyInfo>,
201
}
202
203
+
pub async fn list_passkeys(
204
+
State(state): State<AppState>,
205
+
auth: Auth<Active>,
206
+
) -> Result<Response, ApiError> {
207
+
let passkeys = state
208
+
.user_repo
209
+
.get_passkeys_for_user(&auth.did)
210
+
.await
211
+
.map_err(|e| {
212
error!("DB error fetching passkeys: {:?}", e);
213
+
ApiError::InternalError(None)
214
+
})?;
215
216
let passkey_infos: Vec<PasskeyInfo> = passkeys
217
.into_iter()
···
224
})
225
.collect();
226
227
+
Ok(Json(ListPasskeysResponse {
228
passkeys: passkey_infos,
229
})
230
+
.into_response())
231
}
232
233
#[derive(Deserialize)]
···
238
239
pub async fn delete_passkey(
240
State(state): State<AppState>,
241
+
auth: Auth<Active>,
242
Json(input): Json<DeletePasskeyInput>,
243
+
) -> Result<Response, ApiError> {
244
+
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
245
{
246
+
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
247
&*state.user_repo,
248
&*state.session_repo,
249
+
&auth.did,
250
)
251
+
.await);
252
}
253
254
+
if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.did).await {
255
+
return Ok(crate::api::server::reauth::reauth_required_response(
256
&*state.user_repo,
257
&*state.session_repo,
258
+
&auth.did,
259
)
260
+
.await);
261
}
262
263
+
let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?;
264
265
+
match state.user_repo.delete_passkey(id, &auth.did).await {
266
Ok(true) => {
267
+
info!(did = %auth.did, passkey_id = %id, "Passkey deleted");
268
+
Ok(EmptyResponse::ok().into_response())
269
}
270
+
Ok(false) => Err(ApiError::PasskeyNotFound),
271
Err(e) => {
272
error!("DB error deleting passkey: {:?}", e);
273
+
Err(ApiError::InternalError(None))
274
}
275
}
276
}
···
284
285
pub async fn update_passkey(
286
State(state): State<AppState>,
287
+
auth: Auth<Active>,
288
Json(input): Json<UpdatePasskeyInput>,
289
+
) -> Result<Response, ApiError> {
290
+
let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?;
291
292
match state
293
.user_repo
294
+
.update_passkey_name(id, &auth.did, &input.friendly_name)
295
.await
296
{
297
Ok(true) => {
298
+
info!(did = %auth.did, passkey_id = %id, "Passkey renamed");
299
+
Ok(EmptyResponse::ok().into_response())
300
}
301
+
Ok(false) => Err(ApiError::PasskeyNotFound),
302
Err(e) => {
303
error!("DB error updating passkey: {:?}", e);
304
+
Err(ApiError::InternalError(None))
305
}
306
}
307
}
+127
-126
crates/tranquil-pds/src/api/server/password.rs
+127
-126
crates/tranquil-pds/src/api/server/password.rs
···
1
use crate::api::error::ApiError;
2
use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse};
3
-
use crate::auth::BearerAuth;
4
use crate::state::{AppState, RateLimitKind};
5
use crate::types::PlainPassword;
6
use crate::validation::validate_password;
···
227
228
pub async fn change_password(
229
State(state): State<AppState>,
230
-
auth: BearerAuth,
231
Json(input): Json<ChangePasswordInput>,
232
-
) -> Response {
233
-
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.0.did)
234
-
.await
235
{
236
-
return crate::api::server::reauth::legacy_mfa_required_response(
237
&*state.user_repo,
238
&*state.session_repo,
239
-
&auth.0.did,
240
)
241
-
.await;
242
}
243
244
let current_password = &input.current_password;
245
let new_password = &input.new_password;
246
if current_password.is_empty() {
247
-
return ApiError::InvalidRequest("currentPassword is required".into()).into_response();
248
}
249
if new_password.is_empty() {
250
-
return ApiError::InvalidRequest("newPassword is required".into()).into_response();
251
}
252
if let Err(e) = validate_password(new_password) {
253
-
return ApiError::InvalidRequest(e.to_string()).into_response();
254
}
255
-
let user = match state
256
.user_repo
257
-
.get_id_and_password_hash_by_did(&auth.0.did)
258
.await
259
-
{
260
-
Ok(Some(u)) => u,
261
-
Ok(None) => {
262
-
return ApiError::AccountNotFound.into_response();
263
-
}
264
-
Err(e) => {
265
error!("DB error in change_password: {:?}", e);
266
-
return ApiError::InternalError(None).into_response();
267
-
}
268
-
};
269
let (user_id, password_hash) = (user.id, user.password_hash);
270
-
let valid = match verify(current_password, &password_hash) {
271
-
Ok(v) => v,
272
-
Err(e) => {
273
-
error!("Password verification error: {:?}", e);
274
-
return ApiError::InternalError(None).into_response();
275
-
}
276
-
};
277
if !valid {
278
-
return ApiError::InvalidPassword("Current password is incorrect".into()).into_response();
279
}
280
let new_password_clone = new_password.to_string();
281
-
let new_hash =
282
-
match tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST)).await {
283
-
Ok(Ok(h)) => h,
284
-
Ok(Err(e)) => {
285
-
error!("Failed to hash password: {:?}", e);
286
-
return ApiError::InternalError(None).into_response();
287
-
}
288
-
Err(e) => {
289
-
error!("Failed to spawn blocking task: {:?}", e);
290
-
return ApiError::InternalError(None).into_response();
291
-
}
292
-
};
293
-
if let Err(e) = state
294
.user_repo
295
.update_password_hash(user_id, &new_hash)
296
.await
297
-
{
298
-
error!("DB error updating password: {:?}", e);
299
-
return ApiError::InternalError(None).into_response();
300
-
}
301
-
info!(did = %&auth.0.did, "Password changed successfully");
302
-
EmptyResponse::ok().into_response()
303
}
304
305
-
pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
306
-
match state.user_repo.has_password_by_did(&auth.0.did).await {
307
-
Ok(Some(has)) => HasPasswordResponse::response(has).into_response(),
308
-
Ok(None) => ApiError::AccountNotFound.into_response(),
309
Err(e) => {
310
error!("DB error: {:?}", e);
311
-
ApiError::InternalError(None).into_response()
312
}
313
}
314
}
315
316
-
pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response {
317
-
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.0.did)
318
-
.await
319
{
320
-
return crate::api::server::reauth::legacy_mfa_required_response(
321
&*state.user_repo,
322
&*state.session_repo,
323
-
&auth.0.did,
324
)
325
-
.await;
326
}
327
328
if crate::api::server::reauth::check_reauth_required_cached(
329
&*state.session_repo,
330
&state.cache,
331
-
&auth.0.did,
332
)
333
.await
334
{
335
-
return crate::api::server::reauth::reauth_required_response(
336
&*state.user_repo,
337
&*state.session_repo,
338
-
&auth.0.did,
339
)
340
-
.await;
341
}
342
343
let has_passkeys = state
344
.user_repo
345
-
.has_passkeys(&auth.0.did)
346
.await
347
.unwrap_or(false);
348
if !has_passkeys {
349
-
return ApiError::InvalidRequest(
350
"You must have at least one passkey registered before removing your password".into(),
351
-
)
352
-
.into_response();
353
}
354
355
-
let user = match state.user_repo.get_password_info_by_did(&auth.0.did).await {
356
-
Ok(Some(u)) => u,
357
-
Ok(None) => {
358
-
return ApiError::AccountNotFound.into_response();
359
-
}
360
-
Err(e) => {
361
error!("DB error: {:?}", e);
362
-
return ApiError::InternalError(None).into_response();
363
-
}
364
-
};
365
366
if user.password_hash.is_none() {
367
-
return ApiError::InvalidRequest("Account already has no password".into()).into_response();
368
}
369
370
-
if let Err(e) = state.user_repo.remove_user_password(user.id).await {
371
-
error!("DB error removing password: {:?}", e);
372
-
return ApiError::InternalError(None).into_response();
373
-
}
374
375
-
info!(did = %&auth.0.did, "Password removed - account is now passkey-only");
376
-
SuccessResponse::ok().into_response()
377
}
378
379
#[derive(Deserialize)]
···
384
385
pub async fn set_password(
386
State(state): State<AppState>,
387
-
auth: BearerAuth,
388
Json(input): Json<SetPasswordInput>,
389
-
) -> Response {
390
let has_password = state
391
.user_repo
392
-
.has_password_by_did(&auth.0.did)
393
.await
394
.ok()
395
.flatten()
396
.unwrap_or(false);
397
let has_passkeys = state
398
.user_repo
399
-
.has_passkeys(&auth.0.did)
400
.await
401
.unwrap_or(false);
402
let has_totp = state
403
.user_repo
404
-
.has_totp_enabled(&auth.0.did)
405
.await
406
.unwrap_or(false);
407
···
411
&& crate::api::server::reauth::check_reauth_required_cached(
412
&*state.session_repo,
413
&state.cache,
414
-
&auth.0.did,
415
)
416
.await
417
{
418
-
return crate::api::server::reauth::reauth_required_response(
419
&*state.user_repo,
420
&*state.session_repo,
421
-
&auth.0.did,
422
)
423
-
.await;
424
}
425
426
let new_password = &input.new_password;
427
if new_password.is_empty() {
428
-
return ApiError::InvalidRequest("newPassword is required".into()).into_response();
429
}
430
if let Err(e) = validate_password(new_password) {
431
-
return ApiError::InvalidRequest(e.to_string()).into_response();
432
}
433
434
-
let user = match state.user_repo.get_password_info_by_did(&auth.0.did).await {
435
-
Ok(Some(u)) => u,
436
-
Ok(None) => {
437
-
return ApiError::AccountNotFound.into_response();
438
-
}
439
-
Err(e) => {
440
error!("DB error: {:?}", e);
441
-
return ApiError::InternalError(None).into_response();
442
-
}
443
-
};
444
445
if user.password_hash.is_some() {
446
-
return ApiError::InvalidRequest(
447
"Account already has a password. Use changePassword instead.".into(),
448
-
)
449
-
.into_response();
450
}
451
452
let new_password_clone = new_password.to_string();
453
-
let new_hash =
454
-
match tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST)).await {
455
-
Ok(Ok(h)) => h,
456
-
Ok(Err(e)) => {
457
-
error!("Failed to hash password: {:?}", e);
458
-
return ApiError::InternalError(None).into_response();
459
-
}
460
-
Err(e) => {
461
-
error!("Failed to spawn blocking task: {:?}", e);
462
-
return ApiError::InternalError(None).into_response();
463
-
}
464
-
};
465
466
-
if let Err(e) = state
467
.user_repo
468
.set_new_user_password(user.id, &new_hash)
469
.await
470
-
{
471
-
error!("DB error setting password: {:?}", e);
472
-
return ApiError::InternalError(None).into_response();
473
-
}
474
475
-
info!(did = %&auth.0.did, "Password set for passkey-only account");
476
-
SuccessResponse::ok().into_response()
477
}
···
1
use crate::api::error::ApiError;
2
use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse};
3
+
use crate::auth::{Active, Auth};
4
use crate::state::{AppState, RateLimitKind};
5
use crate::types::PlainPassword;
6
use crate::validation::validate_password;
···
227
228
pub async fn change_password(
229
State(state): State<AppState>,
230
+
auth: Auth<Active>,
231
Json(input): Json<ChangePasswordInput>,
232
+
) -> Result<Response, ApiError> {
233
+
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
234
{
235
+
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
236
&*state.user_repo,
237
&*state.session_repo,
238
+
&auth.did,
239
)
240
+
.await);
241
}
242
243
let current_password = &input.current_password;
244
let new_password = &input.new_password;
245
if current_password.is_empty() {
246
+
return Err(ApiError::InvalidRequest(
247
+
"currentPassword is required".into(),
248
+
));
249
}
250
if new_password.is_empty() {
251
+
return Err(ApiError::InvalidRequest("newPassword is required".into()));
252
}
253
if let Err(e) = validate_password(new_password) {
254
+
return Err(ApiError::InvalidRequest(e.to_string()));
255
}
256
+
let user = state
257
.user_repo
258
+
.get_id_and_password_hash_by_did(&auth.did)
259
.await
260
+
.map_err(|e| {
261
error!("DB error in change_password: {:?}", e);
262
+
ApiError::InternalError(None)
263
+
})?
264
+
.ok_or(ApiError::AccountNotFound)?;
265
+
266
let (user_id, password_hash) = (user.id, user.password_hash);
267
+
let valid = verify(current_password, &password_hash).map_err(|e| {
268
+
error!("Password verification error: {:?}", e);
269
+
ApiError::InternalError(None)
270
+
})?;
271
if !valid {
272
+
return Err(ApiError::InvalidPassword(
273
+
"Current password is incorrect".into(),
274
+
));
275
}
276
let new_password_clone = new_password.to_string();
277
+
let new_hash = tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST))
278
+
.await
279
+
.map_err(|e| {
280
+
error!("Failed to spawn blocking task: {:?}", e);
281
+
ApiError::InternalError(None)
282
+
})?
283
+
.map_err(|e| {
284
+
error!("Failed to hash password: {:?}", e);
285
+
ApiError::InternalError(None)
286
+
})?;
287
+
288
+
state
289
.user_repo
290
.update_password_hash(user_id, &new_hash)
291
.await
292
+
.map_err(|e| {
293
+
error!("DB error updating password: {:?}", e);
294
+
ApiError::InternalError(None)
295
+
})?;
296
+
297
+
info!(did = %&auth.did, "Password changed successfully");
298
+
Ok(EmptyResponse::ok().into_response())
299
}
300
301
+
pub async fn get_password_status(
302
+
State(state): State<AppState>,
303
+
auth: Auth<Active>,
304
+
) -> Result<Response, ApiError> {
305
+
match state.user_repo.has_password_by_did(&auth.did).await {
306
+
Ok(Some(has)) => Ok(HasPasswordResponse::response(has).into_response()),
307
+
Ok(None) => Err(ApiError::AccountNotFound),
308
Err(e) => {
309
error!("DB error: {:?}", e);
310
+
Err(ApiError::InternalError(None))
311
}
312
}
313
}
314
315
+
pub async fn remove_password(
316
+
State(state): State<AppState>,
317
+
auth: Auth<Active>,
318
+
) -> Result<Response, ApiError> {
319
+
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
320
{
321
+
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
322
&*state.user_repo,
323
&*state.session_repo,
324
+
&auth.did,
325
)
326
+
.await);
327
}
328
329
if crate::api::server::reauth::check_reauth_required_cached(
330
&*state.session_repo,
331
&state.cache,
332
+
&auth.did,
333
)
334
.await
335
{
336
+
return Ok(crate::api::server::reauth::reauth_required_response(
337
&*state.user_repo,
338
&*state.session_repo,
339
+
&auth.did,
340
)
341
+
.await);
342
}
343
344
let has_passkeys = state
345
.user_repo
346
+
.has_passkeys(&auth.did)
347
.await
348
.unwrap_or(false);
349
if !has_passkeys {
350
+
return Err(ApiError::InvalidRequest(
351
"You must have at least one passkey registered before removing your password".into(),
352
+
));
353
}
354
355
+
let user = state
356
+
.user_repo
357
+
.get_password_info_by_did(&auth.did)
358
+
.await
359
+
.map_err(|e| {
360
error!("DB error: {:?}", e);
361
+
ApiError::InternalError(None)
362
+
})?
363
+
.ok_or(ApiError::AccountNotFound)?;
364
365
if user.password_hash.is_none() {
366
+
return Err(ApiError::InvalidRequest(
367
+
"Account already has no password".into(),
368
+
));
369
}
370
371
+
state
372
+
.user_repo
373
+
.remove_user_password(user.id)
374
+
.await
375
+
.map_err(|e| {
376
+
error!("DB error removing password: {:?}", e);
377
+
ApiError::InternalError(None)
378
+
})?;
379
380
+
info!(did = %&auth.did, "Password removed - account is now passkey-only");
381
+
Ok(SuccessResponse::ok().into_response())
382
}
383
384
#[derive(Deserialize)]
···
389
390
pub async fn set_password(
391
State(state): State<AppState>,
392
+
auth: Auth<Active>,
393
Json(input): Json<SetPasswordInput>,
394
+
) -> Result<Response, ApiError> {
395
let has_password = state
396
.user_repo
397
+
.has_password_by_did(&auth.did)
398
.await
399
.ok()
400
.flatten()
401
.unwrap_or(false);
402
let has_passkeys = state
403
.user_repo
404
+
.has_passkeys(&auth.did)
405
.await
406
.unwrap_or(false);
407
let has_totp = state
408
.user_repo
409
+
.has_totp_enabled(&auth.did)
410
.await
411
.unwrap_or(false);
412
···
416
&& crate::api::server::reauth::check_reauth_required_cached(
417
&*state.session_repo,
418
&state.cache,
419
+
&auth.did,
420
)
421
.await
422
{
423
+
return Ok(crate::api::server::reauth::reauth_required_response(
424
&*state.user_repo,
425
&*state.session_repo,
426
+
&auth.did,
427
)
428
+
.await);
429
}
430
431
let new_password = &input.new_password;
432
if new_password.is_empty() {
433
+
return Err(ApiError::InvalidRequest("newPassword is required".into()));
434
}
435
if let Err(e) = validate_password(new_password) {
436
+
return Err(ApiError::InvalidRequest(e.to_string()));
437
}
438
439
+
let user = state
440
+
.user_repo
441
+
.get_password_info_by_did(&auth.did)
442
+
.await
443
+
.map_err(|e| {
444
error!("DB error: {:?}", e);
445
+
ApiError::InternalError(None)
446
+
})?
447
+
.ok_or(ApiError::AccountNotFound)?;
448
449
if user.password_hash.is_some() {
450
+
return Err(ApiError::InvalidRequest(
451
"Account already has a password. Use changePassword instead.".into(),
452
+
));
453
}
454
455
let new_password_clone = new_password.to_string();
456
+
let new_hash = tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST))
457
+
.await
458
+
.map_err(|e| {
459
+
error!("Failed to spawn blocking task: {:?}", e);
460
+
ApiError::InternalError(None)
461
+
})?
462
+
.map_err(|e| {
463
+
error!("Failed to hash password: {:?}", e);
464
+
ApiError::InternalError(None)
465
+
})?;
466
467
+
state
468
.user_repo
469
.set_new_user_password(user.id, &new_hash)
470
.await
471
+
.map_err(|e| {
472
+
error!("DB error setting password: {:?}", e);
473
+
ApiError::InternalError(None)
474
+
})?;
475
476
+
info!(did = %&auth.did, "Password set for passkey-only account");
477
+
Ok(SuccessResponse::ok().into_response())
478
}
+127
-145
crates/tranquil-pds/src/api/server/reauth.rs
+127
-145
crates/tranquil-pds/src/api/server/reauth.rs
···
10
use tracing::{error, info, warn};
11
use tranquil_db_traits::{SessionRepository, UserRepository};
12
13
-
use crate::auth::BearerAuth;
14
use crate::state::{AppState, RateLimitKind};
15
use crate::types::PlainPassword;
16
···
24
pub available_methods: Vec<String>,
25
}
26
27
-
pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
28
-
let last_reauth_at = match state.session_repo.get_last_reauth_at(&auth.0.did).await {
29
-
Ok(t) => t,
30
-
Err(e) => {
31
error!("DB error: {:?}", e);
32
-
return ApiError::InternalError(None).into_response();
33
-
}
34
-
};
35
36
let reauth_required = is_reauth_required(last_reauth_at);
37
let available_methods =
38
-
get_available_reauth_methods(&*state.user_repo, &*state.session_repo, &auth.0.did).await;
39
40
-
Json(ReauthStatusResponse {
41
last_reauth_at,
42
reauth_required,
43
available_methods,
44
})
45
-
.into_response()
46
}
47
48
#[derive(Deserialize)]
···
59
60
pub async fn reauth_password(
61
State(state): State<AppState>,
62
-
auth: BearerAuth,
63
Json(input): Json<PasswordReauthInput>,
64
-
) -> Response {
65
-
let password_hash = match state.user_repo.get_password_hash_by_did(&auth.0.did).await {
66
-
Ok(Some(hash)) => hash,
67
-
Ok(None) => {
68
-
return ApiError::AccountNotFound.into_response();
69
-
}
70
-
Err(e) => {
71
error!("DB error: {:?}", e);
72
-
return ApiError::InternalError(None).into_response();
73
-
}
74
-
};
75
76
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
77
78
if !password_valid {
79
let app_password_hashes = state
80
.session_repo
81
-
.get_app_password_hashes_by_did(&auth.0.did)
82
.await
83
.unwrap_or_default();
84
···
87
});
88
89
if !app_password_valid {
90
-
warn!(did = %&auth.0.did, "Re-auth failed: invalid password");
91
-
return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
92
}
93
}
94
95
-
match update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.0.did).await {
96
-
Ok(reauthed_at) => {
97
-
info!(did = %&auth.0.did, "Re-auth successful via password");
98
-
Json(ReauthResponse { reauthed_at }).into_response()
99
-
}
100
-
Err(e) => {
101
error!("DB error updating reauth: {:?}", e);
102
-
ApiError::InternalError(None).into_response()
103
-
}
104
-
}
105
}
106
107
#[derive(Deserialize)]
···
112
113
pub async fn reauth_totp(
114
State(state): State<AppState>,
115
-
auth: BearerAuth,
116
Json(input): Json<TotpReauthInput>,
117
-
) -> Response {
118
if !state
119
-
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
120
.await
121
{
122
-
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
123
-
return ApiError::RateLimitExceeded(Some(
124
"Too many verification attempts. Please try again in a few minutes.".into(),
125
-
))
126
-
.into_response();
127
}
128
129
let valid =
130
-
crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code)
131
.await;
132
133
if !valid {
134
-
warn!(did = %&auth.0.did, "Re-auth failed: invalid TOTP code");
135
-
return ApiError::InvalidCode(Some("Invalid TOTP or backup code".into())).into_response();
136
}
137
138
-
match update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.0.did).await {
139
-
Ok(reauthed_at) => {
140
-
info!(did = %&auth.0.did, "Re-auth successful via TOTP");
141
-
Json(ReauthResponse { reauthed_at }).into_response()
142
-
}
143
-
Err(e) => {
144
error!("DB error updating reauth: {:?}", e);
145
-
ApiError::InternalError(None).into_response()
146
-
}
147
-
}
148
}
149
150
#[derive(Serialize)]
···
153
pub options: serde_json::Value,
154
}
155
156
-
pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAuth) -> Response {
157
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
158
159
-
let stored_passkeys = match state.user_repo.get_passkeys_for_user(&auth.0.did).await {
160
-
Ok(pks) => pks,
161
-
Err(e) => {
162
error!("Failed to get passkeys: {:?}", e);
163
-
return ApiError::InternalError(None).into_response();
164
-
}
165
-
};
166
167
if stored_passkeys.is_empty() {
168
-
return ApiError::NoPasskeys.into_response();
169
}
170
171
let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys
···
174
.collect();
175
176
if passkeys.is_empty() {
177
-
return ApiError::InternalError(Some("Failed to load passkeys".into())).into_response();
178
}
179
180
-
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
181
-
Ok(w) => w,
182
-
Err(e) => {
183
-
error!("Failed to create WebAuthn config: {:?}", e);
184
-
return ApiError::InternalError(None).into_response();
185
-
}
186
-
};
187
188
-
let (rcr, auth_state) = match webauthn.start_authentication(passkeys) {
189
-
Ok(result) => result,
190
-
Err(e) => {
191
-
error!("Failed to start passkey authentication: {:?}", e);
192
-
return ApiError::InternalError(None).into_response();
193
-
}
194
-
};
195
196
-
let state_json = match serde_json::to_string(&auth_state) {
197
-
Ok(s) => s,
198
-
Err(e) => {
199
-
error!("Failed to serialize authentication state: {:?}", e);
200
-
return ApiError::InternalError(None).into_response();
201
-
}
202
-
};
203
204
-
if let Err(e) = state
205
.user_repo
206
-
.save_webauthn_challenge(&auth.0.did, "authentication", &state_json)
207
.await
208
-
{
209
-
error!("Failed to save authentication state: {:?}", e);
210
-
return ApiError::InternalError(None).into_response();
211
-
}
212
213
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
214
-
Json(PasskeyReauthStartResponse { options }).into_response()
215
}
216
217
#[derive(Deserialize)]
···
222
223
pub async fn reauth_passkey_finish(
224
State(state): State<AppState>,
225
-
auth: BearerAuth,
226
Json(input): Json<PasskeyReauthFinishInput>,
227
-
) -> Response {
228
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
229
230
-
let auth_state_json = match state
231
.user_repo
232
-
.load_webauthn_challenge(&auth.0.did, "authentication")
233
.await
234
-
{
235
-
Ok(Some(json)) => json,
236
-
Ok(None) => {
237
-
return ApiError::NoChallengeInProgress.into_response();
238
-
}
239
-
Err(e) => {
240
error!("Failed to load authentication state: {:?}", e);
241
-
return ApiError::InternalError(None).into_response();
242
-
}
243
-
};
244
245
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
246
-
match serde_json::from_str(&auth_state_json) {
247
-
Ok(s) => s,
248
-
Err(e) => {
249
-
error!("Failed to deserialize authentication state: {:?}", e);
250
-
return ApiError::InternalError(None).into_response();
251
-
}
252
-
};
253
254
let credential: webauthn_rs::prelude::PublicKeyCredential =
255
-
match serde_json::from_value(input.credential) {
256
-
Ok(c) => c,
257
-
Err(e) => {
258
-
warn!("Failed to parse credential: {:?}", e);
259
-
return ApiError::InvalidCredential.into_response();
260
-
}
261
-
};
262
263
-
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
264
-
Ok(w) => w,
265
-
Err(e) => {
266
-
error!("Failed to create WebAuthn config: {:?}", e);
267
-
return ApiError::InternalError(None).into_response();
268
-
}
269
-
};
270
271
-
let auth_result = match webauthn.finish_authentication(&credential, &auth_state) {
272
-
Ok(r) => r,
273
-
Err(e) => {
274
-
warn!(did = %&auth.0.did, "Passkey re-auth failed: {:?}", e);
275
-
return ApiError::AuthenticationFailed(Some("Passkey authentication failed".into()))
276
-
.into_response();
277
-
}
278
-
};
279
280
let cred_id_bytes = auth_result.cred_id().as_ref();
281
match state
···
284
.await
285
{
286
Ok(false) => {
287
-
warn!(did = %&auth.0.did, "Passkey counter anomaly detected - possible cloned key");
288
let _ = state
289
.user_repo
290
-
.delete_webauthn_challenge(&auth.0.did, "authentication")
291
.await;
292
-
return ApiError::PasskeyCounterAnomaly.into_response();
293
}
294
Err(e) => {
295
error!("Failed to update passkey counter: {:?}", e);
···
299
300
let _ = state
301
.user_repo
302
-
.delete_webauthn_challenge(&auth.0.did, "authentication")
303
.await;
304
305
-
match update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.0.did).await {
306
-
Ok(reauthed_at) => {
307
-
info!(did = %&auth.0.did, "Re-auth successful via passkey");
308
-
Json(ReauthResponse { reauthed_at }).into_response()
309
-
}
310
-
Err(e) => {
311
error!("DB error updating reauth: {:?}", e);
312
-
ApiError::InternalError(None).into_response()
313
-
}
314
-
}
315
}
316
317
pub async fn update_last_reauth_cached(
···
10
use tracing::{error, info, warn};
11
use tranquil_db_traits::{SessionRepository, UserRepository};
12
13
+
use crate::auth::{Active, Auth};
14
use crate::state::{AppState, RateLimitKind};
15
use crate::types::PlainPassword;
16
···
24
pub available_methods: Vec<String>,
25
}
26
27
+
pub async fn get_reauth_status(
28
+
State(state): State<AppState>,
29
+
auth: Auth<Active>,
30
+
) -> Result<Response, ApiError> {
31
+
let last_reauth_at = state
32
+
.session_repo
33
+
.get_last_reauth_at(&auth.did)
34
+
.await
35
+
.map_err(|e| {
36
error!("DB error: {:?}", e);
37
+
ApiError::InternalError(None)
38
+
})?;
39
40
let reauth_required = is_reauth_required(last_reauth_at);
41
let available_methods =
42
+
get_available_reauth_methods(&*state.user_repo, &*state.session_repo, &auth.did).await;
43
44
+
Ok(Json(ReauthStatusResponse {
45
last_reauth_at,
46
reauth_required,
47
available_methods,
48
})
49
+
.into_response())
50
}
51
52
#[derive(Deserialize)]
···
63
64
pub async fn reauth_password(
65
State(state): State<AppState>,
66
+
auth: Auth<Active>,
67
Json(input): Json<PasswordReauthInput>,
68
+
) -> Result<Response, ApiError> {
69
+
let password_hash = state
70
+
.user_repo
71
+
.get_password_hash_by_did(&auth.did)
72
+
.await
73
+
.map_err(|e| {
74
error!("DB error: {:?}", e);
75
+
ApiError::InternalError(None)
76
+
})?
77
+
.ok_or(ApiError::AccountNotFound)?;
78
79
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
80
81
if !password_valid {
82
let app_password_hashes = state
83
.session_repo
84
+
.get_app_password_hashes_by_did(&auth.did)
85
.await
86
.unwrap_or_default();
87
···
90
});
91
92
if !app_password_valid {
93
+
warn!(did = %&auth.did, "Re-auth failed: invalid password");
94
+
return Err(ApiError::InvalidPassword("Password is incorrect".into()));
95
}
96
}
97
98
+
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
99
+
.await
100
+
.map_err(|e| {
101
error!("DB error updating reauth: {:?}", e);
102
+
ApiError::InternalError(None)
103
+
})?;
104
+
105
+
info!(did = %&auth.did, "Re-auth successful via password");
106
+
Ok(Json(ReauthResponse { reauthed_at }).into_response())
107
}
108
109
#[derive(Deserialize)]
···
114
115
pub async fn reauth_totp(
116
State(state): State<AppState>,
117
+
auth: Auth<Active>,
118
Json(input): Json<TotpReauthInput>,
119
+
) -> Result<Response, ApiError> {
120
if !state
121
+
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
122
.await
123
{
124
+
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
125
+
return Err(ApiError::RateLimitExceeded(Some(
126
"Too many verification attempts. Please try again in a few minutes.".into(),
127
+
)));
128
}
129
130
let valid =
131
+
crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.did, &input.code)
132
.await;
133
134
if !valid {
135
+
warn!(did = %&auth.did, "Re-auth failed: invalid TOTP code");
136
+
return Err(ApiError::InvalidCode(Some(
137
+
"Invalid TOTP or backup code".into(),
138
+
)));
139
}
140
141
+
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
142
+
.await
143
+
.map_err(|e| {
144
error!("DB error updating reauth: {:?}", e);
145
+
ApiError::InternalError(None)
146
+
})?;
147
+
148
+
info!(did = %&auth.did, "Re-auth successful via TOTP");
149
+
Ok(Json(ReauthResponse { reauthed_at }).into_response())
150
}
151
152
#[derive(Serialize)]
···
155
pub options: serde_json::Value,
156
}
157
158
+
pub async fn reauth_passkey_start(
159
+
State(state): State<AppState>,
160
+
auth: Auth<Active>,
161
+
) -> Result<Response, ApiError> {
162
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
163
164
+
let stored_passkeys = state
165
+
.user_repo
166
+
.get_passkeys_for_user(&auth.did)
167
+
.await
168
+
.map_err(|e| {
169
error!("Failed to get passkeys: {:?}", e);
170
+
ApiError::InternalError(None)
171
+
})?;
172
173
if stored_passkeys.is_empty() {
174
+
return Err(ApiError::NoPasskeys);
175
}
176
177
let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys
···
180
.collect();
181
182
if passkeys.is_empty() {
183
+
return Err(ApiError::InternalError(Some(
184
+
"Failed to load passkeys".into(),
185
+
)));
186
}
187
188
+
let webauthn = crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname).map_err(|e| {
189
+
error!("Failed to create WebAuthn config: {:?}", e);
190
+
ApiError::InternalError(None)
191
+
})?;
192
193
+
let (rcr, auth_state) = webauthn.start_authentication(passkeys).map_err(|e| {
194
+
error!("Failed to start passkey authentication: {:?}", e);
195
+
ApiError::InternalError(None)
196
+
})?;
197
198
+
let state_json = serde_json::to_string(&auth_state).map_err(|e| {
199
+
error!("Failed to serialize authentication state: {:?}", e);
200
+
ApiError::InternalError(None)
201
+
})?;
202
203
+
state
204
.user_repo
205
+
.save_webauthn_challenge(&auth.did, "authentication", &state_json)
206
.await
207
+
.map_err(|e| {
208
+
error!("Failed to save authentication state: {:?}", e);
209
+
ApiError::InternalError(None)
210
+
})?;
211
212
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
213
+
Ok(Json(PasskeyReauthStartResponse { options }).into_response())
214
}
215
216
#[derive(Deserialize)]
···
221
222
pub async fn reauth_passkey_finish(
223
State(state): State<AppState>,
224
+
auth: Auth<Active>,
225
Json(input): Json<PasskeyReauthFinishInput>,
226
+
) -> Result<Response, ApiError> {
227
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
228
229
+
let auth_state_json = state
230
.user_repo
231
+
.load_webauthn_challenge(&auth.did, "authentication")
232
.await
233
+
.map_err(|e| {
234
error!("Failed to load authentication state: {:?}", e);
235
+
ApiError::InternalError(None)
236
+
})?
237
+
.ok_or(ApiError::NoChallengeInProgress)?;
238
239
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
240
+
serde_json::from_str(&auth_state_json).map_err(|e| {
241
+
error!("Failed to deserialize authentication state: {:?}", e);
242
+
ApiError::InternalError(None)
243
+
})?;
244
245
let credential: webauthn_rs::prelude::PublicKeyCredential =
246
+
serde_json::from_value(input.credential).map_err(|e| {
247
+
warn!("Failed to parse credential: {:?}", e);
248
+
ApiError::InvalidCredential
249
+
})?;
250
251
+
let webauthn = crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname).map_err(|e| {
252
+
error!("Failed to create WebAuthn config: {:?}", e);
253
+
ApiError::InternalError(None)
254
+
})?;
255
256
+
let auth_result = webauthn
257
+
.finish_authentication(&credential, &auth_state)
258
+
.map_err(|e| {
259
+
warn!(did = %&auth.did, "Passkey re-auth failed: {:?}", e);
260
+
ApiError::AuthenticationFailed(Some("Passkey authentication failed".into()))
261
+
})?;
262
263
let cred_id_bytes = auth_result.cred_id().as_ref();
264
match state
···
267
.await
268
{
269
Ok(false) => {
270
+
warn!(did = %&auth.did, "Passkey counter anomaly detected - possible cloned key");
271
let _ = state
272
.user_repo
273
+
.delete_webauthn_challenge(&auth.did, "authentication")
274
.await;
275
+
return Err(ApiError::PasskeyCounterAnomaly);
276
}
277
Err(e) => {
278
error!("Failed to update passkey counter: {:?}", e);
···
282
283
let _ = state
284
.user_repo
285
+
.delete_webauthn_challenge(&auth.did, "authentication")
286
.await;
287
288
+
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
289
+
.await
290
+
.map_err(|e| {
291
error!("DB error updating reauth: {:?}", e);
292
+
ApiError::InternalError(None)
293
+
})?;
294
+
295
+
info!(did = %&auth.did, "Re-auth successful via passkey");
296
+
Ok(Json(ReauthResponse { reauthed_at }).into_response())
297
}
298
299
pub async fn update_last_reauth_cached(
+4
-4
crates/tranquil-pds/src/api/server/service_auth.rs
+4
-4
crates/tranquil-pds/src/api/server/service_auth.rs
···
95
{
96
Ok(result) => crate::auth::AuthenticatedUser {
97
did: Did::new_unchecked(result.did),
98
-
is_oauth: true,
99
is_admin: false,
100
status: AccountStatus::Active,
101
scope: result.scope,
102
key_bytes: None,
103
controller_did: None,
104
},
105
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
106
return (
···
131
};
132
info!(
133
did = %&auth_user.did,
134
-
is_oauth = auth_user.is_oauth,
135
has_key = auth_user.key_bytes.is_some(),
136
"getServiceAuth auth validated"
137
);
···
180
181
if let Some(method) = lxm {
182
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
183
-
auth_user.is_oauth,
184
auth_user.scope.as_deref(),
185
¶ms.aud,
186
method,
187
) {
188
return e;
189
}
190
-
} else if auth_user.is_oauth {
191
let permissions = auth_user.permissions();
192
if !permissions.has_full_access() {
193
return ApiError::InvalidRequest(
···
95
{
96
Ok(result) => crate::auth::AuthenticatedUser {
97
did: Did::new_unchecked(result.did),
98
is_admin: false,
99
status: AccountStatus::Active,
100
scope: result.scope,
101
key_bytes: None,
102
controller_did: None,
103
+
auth_source: crate::auth::AuthSource::OAuth,
104
},
105
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
106
return (
···
131
};
132
info!(
133
did = %&auth_user.did,
134
+
is_oauth = auth_user.is_oauth(),
135
has_key = auth_user.key_bytes.is_some(),
136
"getServiceAuth auth validated"
137
);
···
180
181
if let Some(method) = lxm {
182
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
183
+
auth_user.is_oauth(),
184
auth_user.scope.as_deref(),
185
¶ms.aud,
186
method,
187
) {
188
return e;
189
}
190
+
} else if auth_user.is_oauth() {
191
let permissions = auth_user.permissions();
192
if !permissions.has_full_access() {
193
return ApiError::InvalidRequest(
+169
-174
crates/tranquil-pds/src/api/server/session.rs
+169
-174
crates/tranquil-pds/src/api/server/session.rs
···
1
use crate::api::error::ApiError;
2
use crate::api::{EmptyResponse, SuccessResponse};
3
-
use crate::auth::{BearerAuth, BearerAuthAllowDeactivated};
4
use crate::state::{AppState, RateLimitKind};
5
use crate::types::{AccountState, Did, Handle, PlainPassword};
6
use axum::{
···
279
280
pub async fn get_session(
281
State(state): State<AppState>,
282
-
BearerAuthAllowDeactivated(auth_user): BearerAuthAllowDeactivated,
283
-
) -> Response {
284
-
let permissions = auth_user.permissions();
285
let can_read_email = permissions.allows_email_read();
286
287
-
let did_for_doc = auth_user.did.clone();
288
let did_resolver = state.did_resolver.clone();
289
let (db_result, did_doc) = tokio::join!(
290
-
state.user_repo.get_session_info_by_did(&auth_user.did),
291
did_resolver.resolve_did_document(&did_for_doc)
292
);
293
match db_result {
···
316
let email_confirmed_value = can_read_email && row.email_verified;
317
let mut response = json!({
318
"handle": handle,
319
-
"did": &auth_user.did,
320
"active": account_state.is_active(),
321
"preferredChannel": preferred_channel,
322
"preferredChannelVerified": preferred_channel_verified,
···
337
if let Some(doc) = did_doc {
338
response["didDoc"] = doc;
339
}
340
-
Json(response).into_response()
341
}
342
-
Ok(None) => ApiError::AuthenticationFailed(None).into_response(),
343
Err(e) => {
344
error!("Database error in get_session: {:?}", e);
345
-
ApiError::InternalError(None).into_response()
346
}
347
}
348
}
···
350
pub async fn delete_session(
351
State(state): State<AppState>,
352
headers: axum::http::HeaderMap,
353
-
_auth: BearerAuth,
354
-
) -> Response {
355
-
let extracted = match crate::auth::extract_auth_token_from_header(
356
headers.get("Authorization").and_then(|h| h.to_str().ok()),
357
-
) {
358
-
Some(t) => t,
359
-
None => return ApiError::AuthenticationRequired.into_response(),
360
-
};
361
-
let jti = match crate::auth::get_jti_from_token(&extracted.token) {
362
-
Ok(jti) => jti,
363
-
Err(_) => return ApiError::AuthenticationFailed(None).into_response(),
364
-
};
365
let did = crate::auth::get_did_from_token(&extracted.token).ok();
366
match state.session_repo.delete_session_by_access_jti(&jti).await {
367
Ok(rows) if rows > 0 => {
···
369
let session_cache_key = format!("auth:session:{}:{}", did, jti);
370
let _ = state.cache.delete(&session_cache_key).await;
371
}
372
-
EmptyResponse::ok().into_response()
373
}
374
-
Ok(_) => ApiError::AuthenticationFailed(None).into_response(),
375
-
Err(_) => ApiError::AuthenticationFailed(None).into_response(),
376
}
377
}
378
···
796
pub async fn list_sessions(
797
State(state): State<AppState>,
798
headers: HeaderMap,
799
-
auth: BearerAuth,
800
-
) -> Response {
801
let current_jti = headers
802
.get("authorization")
803
.and_then(|v| v.to_str().ok())
804
.and_then(|v| v.strip_prefix("Bearer "))
805
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
806
807
-
let jwt_rows = match state.session_repo.list_sessions_by_did(&auth.0.did).await {
808
-
Ok(rows) => rows,
809
-
Err(e) => {
810
error!("DB error fetching JWT sessions: {:?}", e);
811
-
return ApiError::InternalError(None).into_response();
812
-
}
813
-
};
814
815
-
let oauth_rows = match state.oauth_repo.list_sessions_by_did(&auth.0.did).await {
816
-
Ok(rows) => rows,
817
-
Err(e) => {
818
error!("DB error fetching OAuth sessions: {:?}", e);
819
-
return ApiError::InternalError(None).into_response();
820
-
}
821
-
};
822
823
let jwt_sessions = jwt_rows.into_iter().map(|row| SessionInfo {
824
id: format!("jwt:{}", row.id),
···
829
is_current: current_jti.as_ref() == Some(&row.access_jti),
830
});
831
832
-
let is_oauth = auth.0.is_oauth;
833
let oauth_sessions = oauth_rows.into_iter().map(|row| {
834
let client_name = extract_client_name(&row.client_id);
835
let is_current_oauth = is_oauth && current_jti.as_deref() == Some(row.token_id.as_str());
···
846
let mut sessions: Vec<SessionInfo> = jwt_sessions.chain(oauth_sessions).collect();
847
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
848
849
-
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
850
}
851
852
fn extract_client_name(client_id: &str) -> String {
···
867
868
pub async fn revoke_session(
869
State(state): State<AppState>,
870
-
auth: BearerAuth,
871
Json(input): Json<RevokeSessionInput>,
872
-
) -> Response {
873
if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") {
874
-
let Ok(session_id) = jwt_id.parse::<i32>() else {
875
-
return ApiError::InvalidRequest("Invalid session ID".into()).into_response();
876
-
};
877
-
let access_jti = match state
878
.session_repo
879
-
.get_session_access_jti_by_id(session_id, &auth.0.did)
880
.await
881
-
{
882
-
Ok(Some(jti)) => jti,
883
-
Ok(None) => {
884
-
return ApiError::SessionNotFound.into_response();
885
-
}
886
-
Err(e) => {
887
error!("DB error in revoke_session: {:?}", e);
888
-
return ApiError::InternalError(None).into_response();
889
-
}
890
-
};
891
-
if let Err(e) = state.session_repo.delete_session_by_id(session_id).await {
892
-
error!("DB error deleting session: {:?}", e);
893
-
return ApiError::InternalError(None).into_response();
894
-
}
895
-
let cache_key = format!("auth:session:{}:{}", &auth.0.did, access_jti);
896
if let Err(e) = state.cache.delete(&cache_key).await {
897
warn!("Failed to invalidate session cache: {:?}", e);
898
}
899
-
info!(did = %&auth.0.did, session_id = %session_id, "JWT session revoked");
900
} else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") {
901
-
let Ok(session_id) = oauth_id.parse::<i32>() else {
902
-
return ApiError::InvalidRequest("Invalid session ID".into()).into_response();
903
-
};
904
-
match state
905
.oauth_repo
906
-
.delete_session_by_id(session_id, &auth.0.did)
907
.await
908
-
{
909
-
Ok(0) => {
910
-
return ApiError::SessionNotFound.into_response();
911
-
}
912
-
Err(e) => {
913
error!("DB error deleting OAuth session: {:?}", e);
914
-
return ApiError::InternalError(None).into_response();
915
-
}
916
-
_ => {}
917
}
918
-
info!(did = %&auth.0.did, session_id = %session_id, "OAuth session revoked");
919
} else {
920
-
return ApiError::InvalidRequest("Invalid session ID format".into()).into_response();
921
}
922
-
EmptyResponse::ok().into_response()
923
}
924
925
pub async fn revoke_all_sessions(
926
State(state): State<AppState>,
927
headers: HeaderMap,
928
-
auth: BearerAuth,
929
-
) -> Response {
930
-
let current_jti = crate::auth::extract_auth_token_from_header(
931
headers.get("authorization").and_then(|v| v.to_str().ok()),
932
)
933
-
.and_then(|extracted| crate::auth::get_jti_from_token(&extracted.token).ok());
934
935
-
let Some(ref jti) = current_jti else {
936
-
return ApiError::InvalidToken(None).into_response();
937
-
};
938
-
939
-
if auth.0.is_oauth {
940
-
if let Err(e) = state.session_repo.delete_sessions_by_did(&auth.0.did).await {
941
-
error!("DB error revoking JWT sessions: {:?}", e);
942
-
return ApiError::InternalError(None).into_response();
943
-
}
944
let jti_typed = TokenId::from(jti.clone());
945
-
if let Err(e) = state
946
.oauth_repo
947
-
.delete_sessions_by_did_except(&auth.0.did, &jti_typed)
948
.await
949
-
{
950
-
error!("DB error revoking OAuth sessions: {:?}", e);
951
-
return ApiError::InternalError(None).into_response();
952
-
}
953
} else {
954
-
if let Err(e) = state
955
.session_repo
956
-
.delete_sessions_by_did_except_jti(&auth.0.did, jti)
957
.await
958
-
{
959
-
error!("DB error revoking JWT sessions: {:?}", e);
960
-
return ApiError::InternalError(None).into_response();
961
-
}
962
-
if let Err(e) = state.oauth_repo.delete_sessions_by_did(&auth.0.did).await {
963
-
error!("DB error revoking OAuth sessions: {:?}", e);
964
-
return ApiError::InternalError(None).into_response();
965
-
}
966
}
967
968
-
info!(did = %&auth.0.did, "All other sessions revoked");
969
-
SuccessResponse::ok().into_response()
970
}
971
972
#[derive(Serialize)]
···
978
979
pub async fn get_legacy_login_preference(
980
State(state): State<AppState>,
981
-
auth: BearerAuth,
982
-
) -> Response {
983
-
match state.user_repo.get_legacy_login_pref(&auth.0.did).await {
984
-
Ok(Some(pref)) => Json(LegacyLoginPreferenceOutput {
985
-
allow_legacy_login: pref.allow_legacy_login,
986
-
has_mfa: pref.has_mfa,
987
-
})
988
-
.into_response(),
989
-
Ok(None) => ApiError::AccountNotFound.into_response(),
990
-
Err(e) => {
991
error!("DB error: {:?}", e);
992
-
ApiError::InternalError(None).into_response()
993
-
}
994
-
}
995
}
996
997
#[derive(Deserialize)]
···
1002
1003
pub async fn update_legacy_login_preference(
1004
State(state): State<AppState>,
1005
-
auth: BearerAuth,
1006
Json(input): Json<UpdateLegacyLoginInput>,
1007
-
) -> Response {
1008
-
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.0.did)
1009
-
.await
1010
{
1011
-
return crate::api::server::reauth::legacy_mfa_required_response(
1012
&*state.user_repo,
1013
&*state.session_repo,
1014
-
&auth.0.did,
1015
)
1016
-
.await;
1017
}
1018
1019
-
if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.0.did).await {
1020
-
return crate::api::server::reauth::reauth_required_response(
1021
&*state.user_repo,
1022
&*state.session_repo,
1023
-
&auth.0.did,
1024
)
1025
-
.await;
1026
}
1027
1028
-
match state
1029
.user_repo
1030
-
.update_legacy_login(&auth.0.did, input.allow_legacy_login)
1031
.await
1032
-
{
1033
-
Ok(true) => {
1034
-
info!(
1035
-
did = %&auth.0.did,
1036
-
allow_legacy_login = input.allow_legacy_login,
1037
-
"Legacy login preference updated"
1038
-
);
1039
-
Json(json!({
1040
-
"allowLegacyLogin": input.allow_legacy_login
1041
-
}))
1042
-
.into_response()
1043
-
}
1044
-
Ok(false) => ApiError::AccountNotFound.into_response(),
1045
-
Err(e) => {
1046
error!("DB error: {:?}", e);
1047
-
ApiError::InternalError(None).into_response()
1048
-
}
1049
}
1050
}
1051
1052
use crate::comms::VALID_LOCALES;
···
1059
1060
pub async fn update_locale(
1061
State(state): State<AppState>,
1062
-
auth: BearerAuth,
1063
Json(input): Json<UpdateLocaleInput>,
1064
-
) -> Response {
1065
if !VALID_LOCALES.contains(&input.preferred_locale.as_str()) {
1066
-
return ApiError::InvalidRequest(format!(
1067
"Invalid locale. Valid options: {}",
1068
VALID_LOCALES.join(", ")
1069
-
))
1070
-
.into_response();
1071
}
1072
1073
-
match state
1074
.user_repo
1075
-
.update_locale(&auth.0.did, &input.preferred_locale)
1076
.await
1077
-
{
1078
-
Ok(true) => {
1079
-
info!(
1080
-
did = %&auth.0.did,
1081
-
locale = %input.preferred_locale,
1082
-
"User locale preference updated"
1083
-
);
1084
-
Json(json!({
1085
-
"preferredLocale": input.preferred_locale
1086
-
}))
1087
-
.into_response()
1088
-
}
1089
-
Ok(false) => ApiError::AccountNotFound.into_response(),
1090
-
Err(e) => {
1091
error!("DB error updating locale: {:?}", e);
1092
-
ApiError::InternalError(None).into_response()
1093
-
}
1094
}
1095
}
···
1
use crate::api::error::ApiError;
2
use crate::api::{EmptyResponse, SuccessResponse};
3
+
use crate::auth::{Active, Auth, Permissive};
4
use crate::state::{AppState, RateLimitKind};
5
use crate::types::{AccountState, Did, Handle, PlainPassword};
6
use axum::{
···
279
280
pub async fn get_session(
281
State(state): State<AppState>,
282
+
auth: Auth<Permissive>,
283
+
) -> Result<Response, ApiError> {
284
+
let permissions = auth.permissions();
285
let can_read_email = permissions.allows_email_read();
286
287
+
let did_for_doc = auth.did.clone();
288
let did_resolver = state.did_resolver.clone();
289
let (db_result, did_doc) = tokio::join!(
290
+
state.user_repo.get_session_info_by_did(&auth.did),
291
did_resolver.resolve_did_document(&did_for_doc)
292
);
293
match db_result {
···
316
let email_confirmed_value = can_read_email && row.email_verified;
317
let mut response = json!({
318
"handle": handle,
319
+
"did": &auth.did,
320
"active": account_state.is_active(),
321
"preferredChannel": preferred_channel,
322
"preferredChannelVerified": preferred_channel_verified,
···
337
if let Some(doc) = did_doc {
338
response["didDoc"] = doc;
339
}
340
+
Ok(Json(response).into_response())
341
}
342
+
Ok(None) => Err(ApiError::AuthenticationFailed(None)),
343
Err(e) => {
344
error!("Database error in get_session: {:?}", e);
345
+
Err(ApiError::InternalError(None))
346
}
347
}
348
}
···
350
pub async fn delete_session(
351
State(state): State<AppState>,
352
headers: axum::http::HeaderMap,
353
+
_auth: Auth<Active>,
354
+
) -> Result<Response, ApiError> {
355
+
let extracted = crate::auth::extract_auth_token_from_header(
356
headers.get("Authorization").and_then(|h| h.to_str().ok()),
357
+
)
358
+
.ok_or(ApiError::AuthenticationRequired)?;
359
+
let jti = crate::auth::get_jti_from_token(&extracted.token)
360
+
.map_err(|_| ApiError::AuthenticationFailed(None))?;
361
let did = crate::auth::get_did_from_token(&extracted.token).ok();
362
match state.session_repo.delete_session_by_access_jti(&jti).await {
363
Ok(rows) if rows > 0 => {
···
365
let session_cache_key = format!("auth:session:{}:{}", did, jti);
366
let _ = state.cache.delete(&session_cache_key).await;
367
}
368
+
Ok(EmptyResponse::ok().into_response())
369
}
370
+
Ok(_) => Err(ApiError::AuthenticationFailed(None)),
371
+
Err(_) => Err(ApiError::AuthenticationFailed(None)),
372
}
373
}
374
···
792
pub async fn list_sessions(
793
State(state): State<AppState>,
794
headers: HeaderMap,
795
+
auth: Auth<Active>,
796
+
) -> Result<Response, ApiError> {
797
let current_jti = headers
798
.get("authorization")
799
.and_then(|v| v.to_str().ok())
800
.and_then(|v| v.strip_prefix("Bearer "))
801
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
802
803
+
let jwt_rows = state
804
+
.session_repo
805
+
.list_sessions_by_did(&auth.did)
806
+
.await
807
+
.map_err(|e| {
808
error!("DB error fetching JWT sessions: {:?}", e);
809
+
ApiError::InternalError(None)
810
+
})?;
811
812
+
let oauth_rows = state
813
+
.oauth_repo
814
+
.list_sessions_by_did(&auth.did)
815
+
.await
816
+
.map_err(|e| {
817
error!("DB error fetching OAuth sessions: {:?}", e);
818
+
ApiError::InternalError(None)
819
+
})?;
820
821
let jwt_sessions = jwt_rows.into_iter().map(|row| SessionInfo {
822
id: format!("jwt:{}", row.id),
···
827
is_current: current_jti.as_ref() == Some(&row.access_jti),
828
});
829
830
+
let is_oauth = auth.is_oauth();
831
let oauth_sessions = oauth_rows.into_iter().map(|row| {
832
let client_name = extract_client_name(&row.client_id);
833
let is_current_oauth = is_oauth && current_jti.as_deref() == Some(row.token_id.as_str());
···
844
let mut sessions: Vec<SessionInfo> = jwt_sessions.chain(oauth_sessions).collect();
845
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
846
847
+
Ok((StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response())
848
}
849
850
fn extract_client_name(client_id: &str) -> String {
···
865
866
pub async fn revoke_session(
867
State(state): State<AppState>,
868
+
auth: Auth<Active>,
869
Json(input): Json<RevokeSessionInput>,
870
+
) -> Result<Response, ApiError> {
871
if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") {
872
+
let session_id: i32 = jwt_id
873
+
.parse()
874
+
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
875
+
let access_jti = state
876
.session_repo
877
+
.get_session_access_jti_by_id(session_id, &auth.did)
878
.await
879
+
.map_err(|e| {
880
error!("DB error in revoke_session: {:?}", e);
881
+
ApiError::InternalError(None)
882
+
})?
883
+
.ok_or(ApiError::SessionNotFound)?;
884
+
state
885
+
.session_repo
886
+
.delete_session_by_id(session_id)
887
+
.await
888
+
.map_err(|e| {
889
+
error!("DB error deleting session: {:?}", e);
890
+
ApiError::InternalError(None)
891
+
})?;
892
+
let cache_key = format!("auth:session:{}:{}", &auth.did, access_jti);
893
if let Err(e) = state.cache.delete(&cache_key).await {
894
warn!("Failed to invalidate session cache: {:?}", e);
895
}
896
+
info!(did = %&auth.did, session_id = %session_id, "JWT session revoked");
897
} else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") {
898
+
let session_id: i32 = oauth_id
899
+
.parse()
900
+
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
901
+
let deleted = state
902
.oauth_repo
903
+
.delete_session_by_id(session_id, &auth.did)
904
.await
905
+
.map_err(|e| {
906
error!("DB error deleting OAuth session: {:?}", e);
907
+
ApiError::InternalError(None)
908
+
})?;
909
+
if deleted == 0 {
910
+
return Err(ApiError::SessionNotFound);
911
}
912
+
info!(did = %&auth.did, session_id = %session_id, "OAuth session revoked");
913
} else {
914
+
return Err(ApiError::InvalidRequest("Invalid session ID format".into()));
915
}
916
+
Ok(EmptyResponse::ok().into_response())
917
}
918
919
pub async fn revoke_all_sessions(
920
State(state): State<AppState>,
921
headers: HeaderMap,
922
+
auth: Auth<Active>,
923
+
) -> Result<Response, ApiError> {
924
+
let jti = crate::auth::extract_auth_token_from_header(
925
headers.get("authorization").and_then(|v| v.to_str().ok()),
926
)
927
+
.and_then(|extracted| crate::auth::get_jti_from_token(&extracted.token).ok())
928
+
.ok_or(ApiError::InvalidToken(None))?;
929
930
+
if auth.is_oauth() {
931
+
state
932
+
.session_repo
933
+
.delete_sessions_by_did(&auth.did)
934
+
.await
935
+
.map_err(|e| {
936
+
error!("DB error revoking JWT sessions: {:?}", e);
937
+
ApiError::InternalError(None)
938
+
})?;
939
let jti_typed = TokenId::from(jti.clone());
940
+
state
941
.oauth_repo
942
+
.delete_sessions_by_did_except(&auth.did, &jti_typed)
943
.await
944
+
.map_err(|e| {
945
+
error!("DB error revoking OAuth sessions: {:?}", e);
946
+
ApiError::InternalError(None)
947
+
})?;
948
} else {
949
+
state
950
.session_repo
951
+
.delete_sessions_by_did_except_jti(&auth.did, &jti)
952
+
.await
953
+
.map_err(|e| {
954
+
error!("DB error revoking JWT sessions: {:?}", e);
955
+
ApiError::InternalError(None)
956
+
})?;
957
+
state
958
+
.oauth_repo
959
+
.delete_sessions_by_did(&auth.did)
960
.await
961
+
.map_err(|e| {
962
+
error!("DB error revoking OAuth sessions: {:?}", e);
963
+
ApiError::InternalError(None)
964
+
})?;
965
}
966
967
+
info!(did = %&auth.did, "All other sessions revoked");
968
+
Ok(SuccessResponse::ok().into_response())
969
}
970
971
#[derive(Serialize)]
···
977
978
pub async fn get_legacy_login_preference(
979
State(state): State<AppState>,
980
+
auth: Auth<Active>,
981
+
) -> Result<Response, ApiError> {
982
+
let pref = state
983
+
.user_repo
984
+
.get_legacy_login_pref(&auth.did)
985
+
.await
986
+
.map_err(|e| {
987
error!("DB error: {:?}", e);
988
+
ApiError::InternalError(None)
989
+
})?
990
+
.ok_or(ApiError::AccountNotFound)?;
991
+
Ok(Json(LegacyLoginPreferenceOutput {
992
+
allow_legacy_login: pref.allow_legacy_login,
993
+
has_mfa: pref.has_mfa,
994
+
})
995
+
.into_response())
996
}
997
998
#[derive(Deserialize)]
···
1003
1004
pub async fn update_legacy_login_preference(
1005
State(state): State<AppState>,
1006
+
auth: Auth<Active>,
1007
Json(input): Json<UpdateLegacyLoginInput>,
1008
+
) -> Result<Response, ApiError> {
1009
+
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
1010
{
1011
+
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
1012
&*state.user_repo,
1013
&*state.session_repo,
1014
+
&auth.did,
1015
)
1016
+
.await);
1017
}
1018
1019
+
if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.did).await {
1020
+
return Ok(crate::api::server::reauth::reauth_required_response(
1021
&*state.user_repo,
1022
&*state.session_repo,
1023
+
&auth.did,
1024
)
1025
+
.await);
1026
}
1027
1028
+
let updated = state
1029
.user_repo
1030
+
.update_legacy_login(&auth.did, input.allow_legacy_login)
1031
.await
1032
+
.map_err(|e| {
1033
error!("DB error: {:?}", e);
1034
+
ApiError::InternalError(None)
1035
+
})?;
1036
+
if !updated {
1037
+
return Err(ApiError::AccountNotFound);
1038
}
1039
+
info!(
1040
+
did = %&auth.did,
1041
+
allow_legacy_login = input.allow_legacy_login,
1042
+
"Legacy login preference updated"
1043
+
);
1044
+
Ok(Json(json!({
1045
+
"allowLegacyLogin": input.allow_legacy_login
1046
+
}))
1047
+
.into_response())
1048
}
1049
1050
use crate::comms::VALID_LOCALES;
···
1057
1058
pub async fn update_locale(
1059
State(state): State<AppState>,
1060
+
auth: Auth<Active>,
1061
Json(input): Json<UpdateLocaleInput>,
1062
+
) -> Result<Response, ApiError> {
1063
if !VALID_LOCALES.contains(&input.preferred_locale.as_str()) {
1064
+
return Err(ApiError::InvalidRequest(format!(
1065
"Invalid locale. Valid options: {}",
1066
VALID_LOCALES.join(", ")
1067
+
)));
1068
}
1069
1070
+
let updated = state
1071
.user_repo
1072
+
.update_locale(&auth.did, &input.preferred_locale)
1073
.await
1074
+
.map_err(|e| {
1075
error!("DB error updating locale: {:?}", e);
1076
+
ApiError::InternalError(None)
1077
+
})?;
1078
+
if !updated {
1079
+
return Err(ApiError::AccountNotFound);
1080
}
1081
+
info!(
1082
+
did = %&auth.did,
1083
+
locale = %input.preferred_locale,
1084
+
"User locale preference updated"
1085
+
);
1086
+
Ok(Json(json!({
1087
+
"preferredLocale": input.preferred_locale
1088
+
}))
1089
+
.into_response())
1090
}
+161
-161
crates/tranquil-pds/src/api/server/totp.rs
+161
-161
crates/tranquil-pds/src/api/server/totp.rs
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
-
use crate::auth::BearerAuth;
4
use crate::auth::{
5
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
6
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
···
26
pub qr_base64: String,
27
}
28
29
-
pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response {
30
-
match state.user_repo.get_totp_record(&auth.0.did).await {
31
-
Ok(Some(record)) if record.verified => return ApiError::TotpAlreadyEnabled.into_response(),
32
Ok(_) => {}
33
Err(e) => {
34
error!("DB error checking TOTP: {:?}", e);
35
-
return ApiError::InternalError(None).into_response();
36
}
37
}
38
39
let secret = generate_totp_secret();
40
41
-
let handle = match state.user_repo.get_handle_by_did(&auth.0.did).await {
42
-
Ok(Some(h)) => h,
43
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
44
-
Err(e) => {
45
error!("DB error fetching handle: {:?}", e);
46
-
return ApiError::InternalError(None).into_response();
47
-
}
48
-
};
49
50
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
51
let uri = generate_totp_uri(&secret, &handle, &hostname);
52
53
-
let qr_code = match generate_qr_png_base64(&secret, &handle, &hostname) {
54
-
Ok(qr) => qr,
55
-
Err(e) => {
56
-
error!("Failed to generate QR code: {:?}", e);
57
-
return ApiError::InternalError(Some("Failed to generate QR code".into()))
58
-
.into_response();
59
-
}
60
-
};
61
62
-
let encrypted_secret = match encrypt_totp_secret(&secret) {
63
-
Ok(enc) => enc,
64
-
Err(e) => {
65
-
error!("Failed to encrypt TOTP secret: {:?}", e);
66
-
return ApiError::InternalError(None).into_response();
67
-
}
68
-
};
69
70
-
if let Err(e) = state
71
.user_repo
72
-
.upsert_totp_secret(&auth.0.did, &encrypted_secret, ENCRYPTION_VERSION)
73
.await
74
-
{
75
-
error!("Failed to store TOTP secret: {:?}", e);
76
-
return ApiError::InternalError(None).into_response();
77
-
}
78
79
let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
80
81
-
info!(did = %&auth.0.did, "TOTP secret created (pending verification)");
82
83
-
Json(CreateTotpSecretResponse {
84
secret: secret_base32,
85
uri,
86
qr_base64: qr_code,
87
})
88
-
.into_response()
89
}
90
91
#[derive(Deserialize)]
···
101
102
pub async fn enable_totp(
103
State(state): State<AppState>,
104
-
auth: BearerAuth,
105
Json(input): Json<EnableTotpInput>,
106
-
) -> Response {
107
if !state
108
-
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
109
.await
110
{
111
-
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
112
-
return ApiError::RateLimitExceeded(None).into_response();
113
}
114
115
-
let totp_record = match state.user_repo.get_totp_record(&auth.0.did).await {
116
Ok(Some(row)) => row,
117
-
Ok(None) => return ApiError::TotpNotEnabled.into_response(),
118
Err(e) => {
119
error!("DB error fetching TOTP: {:?}", e);
120
-
return ApiError::InternalError(None).into_response();
121
}
122
};
123
124
if totp_record.verified {
125
-
return ApiError::TotpAlreadyEnabled.into_response();
126
}
127
128
-
let secret = match decrypt_totp_secret(
129
&totp_record.secret_encrypted,
130
totp_record.encryption_version,
131
-
) {
132
-
Ok(s) => s,
133
-
Err(e) => {
134
-
error!("Failed to decrypt TOTP secret: {:?}", e);
135
-
return ApiError::InternalError(None).into_response();
136
-
}
137
-
};
138
139
let code = input.code.trim();
140
if !verify_totp_code(&secret, code) {
141
-
return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
142
}
143
144
let backup_codes = generate_backup_codes();
145
-
let backup_hashes: Result<Vec<_>, _> =
146
-
backup_codes.iter().map(|c| hash_backup_code(c)).collect();
147
-
let backup_hashes = match backup_hashes {
148
-
Ok(hashes) => hashes,
149
-
Err(e) => {
150
error!("Failed to hash backup code: {:?}", e);
151
-
return ApiError::InternalError(None).into_response();
152
-
}
153
-
};
154
155
-
if let Err(e) = state
156
.user_repo
157
-
.enable_totp_with_backup_codes(&auth.0.did, &backup_hashes)
158
.await
159
-
{
160
-
error!("Failed to enable TOTP: {:?}", e);
161
-
return ApiError::InternalError(None).into_response();
162
-
}
163
164
-
info!(did = %&auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
165
166
-
Json(EnableTotpResponse { backup_codes }).into_response()
167
}
168
169
#[derive(Deserialize)]
···
174
175
pub async fn disable_totp(
176
State(state): State<AppState>,
177
-
auth: BearerAuth,
178
Json(input): Json<DisableTotpInput>,
179
-
) -> Response {
180
-
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.0.did)
181
-
.await
182
{
183
-
return crate::api::server::reauth::legacy_mfa_required_response(
184
&*state.user_repo,
185
&*state.session_repo,
186
-
&auth.0.did,
187
)
188
-
.await;
189
}
190
191
if !state
192
-
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
193
.await
194
{
195
-
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
196
-
return ApiError::RateLimitExceeded(None).into_response();
197
}
198
199
-
let password_hash = match state.user_repo.get_password_hash_by_did(&auth.0.did).await {
200
-
Ok(Some(hash)) => hash,
201
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
202
-
Err(e) => {
203
error!("DB error fetching user: {:?}", e);
204
-
return ApiError::InternalError(None).into_response();
205
-
}
206
-
};
207
208
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
209
if !password_valid {
210
-
return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
211
}
212
213
-
let totp_record = match state.user_repo.get_totp_record(&auth.0.did).await {
214
Ok(Some(row)) if row.verified => row,
215
-
Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(),
216
Err(e) => {
217
error!("DB error fetching TOTP: {:?}", e);
218
-
return ApiError::InternalError(None).into_response();
219
}
220
};
221
222
let code = input.code.trim();
223
let code_valid = if is_backup_code_format(code) {
224
-
verify_backup_code_for_user(&state, &auth.0.did, code).await
225
} else {
226
-
let secret = match decrypt_totp_secret(
227
&totp_record.secret_encrypted,
228
totp_record.encryption_version,
229
-
) {
230
-
Ok(s) => s,
231
-
Err(e) => {
232
-
error!("Failed to decrypt TOTP secret: {:?}", e);
233
-
return ApiError::InternalError(None).into_response();
234
-
}
235
-
};
236
verify_totp_code(&secret, code)
237
};
238
239
if !code_valid {
240
-
return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
241
}
242
243
-
if let Err(e) = state
244
.user_repo
245
-
.delete_totp_and_backup_codes(&auth.0.did)
246
.await
247
-
{
248
-
error!("Failed to delete TOTP: {:?}", e);
249
-
return ApiError::InternalError(None).into_response();
250
-
}
251
252
-
info!(did = %&auth.0.did, "TOTP disabled");
253
254
-
EmptyResponse::ok().into_response()
255
}
256
257
#[derive(Serialize)]
···
262
pub backup_codes_remaining: i64,
263
}
264
265
-
pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
266
-
let enabled = match state.user_repo.get_totp_record(&auth.0.did).await {
267
Ok(Some(row)) => row.verified,
268
Ok(None) => false,
269
Err(e) => {
270
error!("DB error fetching TOTP status: {:?}", e);
271
-
return ApiError::InternalError(None).into_response();
272
}
273
};
274
275
-
let backup_count = match state.user_repo.count_unused_backup_codes(&auth.0.did).await {
276
-
Ok(count) => count,
277
-
Err(e) => {
278
error!("DB error counting backup codes: {:?}", e);
279
-
return ApiError::InternalError(None).into_response();
280
-
}
281
-
};
282
283
-
Json(GetTotpStatusResponse {
284
enabled,
285
has_backup_codes: backup_count > 0,
286
backup_codes_remaining: backup_count,
287
})
288
-
.into_response()
289
}
290
291
#[derive(Deserialize)]
···
302
303
pub async fn regenerate_backup_codes(
304
State(state): State<AppState>,
305
-
auth: BearerAuth,
306
Json(input): Json<RegenerateBackupCodesInput>,
307
-
) -> Response {
308
if !state
309
-
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
310
.await
311
{
312
-
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
313
-
return ApiError::RateLimitExceeded(None).into_response();
314
}
315
316
-
let password_hash = match state.user_repo.get_password_hash_by_did(&auth.0.did).await {
317
-
Ok(Some(hash)) => hash,
318
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
319
-
Err(e) => {
320
error!("DB error fetching user: {:?}", e);
321
-
return ApiError::InternalError(None).into_response();
322
-
}
323
-
};
324
325
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
326
if !password_valid {
327
-
return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
328
}
329
330
-
let totp_record = match state.user_repo.get_totp_record(&auth.0.did).await {
331
Ok(Some(row)) if row.verified => row,
332
-
Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(),
333
Err(e) => {
334
error!("DB error fetching TOTP: {:?}", e);
335
-
return ApiError::InternalError(None).into_response();
336
}
337
};
338
339
-
let secret = match decrypt_totp_secret(
340
&totp_record.secret_encrypted,
341
totp_record.encryption_version,
342
-
) {
343
-
Ok(s) => s,
344
-
Err(e) => {
345
-
error!("Failed to decrypt TOTP secret: {:?}", e);
346
-
return ApiError::InternalError(None).into_response();
347
-
}
348
-
};
349
350
let code = input.code.trim();
351
if !verify_totp_code(&secret, code) {
352
-
return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
353
}
354
355
let backup_codes = generate_backup_codes();
356
-
let backup_hashes: Result<Vec<_>, _> =
357
-
backup_codes.iter().map(|c| hash_backup_code(c)).collect();
358
-
let backup_hashes = match backup_hashes {
359
-
Ok(hashes) => hashes,
360
-
Err(e) => {
361
error!("Failed to hash backup code: {:?}", e);
362
-
return ApiError::InternalError(None).into_response();
363
-
}
364
-
};
365
366
-
if let Err(e) = state
367
.user_repo
368
-
.replace_backup_codes(&auth.0.did, &backup_hashes)
369
.await
370
-
{
371
-
error!("Failed to regenerate backup codes: {:?}", e);
372
-
return ApiError::InternalError(None).into_response();
373
-
}
374
375
-
info!(did = %&auth.0.did, "Backup codes regenerated");
376
377
-
Json(RegenerateBackupCodesResponse { backup_codes }).into_response()
378
}
379
380
async fn verify_backup_code_for_user(
···
1
use crate::api::EmptyResponse;
2
use crate::api::error::ApiError;
3
+
use crate::auth::{Active, Auth};
4
use crate::auth::{
5
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
6
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
···
26
pub qr_base64: String,
27
}
28
29
+
pub async fn create_totp_secret(
30
+
State(state): State<AppState>,
31
+
auth: Auth<Active>,
32
+
) -> Result<Response, ApiError> {
33
+
match state.user_repo.get_totp_record(&auth.did).await {
34
+
Ok(Some(record)) if record.verified => return Err(ApiError::TotpAlreadyEnabled),
35
Ok(_) => {}
36
Err(e) => {
37
error!("DB error checking TOTP: {:?}", e);
38
+
return Err(ApiError::InternalError(None));
39
}
40
}
41
42
let secret = generate_totp_secret();
43
44
+
let handle = state
45
+
.user_repo
46
+
.get_handle_by_did(&auth.did)
47
+
.await
48
+
.map_err(|e| {
49
error!("DB error fetching handle: {:?}", e);
50
+
ApiError::InternalError(None)
51
+
})?
52
+
.ok_or(ApiError::AccountNotFound)?;
53
54
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
55
let uri = generate_totp_uri(&secret, &handle, &hostname);
56
57
+
let qr_code = generate_qr_png_base64(&secret, &handle, &hostname).map_err(|e| {
58
+
error!("Failed to generate QR code: {:?}", e);
59
+
ApiError::InternalError(Some("Failed to generate QR code".into()))
60
+
})?;
61
62
+
let encrypted_secret = encrypt_totp_secret(&secret).map_err(|e| {
63
+
error!("Failed to encrypt TOTP secret: {:?}", e);
64
+
ApiError::InternalError(None)
65
+
})?;
66
67
+
state
68
.user_repo
69
+
.upsert_totp_secret(&auth.did, &encrypted_secret, ENCRYPTION_VERSION)
70
.await
71
+
.map_err(|e| {
72
+
error!("Failed to store TOTP secret: {:?}", e);
73
+
ApiError::InternalError(None)
74
+
})?;
75
76
let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
77
78
+
info!(did = %&auth.did, "TOTP secret created (pending verification)");
79
80
+
Ok(Json(CreateTotpSecretResponse {
81
secret: secret_base32,
82
uri,
83
qr_base64: qr_code,
84
})
85
+
.into_response())
86
}
87
88
#[derive(Deserialize)]
···
98
99
pub async fn enable_totp(
100
State(state): State<AppState>,
101
+
auth: Auth<Active>,
102
Json(input): Json<EnableTotpInput>,
103
+
) -> Result<Response, ApiError> {
104
if !state
105
+
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
106
.await
107
{
108
+
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
109
+
return Err(ApiError::RateLimitExceeded(None));
110
}
111
112
+
let totp_record = match state.user_repo.get_totp_record(&auth.did).await {
113
Ok(Some(row)) => row,
114
+
Ok(None) => return Err(ApiError::TotpNotEnabled),
115
Err(e) => {
116
error!("DB error fetching TOTP: {:?}", e);
117
+
return Err(ApiError::InternalError(None));
118
}
119
};
120
121
if totp_record.verified {
122
+
return Err(ApiError::TotpAlreadyEnabled);
123
}
124
125
+
let secret = decrypt_totp_secret(
126
&totp_record.secret_encrypted,
127
totp_record.encryption_version,
128
+
)
129
+
.map_err(|e| {
130
+
error!("Failed to decrypt TOTP secret: {:?}", e);
131
+
ApiError::InternalError(None)
132
+
})?;
133
134
let code = input.code.trim();
135
if !verify_totp_code(&secret, code) {
136
+
return Err(ApiError::InvalidCode(Some(
137
+
"Invalid verification code".into(),
138
+
)));
139
}
140
141
let backup_codes = generate_backup_codes();
142
+
let backup_hashes: Vec<_> = backup_codes
143
+
.iter()
144
+
.map(|c| hash_backup_code(c))
145
+
.collect::<Result<Vec<_>, _>>()
146
+
.map_err(|e| {
147
error!("Failed to hash backup code: {:?}", e);
148
+
ApiError::InternalError(None)
149
+
})?;
150
151
+
state
152
.user_repo
153
+
.enable_totp_with_backup_codes(&auth.did, &backup_hashes)
154
.await
155
+
.map_err(|e| {
156
+
error!("Failed to enable TOTP: {:?}", e);
157
+
ApiError::InternalError(None)
158
+
})?;
159
160
+
info!(did = %&auth.did, "TOTP enabled with {} backup codes", backup_codes.len());
161
162
+
Ok(Json(EnableTotpResponse { backup_codes }).into_response())
163
}
164
165
#[derive(Deserialize)]
···
170
171
pub async fn disable_totp(
172
State(state): State<AppState>,
173
+
auth: Auth<Active>,
174
Json(input): Json<DisableTotpInput>,
175
+
) -> Result<Response, ApiError> {
176
+
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
177
{
178
+
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
179
&*state.user_repo,
180
&*state.session_repo,
181
+
&auth.did,
182
)
183
+
.await);
184
}
185
186
if !state
187
+
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
188
.await
189
{
190
+
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
191
+
return Err(ApiError::RateLimitExceeded(None));
192
}
193
194
+
let password_hash = state
195
+
.user_repo
196
+
.get_password_hash_by_did(&auth.did)
197
+
.await
198
+
.map_err(|e| {
199
error!("DB error fetching user: {:?}", e);
200
+
ApiError::InternalError(None)
201
+
})?
202
+
.ok_or(ApiError::AccountNotFound)?;
203
204
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
205
if !password_valid {
206
+
return Err(ApiError::InvalidPassword("Password is incorrect".into()));
207
}
208
209
+
let totp_record = match state.user_repo.get_totp_record(&auth.did).await {
210
Ok(Some(row)) if row.verified => row,
211
+
Ok(Some(_)) | Ok(None) => return Err(ApiError::TotpNotEnabled),
212
Err(e) => {
213
error!("DB error fetching TOTP: {:?}", e);
214
+
return Err(ApiError::InternalError(None));
215
}
216
};
217
218
let code = input.code.trim();
219
let code_valid = if is_backup_code_format(code) {
220
+
verify_backup_code_for_user(&state, &auth.did, code).await
221
} else {
222
+
let secret = decrypt_totp_secret(
223
&totp_record.secret_encrypted,
224
totp_record.encryption_version,
225
+
)
226
+
.map_err(|e| {
227
+
error!("Failed to decrypt TOTP secret: {:?}", e);
228
+
ApiError::InternalError(None)
229
+
})?;
230
verify_totp_code(&secret, code)
231
};
232
233
if !code_valid {
234
+
return Err(ApiError::InvalidCode(Some(
235
+
"Invalid verification code".into(),
236
+
)));
237
}
238
239
+
state
240
.user_repo
241
+
.delete_totp_and_backup_codes(&auth.did)
242
.await
243
+
.map_err(|e| {
244
+
error!("Failed to delete TOTP: {:?}", e);
245
+
ApiError::InternalError(None)
246
+
})?;
247
248
+
info!(did = %&auth.did, "TOTP disabled");
249
250
+
Ok(EmptyResponse::ok().into_response())
251
}
252
253
#[derive(Serialize)]
···
258
pub backup_codes_remaining: i64,
259
}
260
261
+
pub async fn get_totp_status(
262
+
State(state): State<AppState>,
263
+
auth: Auth<Active>,
264
+
) -> Result<Response, ApiError> {
265
+
let enabled = match state.user_repo.get_totp_record(&auth.did).await {
266
Ok(Some(row)) => row.verified,
267
Ok(None) => false,
268
Err(e) => {
269
error!("DB error fetching TOTP status: {:?}", e);
270
+
return Err(ApiError::InternalError(None));
271
}
272
};
273
274
+
let backup_count = state
275
+
.user_repo
276
+
.count_unused_backup_codes(&auth.did)
277
+
.await
278
+
.map_err(|e| {
279
error!("DB error counting backup codes: {:?}", e);
280
+
ApiError::InternalError(None)
281
+
})?;
282
283
+
Ok(Json(GetTotpStatusResponse {
284
enabled,
285
has_backup_codes: backup_count > 0,
286
backup_codes_remaining: backup_count,
287
})
288
+
.into_response())
289
}
290
291
#[derive(Deserialize)]
···
302
303
pub async fn regenerate_backup_codes(
304
State(state): State<AppState>,
305
+
auth: Auth<Active>,
306
Json(input): Json<RegenerateBackupCodesInput>,
307
+
) -> Result<Response, ApiError> {
308
if !state
309
+
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
310
.await
311
{
312
+
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
313
+
return Err(ApiError::RateLimitExceeded(None));
314
}
315
316
+
let password_hash = state
317
+
.user_repo
318
+
.get_password_hash_by_did(&auth.did)
319
+
.await
320
+
.map_err(|e| {
321
error!("DB error fetching user: {:?}", e);
322
+
ApiError::InternalError(None)
323
+
})?
324
+
.ok_or(ApiError::AccountNotFound)?;
325
326
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
327
if !password_valid {
328
+
return Err(ApiError::InvalidPassword("Password is incorrect".into()));
329
}
330
331
+
let totp_record = match state.user_repo.get_totp_record(&auth.did).await {
332
Ok(Some(row)) if row.verified => row,
333
+
Ok(Some(_)) | Ok(None) => return Err(ApiError::TotpNotEnabled),
334
Err(e) => {
335
error!("DB error fetching TOTP: {:?}", e);
336
+
return Err(ApiError::InternalError(None));
337
}
338
};
339
340
+
let secret = decrypt_totp_secret(
341
&totp_record.secret_encrypted,
342
totp_record.encryption_version,
343
+
)
344
+
.map_err(|e| {
345
+
error!("Failed to decrypt TOTP secret: {:?}", e);
346
+
ApiError::InternalError(None)
347
+
})?;
348
349
let code = input.code.trim();
350
if !verify_totp_code(&secret, code) {
351
+
return Err(ApiError::InvalidCode(Some(
352
+
"Invalid verification code".into(),
353
+
)));
354
}
355
356
let backup_codes = generate_backup_codes();
357
+
let backup_hashes: Vec<_> = backup_codes
358
+
.iter()
359
+
.map(|c| hash_backup_code(c))
360
+
.collect::<Result<Vec<_>, _>>()
361
+
.map_err(|e| {
362
error!("Failed to hash backup code: {:?}", e);
363
+
ApiError::InternalError(None)
364
+
})?;
365
366
+
state
367
.user_repo
368
+
.replace_backup_codes(&auth.did, &backup_hashes)
369
.await
370
+
.map_err(|e| {
371
+
error!("Failed to regenerate backup codes: {:?}", e);
372
+
ApiError::InternalError(None)
373
+
})?;
374
375
+
info!(did = %&auth.did, "Backup codes regenerated");
376
377
+
Ok(Json(RegenerateBackupCodesResponse { backup_codes }).into_response())
378
}
379
380
async fn verify_backup_code_for_user(
+57
-55
crates/tranquil-pds/src/api/server/trusted_devices.rs
+57
-55
crates/tranquil-pds/src/api/server/trusted_devices.rs
···
11
use tranquil_db_traits::OAuthRepository;
12
use tranquil_types::DeviceId;
13
14
-
use crate::auth::BearerAuth;
15
use crate::state::AppState;
16
17
const TRUST_DURATION_DAYS: i64 = 30;
···
71
pub devices: Vec<TrustedDevice>,
72
}
73
74
-
pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAuth) -> Response {
75
-
match state.oauth_repo.list_trusted_devices(&auth.0.did).await {
76
-
Ok(rows) => {
77
-
let devices = rows
78
-
.into_iter()
79
-
.map(|row| {
80
-
let trust_state =
81
-
DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until);
82
-
TrustedDevice {
83
-
id: row.id,
84
-
user_agent: row.user_agent,
85
-
friendly_name: row.friendly_name,
86
-
trusted_at: row.trusted_at,
87
-
trusted_until: row.trusted_until,
88
-
last_seen_at: row.last_seen_at,
89
-
trust_state,
90
-
}
91
-
})
92
-
.collect();
93
-
Json(ListTrustedDevicesResponse { devices }).into_response()
94
-
}
95
-
Err(e) => {
96
error!("DB error: {:?}", e);
97
-
ApiError::InternalError(None).into_response()
98
-
}
99
-
}
100
}
101
102
#[derive(Deserialize)]
···
107
108
pub async fn revoke_trusted_device(
109
State(state): State<AppState>,
110
-
auth: BearerAuth,
111
Json(input): Json<RevokeTrustedDeviceInput>,
112
-
) -> Response {
113
let device_id = DeviceId::from(input.device_id.clone());
114
match state
115
.oauth_repo
116
-
.device_belongs_to_user(&device_id, &auth.0.did)
117
.await
118
{
119
Ok(true) => {}
120
Ok(false) => {
121
-
return ApiError::DeviceNotFound.into_response();
122
}
123
Err(e) => {
124
error!("DB error: {:?}", e);
125
-
return ApiError::InternalError(None).into_response();
126
}
127
}
128
129
-
match state.oauth_repo.revoke_device_trust(&device_id).await {
130
-
Ok(()) => {
131
-
info!(did = %&auth.0.did, device_id = %input.device_id, "Trusted device revoked");
132
-
SuccessResponse::ok().into_response()
133
-
}
134
-
Err(e) => {
135
error!("DB error: {:?}", e);
136
-
ApiError::InternalError(None).into_response()
137
-
}
138
-
}
139
}
140
141
#[derive(Deserialize)]
···
147
148
pub async fn update_trusted_device(
149
State(state): State<AppState>,
150
-
auth: BearerAuth,
151
Json(input): Json<UpdateTrustedDeviceInput>,
152
-
) -> Response {
153
let device_id = DeviceId::from(input.device_id.clone());
154
match state
155
.oauth_repo
156
-
.device_belongs_to_user(&device_id, &auth.0.did)
157
.await
158
{
159
Ok(true) => {}
160
Ok(false) => {
161
-
return ApiError::DeviceNotFound.into_response();
162
}
163
Err(e) => {
164
error!("DB error: {:?}", e);
165
-
return ApiError::InternalError(None).into_response();
166
}
167
}
168
169
-
match state
170
.oauth_repo
171
.update_device_friendly_name(&device_id, input.friendly_name.as_deref())
172
.await
173
-
{
174
-
Ok(()) => {
175
-
info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated");
176
-
SuccessResponse::ok().into_response()
177
-
}
178
-
Err(e) => {
179
error!("DB error: {:?}", e);
180
-
ApiError::InternalError(None).into_response()
181
-
}
182
-
}
183
}
184
185
pub async fn get_device_trust_state(
···
11
use tranquil_db_traits::OAuthRepository;
12
use tranquil_types::DeviceId;
13
14
+
use crate::auth::{Active, Auth};
15
use crate::state::AppState;
16
17
const TRUST_DURATION_DAYS: i64 = 30;
···
71
pub devices: Vec<TrustedDevice>,
72
}
73
74
+
pub async fn list_trusted_devices(
75
+
State(state): State<AppState>,
76
+
auth: Auth<Active>,
77
+
) -> Result<Response, ApiError> {
78
+
let rows = state
79
+
.oauth_repo
80
+
.list_trusted_devices(&auth.did)
81
+
.await
82
+
.map_err(|e| {
83
error!("DB error: {:?}", e);
84
+
ApiError::InternalError(None)
85
+
})?;
86
+
87
+
let devices = rows
88
+
.into_iter()
89
+
.map(|row| {
90
+
let trust_state = DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until);
91
+
TrustedDevice {
92
+
id: row.id,
93
+
user_agent: row.user_agent,
94
+
friendly_name: row.friendly_name,
95
+
trusted_at: row.trusted_at,
96
+
trusted_until: row.trusted_until,
97
+
last_seen_at: row.last_seen_at,
98
+
trust_state,
99
+
}
100
+
})
101
+
.collect();
102
+
103
+
Ok(Json(ListTrustedDevicesResponse { devices }).into_response())
104
}
105
106
#[derive(Deserialize)]
···
111
112
pub async fn revoke_trusted_device(
113
State(state): State<AppState>,
114
+
auth: Auth<Active>,
115
Json(input): Json<RevokeTrustedDeviceInput>,
116
+
) -> Result<Response, ApiError> {
117
let device_id = DeviceId::from(input.device_id.clone());
118
match state
119
.oauth_repo
120
+
.device_belongs_to_user(&device_id, &auth.did)
121
.await
122
{
123
Ok(true) => {}
124
Ok(false) => {
125
+
return Err(ApiError::DeviceNotFound);
126
}
127
Err(e) => {
128
error!("DB error: {:?}", e);
129
+
return Err(ApiError::InternalError(None));
130
}
131
}
132
133
+
state
134
+
.oauth_repo
135
+
.revoke_device_trust(&device_id)
136
+
.await
137
+
.map_err(|e| {
138
error!("DB error: {:?}", e);
139
+
ApiError::InternalError(None)
140
+
})?;
141
+
142
+
info!(did = %&auth.did, device_id = %input.device_id, "Trusted device revoked");
143
+
Ok(SuccessResponse::ok().into_response())
144
}
145
146
#[derive(Deserialize)]
···
152
153
pub async fn update_trusted_device(
154
State(state): State<AppState>,
155
+
auth: Auth<Active>,
156
Json(input): Json<UpdateTrustedDeviceInput>,
157
+
) -> Result<Response, ApiError> {
158
let device_id = DeviceId::from(input.device_id.clone());
159
match state
160
.oauth_repo
161
+
.device_belongs_to_user(&device_id, &auth.did)
162
.await
163
{
164
Ok(true) => {}
165
Ok(false) => {
166
+
return Err(ApiError::DeviceNotFound);
167
}
168
Err(e) => {
169
error!("DB error: {:?}", e);
170
+
return Err(ApiError::InternalError(None));
171
}
172
}
173
174
+
state
175
.oauth_repo
176
.update_device_friendly_name(&device_id, input.friendly_name.as_deref())
177
.await
178
+
.map_err(|e| {
179
error!("DB error: {:?}", e);
180
+
ApiError::InternalError(None)
181
+
})?;
182
+
183
+
info!(did = %auth.did, device_id = %input.device_id, "Trusted device updated");
184
+
Ok(SuccessResponse::ok().into_response())
185
}
186
187
pub async fn get_device_trust_state(
+9
-28
crates/tranquil-pds/src/api/temp.rs
+9
-28
crates/tranquil-pds/src/api/temp.rs
···
1
use crate::api::error::ApiError;
2
-
use crate::auth::{BearerAuth, extract_auth_token_from_header, validate_token_with_dpop};
3
use crate::state::AppState;
4
use axum::{
5
Json,
6
extract::State,
7
-
http::HeaderMap,
8
response::{IntoResponse, Response},
9
};
10
use cid::Cid;
···
22
pub estimated_time_ms: Option<i64>,
23
}
24
25
-
pub async fn check_signup_queue(State(state): State<AppState>, headers: HeaderMap) -> Response {
26
-
if let Some(extracted) =
27
-
extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
28
{
29
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
30
-
if let Ok(user) = validate_token_with_dpop(
31
-
state.user_repo.as_ref(),
32
-
state.oauth_repo.as_ref(),
33
-
&extracted.token,
34
-
extracted.is_dpop,
35
-
dpop_proof,
36
-
"GET",
37
-
"/",
38
-
false,
39
-
false,
40
-
)
41
-
.await
42
-
&& user.is_oauth
43
-
{
44
-
return ApiError::Forbidden.into_response();
45
-
}
46
}
47
Json(CheckSignupQueueOutput {
48
activated: true,
···
66
67
pub async fn dereference_scope(
68
State(state): State<AppState>,
69
-
auth: BearerAuth,
70
Json(input): Json<DereferenceScopeInput>,
71
-
) -> Response {
72
-
let _ = auth;
73
-
74
let scope_parts: Vec<&str> = input.scope.split_whitespace().collect();
75
let mut resolved_scopes: Vec<String> = Vec::new();
76
···
135
}
136
}
137
138
-
Json(DereferenceScopeOutput {
139
scope: resolved_scopes.join(" "),
140
})
141
-
.into_response()
142
}
···
1
use crate::api::error::ApiError;
2
+
use crate::auth::{Active, Auth, Permissive};
3
use crate::state::AppState;
4
use axum::{
5
Json,
6
extract::State,
7
response::{IntoResponse, Response},
8
};
9
use cid::Cid;
···
21
pub estimated_time_ms: Option<i64>,
22
}
23
24
+
pub async fn check_signup_queue(auth: Option<Auth<Permissive>>) -> Response {
25
+
if let Some(ref user) = auth
26
+
&& user.is_oauth()
27
{
28
+
return ApiError::Forbidden.into_response();
29
}
30
Json(CheckSignupQueueOutput {
31
activated: true,
···
49
50
pub async fn dereference_scope(
51
State(state): State<AppState>,
52
+
_auth: Auth<Active>,
53
Json(input): Json<DereferenceScopeInput>,
54
+
) -> Result<Response, ApiError> {
55
let scope_parts: Vec<&str> = input.scope.split_whitespace().collect();
56
let mut resolved_scopes: Vec<String> = Vec::new();
57
···
116
}
117
}
118
119
+
Ok(Json(DereferenceScopeOutput {
120
scope: resolved_scopes.join(" "),
121
})
122
+
.into_response())
123
}
+547
crates/tranquil-pds/src/auth/auth_extractor.rs
+547
crates/tranquil-pds/src/auth/auth_extractor.rs
···
···
1
+
mod common;
2
+
mod helpers;
3
+
4
+
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
5
+
use chrono::Utc;
6
+
use common::{base_url, client, create_account_and_login, pds_endpoint};
7
+
use helpers::verify_new_account;
8
+
use reqwest::StatusCode;
9
+
use serde_json::{Value, json};
10
+
use sha2::{Digest, Sha256};
11
+
use wiremock::matchers::{method, path};
12
+
use wiremock::{Mock, MockServer, ResponseTemplate};
13
+
14
+
fn generate_pkce() -> (String, String) {
15
+
let verifier_bytes: [u8; 32] = rand::random();
16
+
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
17
+
let mut hasher = Sha256::new();
18
+
hasher.update(code_verifier.as_bytes());
19
+
let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
20
+
(code_verifier, code_challenge)
21
+
}
22
+
23
+
async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer {
24
+
let mock_server = MockServer::start().await;
25
+
let metadata = json!({
26
+
"client_id": mock_server.uri(),
27
+
"client_name": "Auth Extractor Test Client",
28
+
"redirect_uris": [redirect_uri],
29
+
"grant_types": ["authorization_code", "refresh_token"],
30
+
"response_types": ["code"],
31
+
"token_endpoint_auth_method": "none",
32
+
"dpop_bound_access_tokens": dpop_bound
33
+
});
34
+
Mock::given(method("GET"))
35
+
.and(path("/"))
36
+
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
37
+
.mount(&mock_server)
38
+
.await;
39
+
mock_server
40
+
}
41
+
42
+
async fn get_oauth_session(
43
+
http_client: &reqwest::Client,
44
+
url: &str,
45
+
dpop_bound: bool,
46
+
) -> (String, String, String, String) {
47
+
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
48
+
let handle = format!("ae{}", suffix);
49
+
let password = "AuthExtract123!";
50
+
let create_res = http_client
51
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
52
+
.json(&json!({
53
+
"handle": handle,
54
+
"email": format!("{}@example.com", handle),
55
+
"password": password
56
+
}))
57
+
.send()
58
+
.await
59
+
.unwrap();
60
+
assert_eq!(create_res.status(), StatusCode::OK);
61
+
let account: Value = create_res.json().await.unwrap();
62
+
let did = account["did"].as_str().unwrap().to_string();
63
+
verify_new_account(http_client, &did).await;
64
+
65
+
let redirect_uri = "https://example.com/auth-callback";
66
+
let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await;
67
+
let client_id = mock_client.uri();
68
+
let (code_verifier, code_challenge) = generate_pkce();
69
+
70
+
let par_body: Value = http_client
71
+
.post(format!("{}/oauth/par", url))
72
+
.form(&[
73
+
("response_type", "code"),
74
+
("client_id", &client_id),
75
+
("redirect_uri", redirect_uri),
76
+
("code_challenge", &code_challenge),
77
+
("code_challenge_method", "S256"),
78
+
])
79
+
.send()
80
+
.await
81
+
.unwrap()
82
+
.json()
83
+
.await
84
+
.unwrap();
85
+
let request_uri = par_body["request_uri"].as_str().unwrap();
86
+
87
+
let auth_res = http_client
88
+
.post(format!("{}/oauth/authorize", url))
89
+
.header("Content-Type", "application/json")
90
+
.header("Accept", "application/json")
91
+
.json(&json!({
92
+
"request_uri": request_uri,
93
+
"username": &handle,
94
+
"password": password,
95
+
"remember_device": false
96
+
}))
97
+
.send()
98
+
.await
99
+
.unwrap();
100
+
let auth_body: Value = auth_res.json().await.unwrap();
101
+
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
102
+
103
+
if location.contains("/oauth/consent") {
104
+
let consent_res = http_client
105
+
.post(format!("{}/oauth/authorize/consent", url))
106
+
.header("Content-Type", "application/json")
107
+
.json(&json!({
108
+
"request_uri": request_uri,
109
+
"approved_scopes": ["atproto"],
110
+
"remember": false
111
+
}))
112
+
.send()
113
+
.await
114
+
.unwrap();
115
+
let consent_body: Value = consent_res.json().await.unwrap();
116
+
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
117
+
}
118
+
119
+
let code = location
120
+
.split("code=")
121
+
.nth(1)
122
+
.unwrap()
123
+
.split('&')
124
+
.next()
125
+
.unwrap();
126
+
127
+
let token_body: Value = http_client
128
+
.post(format!("{}/oauth/token", url))
129
+
.form(&[
130
+
("grant_type", "authorization_code"),
131
+
("code", code),
132
+
("redirect_uri", redirect_uri),
133
+
("code_verifier", &code_verifier),
134
+
("client_id", &client_id),
135
+
])
136
+
.send()
137
+
.await
138
+
.unwrap()
139
+
.json()
140
+
.await
141
+
.unwrap();
142
+
143
+
(
144
+
token_body["access_token"].as_str().unwrap().to_string(),
145
+
token_body["refresh_token"].as_str().unwrap().to_string(),
146
+
client_id,
147
+
did,
148
+
)
149
+
}
150
+
151
+
#[tokio::test]
152
+
async fn test_oauth_token_works_with_bearer_auth() {
153
+
let url = base_url().await;
154
+
let http_client = client();
155
+
let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await;
156
+
157
+
let res = http_client
158
+
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
159
+
.bearer_auth(&access_token)
160
+
.send()
161
+
.await
162
+
.unwrap();
163
+
164
+
assert_eq!(res.status(), StatusCode::OK, "OAuth token should work with RequiredAuth extractor");
165
+
let body: Value = res.json().await.unwrap();
166
+
assert_eq!(body["did"].as_str().unwrap(), did);
167
+
}
168
+
169
+
#[tokio::test]
170
+
async fn test_session_token_still_works() {
171
+
let url = base_url().await;
172
+
let http_client = client();
173
+
let (jwt, did) = create_account_and_login(&http_client).await;
174
+
175
+
let res = http_client
176
+
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
177
+
.bearer_auth(&jwt)
178
+
.send()
179
+
.await
180
+
.unwrap();
181
+
182
+
assert_eq!(res.status(), StatusCode::OK, "Session token should still work");
183
+
let body: Value = res.json().await.unwrap();
184
+
assert_eq!(body["did"].as_str().unwrap(), did);
185
+
}
186
+
187
+
188
+
#[tokio::test]
189
+
async fn test_oauth_admin_extractor_allows_oauth_tokens() {
190
+
let url = base_url().await;
191
+
let http_client = client();
192
+
193
+
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
194
+
let handle = format!("adm{}", suffix);
195
+
let password = "AdminOAuth123!";
196
+
let create_res = http_client
197
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
198
+
.json(&json!({
199
+
"handle": handle,
200
+
"email": format!("{}@example.com", handle),
201
+
"password": password
202
+
}))
203
+
.send()
204
+
.await
205
+
.unwrap();
206
+
assert_eq!(create_res.status(), StatusCode::OK);
207
+
let account: Value = create_res.json().await.unwrap();
208
+
let did = account["did"].as_str().unwrap().to_string();
209
+
verify_new_account(&http_client, &did).await;
210
+
211
+
let pool = common::get_test_db_pool().await;
212
+
sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did)
213
+
.execute(pool)
214
+
.await
215
+
.expect("Failed to mark user as admin");
216
+
217
+
let redirect_uri = "https://example.com/admin-callback";
218
+
let mock_client = setup_mock_client_metadata(redirect_uri, false).await;
219
+
let client_id = mock_client.uri();
220
+
let (code_verifier, code_challenge) = generate_pkce();
221
+
222
+
let par_body: Value = http_client
223
+
.post(format!("{}/oauth/par", url))
224
+
.form(&[
225
+
("response_type", "code"),
226
+
("client_id", &client_id),
227
+
("redirect_uri", redirect_uri),
228
+
("code_challenge", &code_challenge),
229
+
("code_challenge_method", "S256"),
230
+
])
231
+
.send()
232
+
.await
233
+
.unwrap()
234
+
.json()
235
+
.await
236
+
.unwrap();
237
+
let request_uri = par_body["request_uri"].as_str().unwrap();
238
+
239
+
let auth_res = http_client
240
+
.post(format!("{}/oauth/authorize", url))
241
+
.header("Content-Type", "application/json")
242
+
.header("Accept", "application/json")
243
+
.json(&json!({
244
+
"request_uri": request_uri,
245
+
"username": &handle,
246
+
"password": password,
247
+
"remember_device": false
248
+
}))
249
+
.send()
250
+
.await
251
+
.unwrap();
252
+
let auth_body: Value = auth_res.json().await.unwrap();
253
+
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
254
+
if location.contains("/oauth/consent") {
255
+
let consent_res = http_client
256
+
.post(format!("{}/oauth/authorize/consent", url))
257
+
.header("Content-Type", "application/json")
258
+
.json(&json!({
259
+
"request_uri": request_uri,
260
+
"approved_scopes": ["atproto"],
261
+
"remember": false
262
+
}))
263
+
.send()
264
+
.await
265
+
.unwrap();
266
+
let consent_body: Value = consent_res.json().await.unwrap();
267
+
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
268
+
}
269
+
270
+
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
271
+
let token_body: Value = http_client
272
+
.post(format!("{}/oauth/token", url))
273
+
.form(&[
274
+
("grant_type", "authorization_code"),
275
+
("code", code),
276
+
("redirect_uri", redirect_uri),
277
+
("code_verifier", &code_verifier),
278
+
("client_id", &client_id),
279
+
])
280
+
.send()
281
+
.await
282
+
.unwrap()
283
+
.json()
284
+
.await
285
+
.unwrap();
286
+
let access_token = token_body["access_token"].as_str().unwrap();
287
+
288
+
let res = http_client
289
+
.get(format!("{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", url, did))
290
+
.bearer_auth(access_token)
291
+
.send()
292
+
.await
293
+
.unwrap();
294
+
295
+
assert_eq!(
296
+
res.status(),
297
+
StatusCode::OK,
298
+
"OAuth token for admin user should work with admin endpoint"
299
+
);
300
+
}
301
+
302
+
#[tokio::test]
303
+
async fn test_expired_oauth_token_returns_proper_error() {
304
+
let url = base_url().await;
305
+
let http_client = client();
306
+
307
+
let now = Utc::now().timestamp();
308
+
let header = json!({"alg": "HS256", "typ": "at+jwt"});
309
+
let payload = json!({
310
+
"iss": url,
311
+
"sub": "did:plc:test123",
312
+
"aud": url,
313
+
"iat": now - 7200,
314
+
"exp": now - 3600,
315
+
"jti": "expired-token",
316
+
"sid": "expired-session",
317
+
"scope": "atproto",
318
+
"client_id": "https://example.com"
319
+
});
320
+
let fake_token = format!(
321
+
"{}.{}.{}",
322
+
URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
323
+
URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
324
+
URL_SAFE_NO_PAD.encode([1u8; 32])
325
+
);
326
+
327
+
let res = http_client
328
+
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
329
+
.bearer_auth(&fake_token)
330
+
.send()
331
+
.await
332
+
.unwrap();
333
+
334
+
assert_eq!(
335
+
res.status(),
336
+
StatusCode::UNAUTHORIZED,
337
+
"Expired token should be rejected"
338
+
);
339
+
}
340
+
341
+
#[tokio::test]
342
+
async fn test_dpop_nonce_error_has_proper_headers() {
343
+
let url = base_url().await;
344
+
let pds_url = pds_endpoint();
345
+
let http_client = client();
346
+
347
+
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
348
+
let handle = format!("dpop{}", suffix);
349
+
let create_res = http_client
350
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
351
+
.json(&json!({
352
+
"handle": handle,
353
+
"email": format!("{}@test.com", handle),
354
+
"password": "DpopTest123!"
355
+
}))
356
+
.send()
357
+
.await
358
+
.unwrap();
359
+
assert_eq!(create_res.status(), StatusCode::OK);
360
+
let account: Value = create_res.json().await.unwrap();
361
+
let did = account["did"].as_str().unwrap();
362
+
verify_new_account(&http_client, did).await;
363
+
364
+
let redirect_uri = "https://example.com/dpop-callback";
365
+
let mock_server = MockServer::start().await;
366
+
let client_id = mock_server.uri();
367
+
let metadata = json!({
368
+
"client_id": &client_id,
369
+
"client_name": "DPoP Test Client",
370
+
"redirect_uris": [redirect_uri],
371
+
"grant_types": ["authorization_code", "refresh_token"],
372
+
"response_types": ["code"],
373
+
"token_endpoint_auth_method": "none",
374
+
"dpop_bound_access_tokens": true
375
+
});
376
+
Mock::given(method("GET"))
377
+
.and(path("/"))
378
+
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
379
+
.mount(&mock_server)
380
+
.await;
381
+
382
+
let (code_verifier, code_challenge) = generate_pkce();
383
+
let par_body: Value = http_client
384
+
.post(format!("{}/oauth/par", url))
385
+
.form(&[
386
+
("response_type", "code"),
387
+
("client_id", &client_id),
388
+
("redirect_uri", redirect_uri),
389
+
("code_challenge", &code_challenge),
390
+
("code_challenge_method", "S256"),
391
+
])
392
+
.send()
393
+
.await
394
+
.unwrap()
395
+
.json()
396
+
.await
397
+
.unwrap();
398
+
399
+
let request_uri = par_body["request_uri"].as_str().unwrap();
400
+
let auth_res = http_client
401
+
.post(format!("{}/oauth/authorize", url))
402
+
.header("Content-Type", "application/json")
403
+
.header("Accept", "application/json")
404
+
.json(&json!({
405
+
"request_uri": request_uri,
406
+
"username": &handle,
407
+
"password": "DpopTest123!",
408
+
"remember_device": false
409
+
}))
410
+
.send()
411
+
.await
412
+
.unwrap();
413
+
let auth_body: Value = auth_res.json().await.unwrap();
414
+
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
415
+
if location.contains("/oauth/consent") {
416
+
let consent_res = http_client
417
+
.post(format!("{}/oauth/authorize/consent", url))
418
+
.header("Content-Type", "application/json")
419
+
.json(&json!({
420
+
"request_uri": request_uri,
421
+
"approved_scopes": ["atproto"],
422
+
"remember": false
423
+
}))
424
+
.send()
425
+
.await
426
+
.unwrap();
427
+
let consent_body: Value = consent_res.json().await.unwrap();
428
+
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
429
+
}
430
+
431
+
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
432
+
433
+
let token_endpoint = format!("{}/oauth/token", pds_url);
434
+
let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None);
435
+
436
+
let token_res = http_client
437
+
.post(format!("{}/oauth/token", url))
438
+
.header("DPoP", &dpop_proof)
439
+
.form(&[
440
+
("grant_type", "authorization_code"),
441
+
("code", code),
442
+
("redirect_uri", redirect_uri),
443
+
("code_verifier", &code_verifier),
444
+
("client_id", &client_id),
445
+
])
446
+
.send()
447
+
.await
448
+
.unwrap();
449
+
450
+
let token_status = token_res.status();
451
+
let token_nonce = token_res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap().to_string());
452
+
let token_body: Value = token_res.json().await.unwrap();
453
+
454
+
let access_token = if token_status == StatusCode::OK {
455
+
token_body["access_token"].as_str().unwrap().to_string()
456
+
} else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") {
457
+
let nonce = token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error");
458
+
let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce));
459
+
460
+
let retry_res = http_client
461
+
.post(format!("{}/oauth/token", url))
462
+
.header("DPoP", &dpop_proof_with_nonce)
463
+
.form(&[
464
+
("grant_type", "authorization_code"),
465
+
("code", code),
466
+
("redirect_uri", redirect_uri),
467
+
("code_verifier", &code_verifier),
468
+
("client_id", &client_id),
469
+
])
470
+
.send()
471
+
.await
472
+
.unwrap();
473
+
let retry_body: Value = retry_res.json().await.unwrap();
474
+
retry_body["access_token"].as_str().expect("Should get access_token after nonce retry").to_string()
475
+
} else {
476
+
panic!("Token exchange failed unexpectedly: {:?}", token_body);
477
+
};
478
+
479
+
let res = http_client
480
+
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
481
+
.header("Authorization", format!("DPoP {}", access_token))
482
+
.send()
483
+
.await
484
+
.unwrap();
485
+
486
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DPoP token without proof should fail");
487
+
488
+
let www_auth = res.headers().get("www-authenticate").map(|h| h.to_str().unwrap());
489
+
assert!(www_auth.is_some(), "Should have WWW-Authenticate header");
490
+
assert!(
491
+
www_auth.unwrap().contains("use_dpop_nonce"),
492
+
"WWW-Authenticate should indicate dpop nonce required"
493
+
);
494
+
495
+
let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap());
496
+
assert!(nonce.is_some(), "Should return DPoP-Nonce header");
497
+
498
+
let body: Value = res.json().await.unwrap();
499
+
assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce");
500
+
}
501
+
502
+
fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) {
503
+
use p256::ecdsa::{SigningKey, signature::Signer};
504
+
use p256::elliptic_curve::rand_core::OsRng;
505
+
506
+
let signing_key = SigningKey::random(&mut OsRng);
507
+
let verifying_key = signing_key.verifying_key();
508
+
let point = verifying_key.to_encoded_point(false);
509
+
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
510
+
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
511
+
512
+
let jwk = json!({
513
+
"kty": "EC",
514
+
"crv": "P-256",
515
+
"x": x,
516
+
"y": y
517
+
});
518
+
519
+
let header = {
520
+
let h = json!({
521
+
"typ": "dpop+jwt",
522
+
"alg": "ES256",
523
+
"jwk": jwk.clone()
524
+
});
525
+
h
526
+
};
527
+
528
+
let mut payload = json!({
529
+
"jti": uuid::Uuid::new_v4().to_string(),
530
+
"htm": method,
531
+
"htu": uri,
532
+
"iat": Utc::now().timestamp()
533
+
});
534
+
if let Some(n) = nonce {
535
+
payload["nonce"] = json!(n);
536
+
}
537
+
538
+
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
539
+
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
540
+
let signing_input = format!("{}.{}", header_b64, payload_b64);
541
+
542
+
let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
543
+
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
544
+
545
+
let proof = format!("{}.{}", signing_input, sig_b64);
546
+
(jwk, proof)
547
+
}
+432
-231
crates/tranquil-pds/src/auth/extractor.rs
+432
-231
crates/tranquil-pds/src/auth/extractor.rs
···
1
use axum::{
2
-
extract::FromRequestParts,
3
-
http::{header::AUTHORIZATION, request::Parts},
4
response::{IntoResponse, Response},
5
};
6
7
use super::{
8
-
AuthenticatedUser, TokenValidationError, validate_bearer_token_allow_takendown,
9
-
validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated,
10
-
validate_token_with_dpop,
11
};
12
use crate::api::error::ApiError;
13
use crate::state::AppState;
14
use crate::util::build_full_url;
15
-
16
-
pub struct BearerAuth(pub AuthenticatedUser);
17
18
#[derive(Debug)]
19
pub enum AuthError {
···
24
AccountDeactivated,
25
AccountTakedown,
26
AdminRequired,
27
}
28
29
impl IntoResponse for AuthError {
30
fn into_response(self) -> Response {
31
-
ApiError::from(self).into_response()
32
}
33
}
34
35
-
#[cfg(test)]
36
-
fn extract_bearer_token(auth_header: &str) -> Result<&str, AuthError> {
37
-
let auth_header = auth_header.trim();
38
-
39
-
if auth_header.len() < 8 {
40
-
return Err(AuthError::InvalidFormat);
41
-
}
42
-
43
-
let prefix = &auth_header[..7];
44
-
if !prefix.eq_ignore_ascii_case("bearer ") {
45
-
return Err(AuthError::InvalidFormat);
46
-
}
47
-
48
-
let token = auth_header[7..].trim();
49
-
if token.is_empty() {
50
-
return Err(AuthError::InvalidFormat);
51
-
}
52
-
53
-
Ok(token)
54
}
55
56
pub fn extract_bearer_token_from_header(auth_header: Option<&str>) -> Option<String> {
···
73
Some(token.to_string())
74
}
75
76
-
pub struct ExtractedToken {
77
-
pub token: String,
78
-
pub is_dpop: bool,
79
-
}
80
-
81
pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<ExtractedToken> {
82
let header = auth_header?;
83
let header = header.trim();
···
107
None
108
}
109
110
-
impl FromRequestParts<AppState> for BearerAuth {
111
type Rejection = AuthError;
112
113
async fn from_request_parts(
114
parts: &mut Parts,
115
state: &AppState,
116
) -> Result<Self, Self::Rejection> {
117
-
let auth_header = parts
118
-
.headers
119
-
.get(AUTHORIZATION)
120
-
.ok_or(AuthError::MissingToken)?
121
-
.to_str()
122
-
.map_err(|_| AuthError::InvalidFormat)?;
123
124
-
let extracted =
125
-
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
126
127
-
if extracted.is_dpop {
128
-
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
129
-
let method = parts.method.as_str();
130
-
let uri = build_full_url(&parts.uri.to_string());
131
-
132
-
match validate_token_with_dpop(
133
-
state.user_repo.as_ref(),
134
-
state.oauth_repo.as_ref(),
135
-
&extracted.token,
136
-
true,
137
-
dpop_proof,
138
-
method,
139
-
&uri,
140
-
false,
141
-
false,
142
-
)
143
-
.await
144
-
{
145
-
Ok(user) => Ok(BearerAuth(user)),
146
-
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
147
-
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
148
-
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
149
-
Err(_) => Err(AuthError::AuthenticationFailed),
150
}
151
-
} else {
152
-
match validate_bearer_token_cached(
153
-
state.user_repo.as_ref(),
154
-
state.cache.as_ref(),
155
-
&extracted.token,
156
-
)
157
-
.await
158
-
{
159
-
Ok(user) => Ok(BearerAuth(user)),
160
-
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
161
-
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
162
-
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
163
-
Err(_) => Err(AuthError::AuthenticationFailed),
164
-
}
165
}
166
}
167
}
168
169
-
pub struct BearerAuthAllowDeactivated(pub AuthenticatedUser);
170
171
-
impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
172
type Rejection = AuthError;
173
174
async fn from_request_parts(
175
parts: &mut Parts,
176
state: &AppState,
177
) -> Result<Self, Self::Rejection> {
178
-
let auth_header = parts
179
-
.headers
180
-
.get(AUTHORIZATION)
181
-
.ok_or(AuthError::MissingToken)?
182
-
.to_str()
183
-
.map_err(|_| AuthError::InvalidFormat)?;
184
-
185
-
let extracted =
186
-
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
187
188
-
if extracted.is_dpop {
189
-
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
190
-
let method = parts.method.as_str();
191
-
let uri = build_full_url(&parts.uri.to_string());
192
193
-
match validate_token_with_dpop(
194
-
state.user_repo.as_ref(),
195
-
state.oauth_repo.as_ref(),
196
-
&extracted.token,
197
-
true,
198
-
dpop_proof,
199
-
method,
200
-
&uri,
201
-
true,
202
-
false,
203
-
)
204
-
.await
205
-
{
206
-
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
207
-
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
208
-
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
209
-
Err(_) => Err(AuthError::AuthenticationFailed),
210
}
211
-
} else {
212
-
match validate_bearer_token_cached_allow_deactivated(
213
-
state.user_repo.as_ref(),
214
-
state.cache.as_ref(),
215
-
&extracted.token,
216
-
)
217
-
.await
218
-
{
219
-
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
220
-
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
221
-
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
222
-
Err(_) => Err(AuthError::AuthenticationFailed),
223
-
}
224
}
225
}
226
}
227
228
-
pub struct BearerAuthAllowTakendown(pub AuthenticatedUser);
229
230
-
impl FromRequestParts<AppState> for BearerAuthAllowTakendown {
231
type Rejection = AuthError;
232
233
async fn from_request_parts(
234
parts: &mut Parts,
235
state: &AppState,
236
) -> Result<Self, Self::Rejection> {
237
-
let auth_header = parts
238
-
.headers
239
-
.get(AUTHORIZATION)
240
-
.ok_or(AuthError::MissingToken)?
241
-
.to_str()
242
-
.map_err(|_| AuthError::InvalidFormat)?;
243
-
244
-
let extracted =
245
-
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
246
-
247
-
if extracted.is_dpop {
248
-
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
249
-
let method = parts.method.as_str();
250
-
let uri = build_full_url(&parts.uri.to_string());
251
-
252
-
match validate_token_with_dpop(
253
-
state.user_repo.as_ref(),
254
-
state.oauth_repo.as_ref(),
255
-
&extracted.token,
256
-
true,
257
-
dpop_proof,
258
-
method,
259
-
&uri,
260
-
false,
261
-
true,
262
-
)
263
-
.await
264
-
{
265
-
Ok(user) => Ok(BearerAuthAllowTakendown(user)),
266
-
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
267
-
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
268
-
Err(_) => Err(AuthError::AuthenticationFailed),
269
}
270
-
} else {
271
-
match validate_bearer_token_allow_takendown(state.user_repo.as_ref(), &extracted.token)
272
-
.await
273
-
{
274
-
Ok(user) => Ok(BearerAuthAllowTakendown(user)),
275
-
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
276
-
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
277
-
Err(_) => Err(AuthError::AuthenticationFailed),
278
}
279
}
280
}
281
}
282
283
-
pub struct BearerAuthAdmin(pub AuthenticatedUser);
284
-
285
-
impl FromRequestParts<AppState> for BearerAuthAdmin {
286
type Rejection = AuthError;
287
288
async fn from_request_parts(
289
parts: &mut Parts,
290
state: &AppState,
291
-
) -> Result<Self, Self::Rejection> {
292
-
let auth_header = parts
293
-
.headers
294
-
.get(AUTHORIZATION)
295
-
.ok_or(AuthError::MissingToken)?
296
-
.to_str()
297
-
.map_err(|_| AuthError::InvalidFormat)?;
298
299
-
let extracted =
300
-
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
301
302
-
let user = if extracted.is_dpop {
303
-
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
304
-
let method = parts.method.as_str();
305
-
let uri = build_full_url(&parts.uri.to_string());
306
307
-
match validate_token_with_dpop(
308
-
state.user_repo.as_ref(),
309
-
state.oauth_repo.as_ref(),
310
-
&extracted.token,
311
-
true,
312
-
dpop_proof,
313
-
method,
314
-
&uri,
315
-
false,
316
-
false,
317
-
)
318
-
.await
319
-
{
320
-
Ok(user) => user,
321
-
Err(TokenValidationError::AccountDeactivated) => {
322
-
return Err(AuthError::AccountDeactivated);
323
-
}
324
-
Err(TokenValidationError::AccountTakedown) => {
325
-
return Err(AuthError::AccountTakedown);
326
-
}
327
-
Err(TokenValidationError::TokenExpired) => {
328
-
return Err(AuthError::TokenExpired);
329
-
}
330
-
Err(_) => return Err(AuthError::AuthenticationFailed),
331
-
}
332
-
} else {
333
-
match validate_bearer_token_cached(
334
-
state.user_repo.as_ref(),
335
-
state.cache.as_ref(),
336
-
&extracted.token,
337
-
)
338
-
.await
339
-
{
340
-
Ok(user) => user,
341
-
Err(TokenValidationError::AccountDeactivated) => {
342
-
return Err(AuthError::AccountDeactivated);
343
-
}
344
-
Err(TokenValidationError::AccountTakedown) => {
345
-
return Err(AuthError::AccountTakedown);
346
-
}
347
-
Err(TokenValidationError::TokenExpired) => {
348
-
return Err(AuthError::TokenExpired);
349
-
}
350
-
Err(_) => return Err(AuthError::AuthenticationFailed),
351
-
}
352
-
};
353
354
-
if !user.is_admin {
355
-
return Err(AuthError::AdminRequired);
356
-
}
357
-
Ok(BearerAuthAdmin(user))
358
}
359
}
360
361
#[cfg(test)]
···
1
+
use std::marker::PhantomData;
2
+
3
use axum::{
4
+
extract::{FromRequestParts, OptionalFromRequestParts},
5
+
http::{StatusCode, header::AUTHORIZATION, request::Parts},
6
response::{IntoResponse, Response},
7
};
8
+
use tracing::{debug, error, info};
9
10
use super::{
11
+
AccountStatus, AuthSource, AuthenticatedUser, ServiceTokenClaims, ServiceTokenVerifier,
12
+
is_service_token, validate_bearer_token_for_service_auth,
13
};
14
use crate::api::error::ApiError;
15
+
use crate::oauth::scopes::{RepoAction, ScopePermissions};
16
use crate::state::AppState;
17
+
use crate::types::Did;
18
use crate::util::build_full_url;
19
20
#[derive(Debug)]
21
pub enum AuthError {
···
26
AccountDeactivated,
27
AccountTakedown,
28
AdminRequired,
29
+
ServiceAuthNotAllowed,
30
+
InsufficientScope(String),
31
+
OAuthExpiredToken(String),
32
+
UseDpopNonce(String),
33
+
InvalidDpopProof(String),
34
}
35
36
impl IntoResponse for AuthError {
37
fn into_response(self) -> Response {
38
+
match self {
39
+
Self::UseDpopNonce(nonce) => (
40
+
StatusCode::UNAUTHORIZED,
41
+
[
42
+
("DPoP-Nonce", nonce.as_str()),
43
+
("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""),
44
+
],
45
+
axum::Json(serde_json::json!({
46
+
"error": "use_dpop_nonce",
47
+
"message": "DPoP nonce required"
48
+
})),
49
+
)
50
+
.into_response(),
51
+
Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(),
52
+
Self::InvalidDpopProof(msg) => (
53
+
StatusCode::UNAUTHORIZED,
54
+
[("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")],
55
+
axum::Json(serde_json::json!({
56
+
"error": "invalid_dpop_proof",
57
+
"message": msg
58
+
})),
59
+
)
60
+
.into_response(),
61
+
Self::InsufficientScope(msg) => ApiError::InsufficientScope(Some(msg)).into_response(),
62
+
other => ApiError::from(other).into_response(),
63
+
}
64
}
65
}
66
67
+
pub struct ExtractedToken {
68
+
pub token: String,
69
+
pub is_dpop: bool,
70
}
71
72
pub fn extract_bearer_token_from_header(auth_header: Option<&str>) -> Option<String> {
···
89
Some(token.to_string())
90
}
91
92
pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<ExtractedToken> {
93
let header = auth_header?;
94
let header = header.trim();
···
118
None
119
}
120
121
+
pub trait AuthPolicy: Send + Sync + 'static {
122
+
fn validate(user: &AuthenticatedUser) -> Result<(), AuthError>;
123
+
}
124
+
125
+
pub struct Permissive;
126
+
127
+
impl AuthPolicy for Permissive {
128
+
fn validate(_user: &AuthenticatedUser) -> Result<(), AuthError> {
129
+
Ok(())
130
+
}
131
+
}
132
+
133
+
pub struct Active;
134
+
135
+
impl AuthPolicy for Active {
136
+
fn validate(user: &AuthenticatedUser) -> Result<(), AuthError> {
137
+
if user.status.is_deactivated() {
138
+
return Err(AuthError::AccountDeactivated);
139
+
}
140
+
if user.status.is_takendown() {
141
+
return Err(AuthError::AccountTakedown);
142
+
}
143
+
Ok(())
144
+
}
145
+
}
146
+
147
+
pub struct NotTakendown;
148
+
149
+
impl AuthPolicy for NotTakendown {
150
+
fn validate(user: &AuthenticatedUser) -> Result<(), AuthError> {
151
+
if user.status.is_takendown() {
152
+
return Err(AuthError::AccountTakedown);
153
+
}
154
+
Ok(())
155
+
}
156
+
}
157
+
158
+
pub struct AnyUser;
159
+
160
+
impl AuthPolicy for AnyUser {
161
+
fn validate(_user: &AuthenticatedUser) -> Result<(), AuthError> {
162
+
Ok(())
163
+
}
164
+
}
165
+
166
+
pub struct Admin;
167
+
168
+
impl AuthPolicy for Admin {
169
+
fn validate(user: &AuthenticatedUser) -> Result<(), AuthError> {
170
+
if user.status.is_deactivated() {
171
+
return Err(AuthError::AccountDeactivated);
172
+
}
173
+
if user.status.is_takendown() {
174
+
return Err(AuthError::AccountTakedown);
175
+
}
176
+
if !user.is_admin {
177
+
return Err(AuthError::AdminRequired);
178
+
}
179
+
Ok(())
180
+
}
181
+
}
182
+
183
+
impl AuthenticatedUser {
184
+
pub fn require_active(&self) -> Result<&Self, ApiError> {
185
+
if self.status.is_deactivated() {
186
+
return Err(ApiError::AccountDeactivated);
187
+
}
188
+
if self.status.is_takendown() {
189
+
return Err(ApiError::AccountTakedown);
190
+
}
191
+
Ok(self)
192
+
}
193
+
194
+
pub fn require_not_takendown(&self) -> Result<&Self, ApiError> {
195
+
if self.status.is_takendown() {
196
+
return Err(ApiError::AccountTakedown);
197
+
}
198
+
Ok(self)
199
+
}
200
+
201
+
pub fn require_admin(&self) -> Result<&Self, ApiError> {
202
+
if !self.is_admin {
203
+
return Err(ApiError::AdminRequired);
204
+
}
205
+
Ok(self)
206
+
}
207
+
}
208
+
209
+
async fn verify_oauth_token_and_build_user(
210
+
state: &AppState,
211
+
token: &str,
212
+
dpop_proof: Option<&str>,
213
+
method: &str,
214
+
uri: &str,
215
+
) -> Result<AuthenticatedUser, AuthError> {
216
+
match crate::oauth::verify::verify_oauth_access_token(
217
+
state.oauth_repo.as_ref(),
218
+
token,
219
+
dpop_proof,
220
+
method,
221
+
uri,
222
+
)
223
+
.await
224
+
{
225
+
Ok(result) => {
226
+
let user_info = state
227
+
.user_repo
228
+
.get_user_info_by_did(&result.did)
229
+
.await
230
+
.ok()
231
+
.flatten()
232
+
.ok_or(AuthError::AuthenticationFailed)?;
233
+
let status = AccountStatus::from_db_fields(
234
+
user_info.takedown_ref.as_deref(),
235
+
user_info.deactivated_at,
236
+
);
237
+
Ok(AuthenticatedUser {
238
+
did: result.did,
239
+
key_bytes: user_info.key_bytes.and_then(|kb| {
240
+
crate::config::decrypt_key(&kb, user_info.encryption_version).ok()
241
+
}),
242
+
is_admin: user_info.is_admin,
243
+
status,
244
+
scope: result.scope,
245
+
controller_did: None,
246
+
auth_source: AuthSource::OAuth,
247
+
})
248
+
}
249
+
Err(crate::oauth::OAuthError::ExpiredToken(msg)) => Err(AuthError::OAuthExpiredToken(msg)),
250
+
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => Err(AuthError::UseDpopNonce(nonce)),
251
+
Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => {
252
+
Err(AuthError::InvalidDpopProof(msg))
253
+
}
254
+
Err(_) => Err(AuthError::AuthenticationFailed),
255
+
}
256
+
}
257
+
258
+
async fn verify_service_token(token: &str) -> Result<ServiceTokenClaims, AuthError> {
259
+
let verifier = ServiceTokenVerifier::new();
260
+
let claims = verifier
261
+
.verify_service_token(token, None)
262
+
.await
263
+
.map_err(|e| {
264
+
error!("Service token verification failed: {:?}", e);
265
+
AuthError::AuthenticationFailed
266
+
})?;
267
+
268
+
debug!("Service token verified for DID: {}", claims.iss);
269
+
Ok(claims)
270
+
}
271
+
272
+
enum ExtractedAuth {
273
+
User(AuthenticatedUser),
274
+
Service(ServiceTokenClaims),
275
+
}
276
+
277
+
async fn extract_auth_internal(
278
+
parts: &mut Parts,
279
+
state: &AppState,
280
+
) -> Result<ExtractedAuth, AuthError> {
281
+
let auth_header = parts
282
+
.headers
283
+
.get(AUTHORIZATION)
284
+
.ok_or(AuthError::MissingToken)?
285
+
.to_str()
286
+
.map_err(|_| AuthError::InvalidFormat)?;
287
+
288
+
let extracted =
289
+
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
290
+
291
+
if is_service_token(&extracted.token) {
292
+
let claims = verify_service_token(&extracted.token).await?;
293
+
return Ok(ExtractedAuth::Service(claims));
294
+
}
295
+
296
+
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
297
+
let method = parts.method.as_str();
298
+
let uri = build_full_url(&parts.uri.to_string());
299
+
300
+
match validate_bearer_token_for_service_auth(state.user_repo.as_ref(), &extracted.token).await {
301
+
Ok(user) if !user.auth_source.is_oauth() => {
302
+
return Ok(ExtractedAuth::User(user));
303
+
}
304
+
Ok(_) => {}
305
+
Err(super::TokenValidationError::TokenExpired) => {
306
+
info!("JWT access token expired, returning ExpiredToken");
307
+
return Err(AuthError::TokenExpired);
308
+
}
309
+
Err(_) => {}
310
+
}
311
+
312
+
let user = verify_oauth_token_and_build_user(state, &extracted.token, dpop_proof, method, &uri)
313
+
.await?;
314
+
Ok(ExtractedAuth::User(user))
315
+
}
316
+
317
+
async fn extract_user_auth_internal(
318
+
parts: &mut Parts,
319
+
state: &AppState,
320
+
) -> Result<AuthenticatedUser, AuthError> {
321
+
match extract_auth_internal(parts, state).await? {
322
+
ExtractedAuth::User(user) => Ok(user),
323
+
ExtractedAuth::Service(_) => Err(AuthError::ServiceAuthNotAllowed),
324
+
}
325
+
}
326
+
327
+
pub struct Auth<P: AuthPolicy = Active>(pub AuthenticatedUser, PhantomData<P>);
328
+
329
+
impl<P: AuthPolicy> Auth<P> {
330
+
pub fn into_inner(self) -> AuthenticatedUser {
331
+
self.0
332
+
}
333
+
334
+
pub fn needs_scope_check(&self) -> bool {
335
+
self.0.is_oauth()
336
+
}
337
+
338
+
pub fn permissions(&self) -> ScopePermissions {
339
+
self.0.permissions()
340
+
}
341
+
342
+
#[allow(clippy::result_large_err)]
343
+
pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), Response> {
344
+
if !self.needs_scope_check() {
345
+
return Ok(());
346
+
}
347
+
self.permissions()
348
+
.assert_repo(action, collection)
349
+
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
350
+
}
351
+
}
352
+
353
+
impl<P: AuthPolicy> std::ops::Deref for Auth<P> {
354
+
type Target = AuthenticatedUser;
355
+
356
+
fn deref(&self) -> &Self::Target {
357
+
&self.0
358
+
}
359
+
}
360
+
361
+
impl<P: AuthPolicy> FromRequestParts<AppState> for Auth<P> {
362
type Rejection = AuthError;
363
364
async fn from_request_parts(
365
parts: &mut Parts,
366
state: &AppState,
367
) -> Result<Self, Self::Rejection> {
368
+
let user = extract_user_auth_internal(parts, state).await?;
369
+
P::validate(&user)?;
370
+
Ok(Auth(user, PhantomData))
371
+
}
372
+
}
373
374
+
impl<P: AuthPolicy> OptionalFromRequestParts<AppState> for Auth<P> {
375
+
type Rejection = AuthError;
376
377
+
async fn from_request_parts(
378
+
parts: &mut Parts,
379
+
state: &AppState,
380
+
) -> Result<Option<Self>, Self::Rejection> {
381
+
match extract_user_auth_internal(parts, state).await {
382
+
Ok(user) => {
383
+
P::validate(&user)?;
384
+
Ok(Some(Auth(user, PhantomData)))
385
}
386
+
Err(AuthError::MissingToken) => Ok(None),
387
+
Err(e) => Err(e),
388
}
389
}
390
}
391
392
+
pub struct ServiceAuth {
393
+
pub did: Did,
394
+
pub claims: ServiceTokenClaims,
395
+
}
396
+
397
+
impl ServiceAuth {
398
+
pub fn require_lxm(&self, expected_lxm: &str) -> Result<(), ApiError> {
399
+
match &self.claims.lxm {
400
+
Some(lxm) if lxm == "*" || lxm == expected_lxm => Ok(()),
401
+
Some(lxm) => Err(ApiError::AuthorizationError(format!(
402
+
"Token lxm '{}' does not permit '{}'",
403
+
lxm, expected_lxm
404
+
))),
405
+
None => Err(ApiError::AuthorizationError(
406
+
"Token missing lxm claim".to_string(),
407
+
)),
408
+
}
409
+
}
410
+
}
411
412
+
impl FromRequestParts<AppState> for ServiceAuth {
413
type Rejection = AuthError;
414
415
async fn from_request_parts(
416
parts: &mut Parts,
417
state: &AppState,
418
) -> Result<Self, Self::Rejection> {
419
+
match extract_auth_internal(parts, state).await? {
420
+
ExtractedAuth::Service(claims) => {
421
+
let did: Did = claims
422
+
.iss
423
+
.parse()
424
+
.map_err(|_| AuthError::AuthenticationFailed)?;
425
+
Ok(ServiceAuth { did, claims })
426
+
}
427
+
ExtractedAuth::User(_) => Err(AuthError::AuthenticationFailed),
428
+
}
429
+
}
430
+
}
431
432
+
impl OptionalFromRequestParts<AppState> for ServiceAuth {
433
+
type Rejection = AuthError;
434
435
+
async fn from_request_parts(
436
+
parts: &mut Parts,
437
+
state: &AppState,
438
+
) -> Result<Option<Self>, Self::Rejection> {
439
+
match extract_auth_internal(parts, state).await {
440
+
Ok(ExtractedAuth::Service(claims)) => {
441
+
let did: Did = claims
442
+
.iss
443
+
.parse()
444
+
.map_err(|_| AuthError::AuthenticationFailed)?;
445
+
Ok(Some(ServiceAuth { did, claims }))
446
}
447
+
Ok(ExtractedAuth::User(_)) => Err(AuthError::AuthenticationFailed),
448
+
Err(AuthError::MissingToken) => Ok(None),
449
+
Err(e) => Err(e),
450
}
451
}
452
}
453
454
+
pub enum AuthAny<P: AuthPolicy = Active> {
455
+
User(Auth<P>),
456
+
Service(ServiceAuth),
457
+
}
458
+
459
+
impl<P: AuthPolicy> AuthAny<P> {
460
+
pub fn did(&self) -> &Did {
461
+
match self {
462
+
Self::User(auth) => &auth.did,
463
+
Self::Service(auth) => &auth.did,
464
+
}
465
+
}
466
+
467
+
pub fn as_user(&self) -> Option<&Auth<P>> {
468
+
match self {
469
+
Self::User(auth) => Some(auth),
470
+
Self::Service(_) => None,
471
+
}
472
+
}
473
+
474
+
pub fn as_service(&self) -> Option<&ServiceAuth> {
475
+
match self {
476
+
Self::User(_) => None,
477
+
Self::Service(auth) => Some(auth),
478
+
}
479
+
}
480
+
481
+
pub fn is_service(&self) -> bool {
482
+
matches!(self, Self::Service(_))
483
+
}
484
+
485
+
pub fn require_lxm(&self, expected_lxm: &str) -> Result<(), ApiError> {
486
+
match self {
487
+
Self::User(_) => Ok(()),
488
+
Self::Service(auth) => auth.require_lxm(expected_lxm),
489
+
}
490
+
}
491
+
}
492
493
+
impl<P: AuthPolicy> FromRequestParts<AppState> for AuthAny<P> {
494
type Rejection = AuthError;
495
496
async fn from_request_parts(
497
parts: &mut Parts,
498
state: &AppState,
499
) -> Result<Self, Self::Rejection> {
500
+
match extract_auth_internal(parts, state).await? {
501
+
ExtractedAuth::User(user) => {
502
+
P::validate(&user)?;
503
+
Ok(AuthAny::User(Auth(user, PhantomData)))
504
}
505
+
ExtractedAuth::Service(claims) => {
506
+
let did: Did = claims
507
+
.iss
508
+
.parse()
509
+
.map_err(|_| AuthError::AuthenticationFailed)?;
510
+
Ok(AuthAny::Service(ServiceAuth { did, claims }))
511
}
512
}
513
}
514
}
515
516
+
impl<P: AuthPolicy> OptionalFromRequestParts<AppState> for AuthAny<P> {
517
type Rejection = AuthError;
518
519
async fn from_request_parts(
520
parts: &mut Parts,
521
state: &AppState,
522
+
) -> Result<Option<Self>, Self::Rejection> {
523
+
match extract_auth_internal(parts, state).await {
524
+
Ok(ExtractedAuth::User(user)) => {
525
+
P::validate(&user)?;
526
+
Ok(Some(AuthAny::User(Auth(user, PhantomData))))
527
+
}
528
+
Ok(ExtractedAuth::Service(claims)) => {
529
+
let did: Did = claims
530
+
.iss
531
+
.parse()
532
+
.map_err(|_| AuthError::AuthenticationFailed)?;
533
+
Ok(Some(AuthAny::Service(ServiceAuth { did, claims })))
534
+
}
535
+
Err(AuthError::MissingToken) => Ok(None),
536
+
Err(e) => Err(e),
537
+
}
538
+
}
539
+
}
540
541
+
#[cfg(test)]
542
+
fn extract_bearer_token(auth_header: &str) -> Result<&str, AuthError> {
543
+
let auth_header = auth_header.trim();
544
545
+
if auth_header.len() < 8 {
546
+
return Err(AuthError::InvalidFormat);
547
+
}
548
549
+
let prefix = &auth_header[..7];
550
+
if !prefix.eq_ignore_ascii_case("bearer ") {
551
+
return Err(AuthError::InvalidFormat);
552
+
}
553
554
+
let token = auth_header[7..].trim();
555
+
if token.is_empty() {
556
+
return Err(AuthError::InvalidFormat);
557
}
558
+
559
+
Ok(token)
560
}
561
562
#[cfg(test)]
+74
-7
crates/tranquil-pds/src/auth/mod.rs
+74
-7
crates/tranquil-pds/src/auth/mod.rs
···
3
use std::time::Duration;
4
5
use crate::AccountStatus;
6
use crate::cache::Cache;
7
use crate::oauth::scopes::ScopePermissions;
8
use crate::types::Did;
···
16
pub mod webauthn;
17
18
pub use extractor::{
19
-
AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,
20
-
extract_auth_token_from_header, extract_bearer_token_from_header,
21
};
22
pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
23
···
93
}
94
}
95
96
pub struct AuthenticatedUser {
97
pub did: Did,
98
pub key_bytes: Option<Vec<u8>>,
99
-
pub is_oauth: bool,
100
pub is_admin: bool,
101
pub status: AccountStatus,
102
pub scope: Option<String>,
103
pub controller_did: Option<Did>,
104
}
105
106
impl AuthenticatedUser {
···
110
{
111
return ScopePermissions::from_scope_string(Some(scope));
112
}
113
-
if !self.is_oauth {
114
return ScopePermissions::from_scope_string(Some("atproto"));
115
}
116
ScopePermissions::from_scope_string(self.scope.as_deref())
···
348
return Ok(AuthenticatedUser {
349
did: did.clone(),
350
key_bytes: Some(decrypted_key),
351
-
is_oauth: false,
352
is_admin,
353
status,
354
scope: token_data.claims.scope.clone(),
355
controller_did,
356
});
357
}
358
}
···
396
return Ok(AuthenticatedUser {
397
did: Did::new_unchecked(oauth_token.did),
398
key_bytes,
399
-
is_oauth: true,
400
is_admin: oauth_token.is_admin,
401
status,
402
scope: oauth_info.scope,
403
controller_did: oauth_info.controller_did.map(Did::new_unchecked),
404
});
405
} else {
406
return Err(TokenValidationError::TokenExpired);
···
480
Ok(AuthenticatedUser {
481
did: Did::new_unchecked(result.did),
482
key_bytes,
483
-
is_oauth: true,
484
is_admin: user_info.is_admin,
485
status,
486
scope: result.scope,
487
controller_did: None,
488
})
489
}
490
Err(crate::oauth::OAuthError::ExpiredToken(_)) => {
···
3
use std::time::Duration;
4
5
use crate::AccountStatus;
6
+
use crate::api::ApiError;
7
use crate::cache::Cache;
8
use crate::oauth::scopes::ScopePermissions;
9
use crate::types::Did;
···
17
pub mod webauthn;
18
19
pub use extractor::{
20
+
Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown,
21
+
Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header,
22
};
23
pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
24
···
94
}
95
}
96
97
+
pub enum AuthSource {
98
+
Session,
99
+
OAuth,
100
+
Service { claims: ServiceTokenClaims },
101
+
}
102
+
103
+
impl AuthSource {
104
+
pub fn is_oauth(&self) -> bool {
105
+
matches!(self, Self::OAuth)
106
+
}
107
+
108
+
pub fn is_service(&self) -> bool {
109
+
matches!(self, Self::Service { .. })
110
+
}
111
+
112
+
pub fn service_claims(&self) -> Option<&ServiceTokenClaims> {
113
+
match self {
114
+
Self::Service { claims } => Some(claims),
115
+
_ => None,
116
+
}
117
+
}
118
+
}
119
+
120
pub struct AuthenticatedUser {
121
pub did: Did,
122
pub key_bytes: Option<Vec<u8>>,
123
pub is_admin: bool,
124
pub status: AccountStatus,
125
pub scope: Option<String>,
126
pub controller_did: Option<Did>,
127
+
pub auth_source: AuthSource,
128
+
}
129
+
130
+
impl AuthenticatedUser {
131
+
pub fn is_oauth(&self) -> bool {
132
+
self.auth_source.is_oauth()
133
+
}
134
+
135
+
pub fn is_service(&self) -> bool {
136
+
self.auth_source.is_service()
137
+
}
138
+
139
+
pub fn service_claims(&self) -> Option<&ServiceTokenClaims> {
140
+
self.auth_source.service_claims()
141
+
}
142
+
143
+
pub fn require_lxm(&self, expected_lxm: &str) -> Result<(), ApiError> {
144
+
match self.auth_source.service_claims() {
145
+
Some(claims) => match &claims.lxm {
146
+
Some(lxm) if lxm == "*" || lxm == expected_lxm => Ok(()),
147
+
Some(lxm) => Err(ApiError::AuthorizationError(format!(
148
+
"Token lxm '{}' does not permit '{}'",
149
+
lxm, expected_lxm
150
+
))),
151
+
None => Err(ApiError::AuthorizationError(
152
+
"Token missing lxm claim".to_string(),
153
+
)),
154
+
},
155
+
None => Ok(()),
156
+
}
157
+
}
158
+
159
+
pub fn require_user(&self) -> Result<&Self, ApiError> {
160
+
if self.is_service() {
161
+
return Err(ApiError::AuthenticationFailed(Some(
162
+
"User authentication required".to_string(),
163
+
)));
164
+
}
165
+
Ok(self)
166
+
}
167
+
168
+
pub fn as_user(&self) -> Option<&Self> {
169
+
if self.is_service() { None } else { Some(self) }
170
+
}
171
}
172
173
impl AuthenticatedUser {
···
177
{
178
return ScopePermissions::from_scope_string(Some(scope));
179
}
180
+
if !self.is_oauth() {
181
return ScopePermissions::from_scope_string(Some("atproto"));
182
}
183
ScopePermissions::from_scope_string(self.scope.as_deref())
···
415
return Ok(AuthenticatedUser {
416
did: did.clone(),
417
key_bytes: Some(decrypted_key),
418
is_admin,
419
status,
420
scope: token_data.claims.scope.clone(),
421
controller_did,
422
+
auth_source: AuthSource::Session,
423
});
424
}
425
}
···
463
return Ok(AuthenticatedUser {
464
did: Did::new_unchecked(oauth_token.did),
465
key_bytes,
466
is_admin: oauth_token.is_admin,
467
status,
468
scope: oauth_info.scope,
469
controller_did: oauth_info.controller_did.map(Did::new_unchecked),
470
+
auth_source: AuthSource::OAuth,
471
});
472
} else {
473
return Err(TokenValidationError::TokenExpired);
···
547
Ok(AuthenticatedUser {
548
did: Did::new_unchecked(result.did),
549
key_bytes,
550
is_admin: user_info.is_admin,
551
status,
552
scope: result.scope,
553
controller_did: None,
554
+
auth_source: AuthSource::OAuth,
555
})
556
}
557
Err(crate::oauth::OAuthError::ExpiredToken(_)) => {
+11
-2
crates/tranquil-pds/src/lib.rs
+11
-2
crates/tranquil-pds/src/lib.rs
···
528
));
529
let xrpc_service = ServiceBuilder::new()
530
.layer(XrpcProxyLayer::new(state.clone()))
531
-
.service(xrpc_router.with_state(state.clone()));
532
533
let oauth_router = Router::new()
534
.route("/jwks", get(oauth::endpoints::oauth_jwks))
···
568
"/register/complete",
569
post(oauth::endpoints::register_complete),
570
)
571
.route("/authorize/consent", get(oauth::endpoints::consent_get))
572
.route("/authorize/consent", post(oauth::endpoints::consent_post))
573
.route(
···
605
.route(
606
"/sso/check-handle-available",
607
get(sso::endpoints::check_handle_available),
608
-
);
609
610
let well_known_router = Router::new()
611
.route("/did.json", get(api::identity::well_known_did))
···
528
));
529
let xrpc_service = ServiceBuilder::new()
530
.layer(XrpcProxyLayer::new(state.clone()))
531
+
.service(
532
+
xrpc_router
533
+
.layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware))
534
+
.with_state(state.clone()),
535
+
);
536
537
let oauth_router = Router::new()
538
.route("/jwks", get(oauth::endpoints::oauth_jwks))
···
572
"/register/complete",
573
post(oauth::endpoints::register_complete),
574
)
575
+
.route(
576
+
"/establish-session",
577
+
post(oauth::endpoints::establish_session),
578
+
)
579
.route("/authorize/consent", get(oauth::endpoints::consent_get))
580
.route("/authorize/consent", post(oauth::endpoints::consent_post))
581
.route(
···
613
.route(
614
"/sso/check-handle-available",
615
get(sso::endpoints::check_handle_available),
616
+
)
617
+
.layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware));
618
619
let well_known_router = Router::new()
620
.route("/did.json", get(api::identity::well_known_did))
+8
-56
crates/tranquil-pds/src/oauth/endpoints/delegation.rs
+8
-56
crates/tranquil-pds/src/oauth/endpoints/delegation.rs
···
1
-
use crate::auth::{extract_auth_token_from_header, validate_token_with_dpop};
2
use crate::delegation::DelegationActionType;
3
use crate::state::{AppState, RateLimitKind};
4
use crate::types::PlainPassword;
5
-
use crate::util::{build_full_url, extract_client_ip};
6
use axum::{
7
Json,
8
extract::State,
···
463
pub async fn delegation_auth_token(
464
State(state): State<AppState>,
465
headers: HeaderMap,
466
Json(form): Json<DelegationTokenAuthSubmit>,
467
) -> Response {
468
-
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
469
-
470
-
let extracted = match extract_auth_token_from_header(auth_header) {
471
-
Some(e) => e,
472
-
None => {
473
-
return (
474
-
StatusCode::UNAUTHORIZED,
475
-
Json(DelegationAuthResponse {
476
-
success: false,
477
-
needs_totp: None,
478
-
redirect_uri: None,
479
-
error: Some("Missing or invalid authorization header".to_string()),
480
-
}),
481
-
)
482
-
.into_response();
483
-
}
484
-
};
485
-
486
-
let dpop_proof = headers.get("dpop").and_then(|h| h.to_str().ok());
487
-
let uri = build_full_url("/oauth/delegation/auth-token");
488
-
489
-
let auth_user = match validate_token_with_dpop(
490
-
state.user_repo.as_ref(),
491
-
state.oauth_repo.as_ref(),
492
-
&extracted.token,
493
-
extracted.is_dpop,
494
-
dpop_proof,
495
-
"POST",
496
-
&uri,
497
-
false,
498
-
false,
499
-
)
500
-
.await
501
-
{
502
-
Ok(user) => user,
503
-
Err(_) => {
504
-
return (
505
-
StatusCode::UNAUTHORIZED,
506
-
Json(DelegationAuthResponse {
507
-
success: false,
508
-
needs_totp: None,
509
-
redirect_uri: None,
510
-
error: Some("Invalid or expired access token".to_string()),
511
-
}),
512
-
)
513
-
.into_response();
514
-
}
515
-
};
516
-
517
-
let controller_did = auth_user.did;
518
519
let delegated_did: Did = match form.delegated_did.parse() {
520
Ok(d) => d,
···
558
559
let grant = match state
560
.delegation_repo
561
-
.get_delegation(&delegated_did, &controller_did)
562
.await
563
{
564
Ok(Some(g)) => g,
···
599
600
if state
601
.oauth_repo
602
-
.set_controller_did(&request_id, &controller_did)
603
.await
604
.is_err()
605
{
···
622
.delegation_repo
623
.log_delegation_action(
624
&delegated_did,
625
-
&controller_did,
626
-
Some(&controller_did),
627
DelegationActionType::TokenIssued,
628
Some(serde_json::json!({
629
"client_id": request.client_id,
···
1
+
use crate::auth::{Active, Auth};
2
use crate::delegation::DelegationActionType;
3
use crate::state::{AppState, RateLimitKind};
4
use crate::types::PlainPassword;
5
+
use crate::util::extract_client_ip;
6
use axum::{
7
Json,
8
extract::State,
···
463
pub async fn delegation_auth_token(
464
State(state): State<AppState>,
465
headers: HeaderMap,
466
+
auth: Auth<Active>,
467
Json(form): Json<DelegationTokenAuthSubmit>,
468
) -> Response {
469
+
let controller_did = &auth.did;
470
471
let delegated_did: Did = match form.delegated_did.parse() {
472
Ok(d) => d,
···
510
511
let grant = match state
512
.delegation_repo
513
+
.get_delegation(&delegated_did, controller_did)
514
.await
515
{
516
Ok(Some(g)) => g,
···
551
552
if state
553
.oauth_repo
554
+
.set_controller_did(&request_id, controller_did)
555
.await
556
.is_err()
557
{
···
574
.delegation_repo
575
.log_delegation_action(
576
&delegated_did,
577
+
controller_did,
578
+
Some(controller_did),
579
DelegationActionType::TokenIssued,
580
Some(serde_json::json!({
581
"client_id": request.client_id,
+31
-13
crates/tranquil-pds/src/oauth/verify.rs
+31
-13
crates/tranquil-pds/src/oauth/verify.rs
···
10
use sha2::Sha256;
11
use subtle::ConstantTimeEq;
12
use tranquil_db_traits::{OAuthRepository, UserRepository};
13
-
use tranquil_types::TokenId;
14
15
use super::scopes::ScopePermissions;
16
use super::{DPoPVerifier, OAuthError};
···
27
}
28
29
pub struct VerifyResult {
30
-
pub did: String,
31
-
pub token_id: String,
32
-
pub client_id: String,
33
pub scope: Option<String>,
34
}
35
···
91
));
92
}
93
}
94
Ok(VerifyResult {
95
-
did: token_data.did,
96
-
token_id: token_id.to_string(),
97
-
client_id: token_data.client_id,
98
scope: token_data.scope,
99
})
100
}
···
202
}
203
204
pub struct OAuthUser {
205
-
pub did: String,
206
-
pub client_id: Option<String>,
207
pub scope: Option<String>,
208
pub is_oauth: bool,
209
pub permissions: ScopePermissions,
···
382
}
383
384
struct LegacyAuthResult {
385
-
did: String,
386
}
387
388
async fn try_legacy_auth(
···
390
token: &str,
391
) -> Result<LegacyAuthResult, ()> {
392
match crate::auth::validate_bearer_token(user_repo, token).await {
393
-
Ok(user) if !user.is_oauth => Ok(LegacyAuthResult {
394
-
did: user.did.to_string(),
395
-
}),
396
_ => Err(()),
397
}
398
}
···
10
use sha2::Sha256;
11
use subtle::ConstantTimeEq;
12
use tranquil_db_traits::{OAuthRepository, UserRepository};
13
+
use tranquil_types::{ClientId, TokenId};
14
+
15
+
use crate::types::Did;
16
17
use super::scopes::ScopePermissions;
18
use super::{DPoPVerifier, OAuthError};
···
29
}
30
31
pub struct VerifyResult {
32
+
pub did: Did,
33
+
pub token_id: TokenId,
34
+
pub client_id: ClientId,
35
pub scope: Option<String>,
36
}
37
···
93
));
94
}
95
}
96
+
let did: Did = token_data
97
+
.did
98
+
.parse()
99
+
.map_err(|_| OAuthError::InvalidToken("Invalid DID in token".to_string()))?;
100
Ok(VerifyResult {
101
+
did,
102
+
token_id,
103
+
client_id: ClientId::from(token_data.client_id),
104
scope: token_data.scope,
105
})
106
}
···
208
}
209
210
pub struct OAuthUser {
211
+
pub did: Did,
212
+
pub client_id: Option<ClientId>,
213
pub scope: Option<String>,
214
pub is_oauth: bool,
215
pub permissions: ScopePermissions,
···
388
}
389
390
struct LegacyAuthResult {
391
+
did: Did,
392
}
393
394
async fn try_legacy_auth(
···
396
token: &str,
397
) -> Result<LegacyAuthResult, ()> {
398
match crate::auth::validate_bearer_token(user_repo, token).await {
399
+
Ok(user) if !user.is_oauth() => Ok(LegacyAuthResult { did: user.did }),
400
_ => Err(()),
401
}
402
}
403
+
404
+
pub async fn dpop_nonce_middleware(
405
+
req: axum::http::Request<axum::body::Body>,
406
+
next: axum::middleware::Next,
407
+
) -> Response {
408
+
let mut response = next.run(req).await;
409
+
let config = AuthConfig::get();
410
+
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
411
+
let nonce = verifier.generate_nonce();
412
+
if let Ok(nonce_val) = nonce.parse() {
413
+
response.headers_mut().insert("DPoP-Nonce", nonce_val);
414
+
}
415
+
response
416
+
}
+2
-2
crates/tranquil-pds/src/sso/endpoints.rs
+2
-2
crates/tranquil-pds/src/sso/endpoints.rs
···
644
645
pub async fn get_linked_accounts(
646
State(state): State<AppState>,
647
-
crate::auth::extractor::BearerAuth(auth): crate::auth::extractor::BearerAuth,
648
) -> Result<Json<LinkedAccountsResponse>, ApiError> {
649
let identities = state
650
.sso_repo
···
679
680
pub async fn unlink_account(
681
State(state): State<AppState>,
682
-
crate::auth::extractor::BearerAuth(auth): crate::auth::extractor::BearerAuth,
683
Json(input): Json<UnlinkAccountRequest>,
684
) -> Result<Json<UnlinkAccountResponse>, ApiError> {
685
if !state
···
644
645
pub async fn get_linked_accounts(
646
State(state): State<AppState>,
647
+
auth: crate::auth::Auth<crate::auth::Active>,
648
) -> Result<Json<LinkedAccountsResponse>, ApiError> {
649
let identities = state
650
.sso_repo
···
679
680
pub async fn unlink_account(
681
State(state): State<AppState>,
682
+
auth: crate::auth::Auth<crate::auth::Active>,
683
Json(input): Json<UnlinkAccountRequest>,
684
) -> Result<Json<UnlinkAccountResponse>, ApiError> {
685
if !state
+102
crates/tranquil-pds/tests/actor.rs
+102
crates/tranquil-pds/tests/actor.rs
···
436
assert_eq!(declared_age["isOverAge16"], false);
437
assert_eq!(declared_age["isOverAge18"], false);
438
}
439
+
440
+
#[tokio::test]
441
+
async fn test_deactivated_account_can_get_preferences() {
442
+
let client = client();
443
+
let base = base_url().await;
444
+
let (token, _did) = create_account_and_login(&client).await;
445
+
446
+
let prefs = json!({
447
+
"preferences": [
448
+
{
449
+
"$type": "app.bsky.actor.defs#adultContentPref",
450
+
"enabled": true
451
+
}
452
+
]
453
+
});
454
+
let put_resp = client
455
+
.post(format!("{}/xrpc/app.bsky.actor.putPreferences", base))
456
+
.header("Authorization", format!("Bearer {}", token))
457
+
.json(&prefs)
458
+
.send()
459
+
.await
460
+
.unwrap();
461
+
assert_eq!(put_resp.status(), 200);
462
+
463
+
let deactivate = client
464
+
.post(format!(
465
+
"{}/xrpc/com.atproto.server.deactivateAccount",
466
+
base
467
+
))
468
+
.header("Authorization", format!("Bearer {}", token))
469
+
.json(&json!({}))
470
+
.send()
471
+
.await
472
+
.unwrap();
473
+
assert_eq!(deactivate.status(), 200);
474
+
475
+
let get_resp = client
476
+
.get(format!("{}/xrpc/app.bsky.actor.getPreferences", base))
477
+
.header("Authorization", format!("Bearer {}", token))
478
+
.send()
479
+
.await
480
+
.unwrap();
481
+
assert_eq!(
482
+
get_resp.status(),
483
+
200,
484
+
"Deactivated account should still be able to get preferences"
485
+
);
486
+
let body: Value = get_resp.json().await.unwrap();
487
+
let prefs_arr = body["preferences"].as_array().unwrap();
488
+
assert_eq!(prefs_arr.len(), 1);
489
+
}
490
+
491
+
#[tokio::test]
492
+
async fn test_deactivated_account_can_put_preferences() {
493
+
let client = client();
494
+
let base = base_url().await;
495
+
let (token, _did) = create_account_and_login(&client).await;
496
+
497
+
let deactivate = client
498
+
.post(format!(
499
+
"{}/xrpc/com.atproto.server.deactivateAccount",
500
+
base
501
+
))
502
+
.header("Authorization", format!("Bearer {}", token))
503
+
.json(&json!({}))
504
+
.send()
505
+
.await
506
+
.unwrap();
507
+
assert_eq!(deactivate.status(), 200);
508
+
509
+
let prefs = json!({
510
+
"preferences": [
511
+
{
512
+
"$type": "app.bsky.actor.defs#adultContentPref",
513
+
"enabled": true
514
+
}
515
+
]
516
+
});
517
+
let put_resp = client
518
+
.post(format!("{}/xrpc/app.bsky.actor.putPreferences", base))
519
+
.header("Authorization", format!("Bearer {}", token))
520
+
.json(&prefs)
521
+
.send()
522
+
.await
523
+
.unwrap();
524
+
assert_eq!(
525
+
put_resp.status(),
526
+
200,
527
+
"Deactivated account should still be able to put preferences"
528
+
);
529
+
530
+
let get_resp = client
531
+
.get(format!("{}/xrpc/app.bsky.actor.getPreferences", base))
532
+
.header("Authorization", format!("Bearer {}", token))
533
+
.send()
534
+
.await
535
+
.unwrap();
536
+
assert_eq!(get_resp.status(), 200);
537
+
let body: Value = get_resp.json().await.unwrap();
538
+
let prefs_arr = body["preferences"].as_array().unwrap();
539
+
assert_eq!(prefs_arr.len(), 1);
540
+
}
+648
crates/tranquil-pds/tests/auth_extractor.rs
+648
crates/tranquil-pds/tests/auth_extractor.rs
···
···
1
+
mod common;
2
+
mod helpers;
3
+
4
+
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
5
+
use chrono::Utc;
6
+
use common::{base_url, client, create_account_and_login, pds_endpoint};
7
+
use helpers::verify_new_account;
8
+
use reqwest::StatusCode;
9
+
use serde_json::{Value, json};
10
+
use sha2::{Digest, Sha256};
11
+
use wiremock::matchers::{method, path};
12
+
use wiremock::{Mock, MockServer, ResponseTemplate};
13
+
14
+
fn generate_pkce() -> (String, String) {
15
+
let verifier_bytes: [u8; 32] = rand::random();
16
+
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
17
+
let mut hasher = Sha256::new();
18
+
hasher.update(code_verifier.as_bytes());
19
+
let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
20
+
(code_verifier, code_challenge)
21
+
}
22
+
23
+
async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer {
24
+
let mock_server = MockServer::start().await;
25
+
let metadata = json!({
26
+
"client_id": mock_server.uri(),
27
+
"client_name": "Auth Extractor Test Client",
28
+
"redirect_uris": [redirect_uri],
29
+
"grant_types": ["authorization_code", "refresh_token"],
30
+
"response_types": ["code"],
31
+
"token_endpoint_auth_method": "none",
32
+
"dpop_bound_access_tokens": dpop_bound
33
+
});
34
+
Mock::given(method("GET"))
35
+
.and(path("/"))
36
+
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
37
+
.mount(&mock_server)
38
+
.await;
39
+
mock_server
40
+
}
41
+
42
+
async fn get_oauth_session(
43
+
http_client: &reqwest::Client,
44
+
url: &str,
45
+
dpop_bound: bool,
46
+
) -> (String, String, String, String) {
47
+
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
48
+
let handle = format!("ae{}", suffix);
49
+
let password = "AuthExtract123!";
50
+
let create_res = http_client
51
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
52
+
.json(&json!({
53
+
"handle": handle,
54
+
"email": format!("{}@example.com", handle),
55
+
"password": password
56
+
}))
57
+
.send()
58
+
.await
59
+
.unwrap();
60
+
assert_eq!(create_res.status(), StatusCode::OK);
61
+
let account: Value = create_res.json().await.unwrap();
62
+
let did = account["did"].as_str().unwrap().to_string();
63
+
verify_new_account(http_client, &did).await;
64
+
65
+
let redirect_uri = "https://example.com/auth-callback";
66
+
let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await;
67
+
let client_id = mock_client.uri();
68
+
let (code_verifier, code_challenge) = generate_pkce();
69
+
70
+
let par_body: Value = http_client
71
+
.post(format!("{}/oauth/par", url))
72
+
.form(&[
73
+
("response_type", "code"),
74
+
("client_id", &client_id),
75
+
("redirect_uri", redirect_uri),
76
+
("code_challenge", &code_challenge),
77
+
("code_challenge_method", "S256"),
78
+
])
79
+
.send()
80
+
.await
81
+
.unwrap()
82
+
.json()
83
+
.await
84
+
.unwrap();
85
+
let request_uri = par_body["request_uri"].as_str().unwrap();
86
+
87
+
let auth_res = http_client
88
+
.post(format!("{}/oauth/authorize", url))
89
+
.header("Content-Type", "application/json")
90
+
.header("Accept", "application/json")
91
+
.json(&json!({
92
+
"request_uri": request_uri,
93
+
"username": &handle,
94
+
"password": password,
95
+
"remember_device": false
96
+
}))
97
+
.send()
98
+
.await
99
+
.unwrap();
100
+
let auth_body: Value = auth_res.json().await.unwrap();
101
+
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
102
+
103
+
if location.contains("/oauth/consent") {
104
+
let consent_res = http_client
105
+
.post(format!("{}/oauth/authorize/consent", url))
106
+
.header("Content-Type", "application/json")
107
+
.json(&json!({
108
+
"request_uri": request_uri,
109
+
"approved_scopes": ["atproto"],
110
+
"remember": false
111
+
}))
112
+
.send()
113
+
.await
114
+
.unwrap();
115
+
let consent_body: Value = consent_res.json().await.unwrap();
116
+
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
117
+
}
118
+
119
+
let code = location
120
+
.split("code=")
121
+
.nth(1)
122
+
.unwrap()
123
+
.split('&')
124
+
.next()
125
+
.unwrap();
126
+
127
+
let token_body: Value = http_client
128
+
.post(format!("{}/oauth/token", url))
129
+
.form(&[
130
+
("grant_type", "authorization_code"),
131
+
("code", code),
132
+
("redirect_uri", redirect_uri),
133
+
("code_verifier", &code_verifier),
134
+
("client_id", &client_id),
135
+
])
136
+
.send()
137
+
.await
138
+
.unwrap()
139
+
.json()
140
+
.await
141
+
.unwrap();
142
+
143
+
(
144
+
token_body["access_token"].as_str().unwrap().to_string(),
145
+
token_body["refresh_token"].as_str().unwrap().to_string(),
146
+
client_id,
147
+
did,
148
+
)
149
+
}
150
+
151
+
#[tokio::test]
152
+
async fn test_oauth_token_works_with_bearer_auth() {
153
+
let url = base_url().await;
154
+
let http_client = client();
155
+
let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await;
156
+
157
+
let res = http_client
158
+
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
159
+
.bearer_auth(&access_token)
160
+
.send()
161
+
.await
162
+
.unwrap();
163
+
164
+
assert_eq!(
165
+
res.status(),
166
+
StatusCode::OK,
167
+
"OAuth token should work with BearerAuth extractor"
168
+
);
169
+
let body: Value = res.json().await.unwrap();
170
+
assert_eq!(body["did"].as_str().unwrap(), did);
171
+
}
172
+
173
+
#[tokio::test]
174
+
async fn test_session_token_still_works() {
175
+
let url = base_url().await;
176
+
let http_client = client();
177
+
let (jwt, did) = create_account_and_login(&http_client).await;
178
+
179
+
let res = http_client
180
+
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
181
+
.bearer_auth(&jwt)
182
+
.send()
183
+
.await
184
+
.unwrap();
185
+
186
+
assert_eq!(
187
+
res.status(),
188
+
StatusCode::OK,
189
+
"Session token should still work"
190
+
);
191
+
let body: Value = res.json().await.unwrap();
192
+
assert_eq!(body["did"].as_str().unwrap(), did);
193
+
}
194
+
195
+
#[tokio::test]
196
+
async fn test_oauth_admin_extractor_allows_oauth_tokens() {
197
+
let url = base_url().await;
198
+
let http_client = client();
199
+
200
+
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
201
+
let handle = format!("adm{}", suffix);
202
+
let password = "AdminOAuth123!";
203
+
let create_res = http_client
204
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
205
+
.json(&json!({
206
+
"handle": handle,
207
+
"email": format!("{}@example.com", handle),
208
+
"password": password
209
+
}))
210
+
.send()
211
+
.await
212
+
.unwrap();
213
+
assert_eq!(create_res.status(), StatusCode::OK);
214
+
let account: Value = create_res.json().await.unwrap();
215
+
let did = account["did"].as_str().unwrap().to_string();
216
+
verify_new_account(&http_client, &did).await;
217
+
218
+
let pool = common::get_test_db_pool().await;
219
+
sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did)
220
+
.execute(pool)
221
+
.await
222
+
.expect("Failed to mark user as admin");
223
+
224
+
let redirect_uri = "https://example.com/admin-callback";
225
+
let mock_client = setup_mock_client_metadata(redirect_uri, false).await;
226
+
let client_id = mock_client.uri();
227
+
let (code_verifier, code_challenge) = generate_pkce();
228
+
229
+
let par_body: Value = http_client
230
+
.post(format!("{}/oauth/par", url))
231
+
.form(&[
232
+
("response_type", "code"),
233
+
("client_id", &client_id),
234
+
("redirect_uri", redirect_uri),
235
+
("code_challenge", &code_challenge),
236
+
("code_challenge_method", "S256"),
237
+
])
238
+
.send()
239
+
.await
240
+
.unwrap()
241
+
.json()
242
+
.await
243
+
.unwrap();
244
+
let request_uri = par_body["request_uri"].as_str().unwrap();
245
+
246
+
let auth_res = http_client
247
+
.post(format!("{}/oauth/authorize", url))
248
+
.header("Content-Type", "application/json")
249
+
.header("Accept", "application/json")
250
+
.json(&json!({
251
+
"request_uri": request_uri,
252
+
"username": &handle,
253
+
"password": password,
254
+
"remember_device": false
255
+
}))
256
+
.send()
257
+
.await
258
+
.unwrap();
259
+
let auth_body: Value = auth_res.json().await.unwrap();
260
+
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
261
+
if location.contains("/oauth/consent") {
262
+
let consent_res = http_client
263
+
.post(format!("{}/oauth/authorize/consent", url))
264
+
.header("Content-Type", "application/json")
265
+
.json(&json!({
266
+
"request_uri": request_uri,
267
+
"approved_scopes": ["atproto"],
268
+
"remember": false
269
+
}))
270
+
.send()
271
+
.await
272
+
.unwrap();
273
+
let consent_body: Value = consent_res.json().await.unwrap();
274
+
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
275
+
}
276
+
277
+
let code = location
278
+
.split("code=")
279
+
.nth(1)
280
+
.unwrap()
281
+
.split('&')
282
+
.next()
283
+
.unwrap();
284
+
let token_body: Value = http_client
285
+
.post(format!("{}/oauth/token", url))
286
+
.form(&[
287
+
("grant_type", "authorization_code"),
288
+
("code", code),
289
+
("redirect_uri", redirect_uri),
290
+
("code_verifier", &code_verifier),
291
+
("client_id", &client_id),
292
+
])
293
+
.send()
294
+
.await
295
+
.unwrap()
296
+
.json()
297
+
.await
298
+
.unwrap();
299
+
let access_token = token_body["access_token"].as_str().unwrap();
300
+
301
+
let res = http_client
302
+
.get(format!(
303
+
"{}/xrpc/com.atproto.admin.getAccountInfos?dids={}",
304
+
url, did
305
+
))
306
+
.bearer_auth(access_token)
307
+
.send()
308
+
.await
309
+
.unwrap();
310
+
311
+
assert_eq!(
312
+
res.status(),
313
+
StatusCode::OK,
314
+
"OAuth token for admin user should work with admin endpoint"
315
+
);
316
+
}
317
+
318
+
#[tokio::test]
319
+
async fn test_expired_oauth_token_returns_proper_error() {
320
+
let url = base_url().await;
321
+
let http_client = client();
322
+
323
+
let now = Utc::now().timestamp();
324
+
let header = json!({"alg": "HS256", "typ": "at+jwt"});
325
+
let payload = json!({
326
+
"iss": url,
327
+
"sub": "did:plc:test123",
328
+
"aud": url,
329
+
"iat": now - 7200,
330
+
"exp": now - 3600,
331
+
"jti": "expired-token",
332
+
"sid": "expired-session",
333
+
"scope": "atproto",
334
+
"client_id": "https://example.com"
335
+
});
336
+
let fake_token = format!(
337
+
"{}.{}.{}",
338
+
URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
339
+
URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
340
+
URL_SAFE_NO_PAD.encode([1u8; 32])
341
+
);
342
+
343
+
let res = http_client
344
+
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
345
+
.bearer_auth(&fake_token)
346
+
.send()
347
+
.await
348
+
.unwrap();
349
+
350
+
assert_eq!(
351
+
res.status(),
352
+
StatusCode::UNAUTHORIZED,
353
+
"Expired token should be rejected"
354
+
);
355
+
}
356
+
357
+
#[tokio::test]
358
+
async fn test_dpop_nonce_error_has_proper_headers() {
359
+
let url = base_url().await;
360
+
let pds_url = pds_endpoint();
361
+
let http_client = client();
362
+
363
+
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
364
+
let handle = format!("dpop{}", suffix);
365
+
let create_res = http_client
366
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
367
+
.json(&json!({
368
+
"handle": handle,
369
+
"email": format!("{}@test.com", handle),
370
+
"password": "DpopTest123!"
371
+
}))
372
+
.send()
373
+
.await
374
+
.unwrap();
375
+
assert_eq!(create_res.status(), StatusCode::OK);
376
+
let account: Value = create_res.json().await.unwrap();
377
+
let did = account["did"].as_str().unwrap();
378
+
verify_new_account(&http_client, did).await;
379
+
380
+
let redirect_uri = "https://example.com/dpop-callback";
381
+
let mock_server = MockServer::start().await;
382
+
let client_id = mock_server.uri();
383
+
let metadata = json!({
384
+
"client_id": &client_id,
385
+
"client_name": "DPoP Test Client",
386
+
"redirect_uris": [redirect_uri],
387
+
"grant_types": ["authorization_code", "refresh_token"],
388
+
"response_types": ["code"],
389
+
"token_endpoint_auth_method": "none",
390
+
"dpop_bound_access_tokens": true
391
+
});
392
+
Mock::given(method("GET"))
393
+
.and(path("/"))
394
+
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
395
+
.mount(&mock_server)
396
+
.await;
397
+
398
+
let (code_verifier, code_challenge) = generate_pkce();
399
+
let par_body: Value = http_client
400
+
.post(format!("{}/oauth/par", url))
401
+
.form(&[
402
+
("response_type", "code"),
403
+
("client_id", &client_id),
404
+
("redirect_uri", redirect_uri),
405
+
("code_challenge", &code_challenge),
406
+
("code_challenge_method", "S256"),
407
+
])
408
+
.send()
409
+
.await
410
+
.unwrap()
411
+
.json()
412
+
.await
413
+
.unwrap();
414
+
415
+
let request_uri = par_body["request_uri"].as_str().unwrap();
416
+
let auth_res = http_client
417
+
.post(format!("{}/oauth/authorize", url))
418
+
.header("Content-Type", "application/json")
419
+
.header("Accept", "application/json")
420
+
.json(&json!({
421
+
"request_uri": request_uri,
422
+
"username": &handle,
423
+
"password": "DpopTest123!",
424
+
"remember_device": false
425
+
}))
426
+
.send()
427
+
.await
428
+
.unwrap();
429
+
let auth_body: Value = auth_res.json().await.unwrap();
430
+
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
431
+
if location.contains("/oauth/consent") {
432
+
let consent_res = http_client
433
+
.post(format!("{}/oauth/authorize/consent", url))
434
+
.header("Content-Type", "application/json")
435
+
.json(&json!({
436
+
"request_uri": request_uri,
437
+
"approved_scopes": ["atproto"],
438
+
"remember": false
439
+
}))
440
+
.send()
441
+
.await
442
+
.unwrap();
443
+
let consent_body: Value = consent_res.json().await.unwrap();
444
+
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
445
+
}
446
+
447
+
let code = location
448
+
.split("code=")
449
+
.nth(1)
450
+
.unwrap()
451
+
.split('&')
452
+
.next()
453
+
.unwrap();
454
+
455
+
let token_endpoint = format!("{}/oauth/token", pds_url);
456
+
let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None);
457
+
458
+
let token_res = http_client
459
+
.post(format!("{}/oauth/token", url))
460
+
.header("DPoP", &dpop_proof)
461
+
.form(&[
462
+
("grant_type", "authorization_code"),
463
+
("code", code),
464
+
("redirect_uri", redirect_uri),
465
+
("code_verifier", &code_verifier),
466
+
("client_id", &client_id),
467
+
])
468
+
.send()
469
+
.await
470
+
.unwrap();
471
+
472
+
let token_status = token_res.status();
473
+
let token_nonce = token_res
474
+
.headers()
475
+
.get("dpop-nonce")
476
+
.map(|h| h.to_str().unwrap().to_string());
477
+
let token_body: Value = token_res.json().await.unwrap();
478
+
479
+
let access_token = if token_status == StatusCode::OK {
480
+
token_body["access_token"].as_str().unwrap().to_string()
481
+
} else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") {
482
+
let nonce =
483
+
token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error");
484
+
let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce));
485
+
486
+
let retry_res = http_client
487
+
.post(format!("{}/oauth/token", url))
488
+
.header("DPoP", &dpop_proof_with_nonce)
489
+
.form(&[
490
+
("grant_type", "authorization_code"),
491
+
("code", code),
492
+
("redirect_uri", redirect_uri),
493
+
("code_verifier", &code_verifier),
494
+
("client_id", &client_id),
495
+
])
496
+
.send()
497
+
.await
498
+
.unwrap();
499
+
let retry_body: Value = retry_res.json().await.unwrap();
500
+
retry_body["access_token"]
501
+
.as_str()
502
+
.expect("Should get access_token after nonce retry")
503
+
.to_string()
504
+
} else {
505
+
panic!("Token exchange failed unexpectedly: {:?}", token_body);
506
+
};
507
+
508
+
let res = http_client
509
+
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
510
+
.header("Authorization", format!("DPoP {}", access_token))
511
+
.send()
512
+
.await
513
+
.unwrap();
514
+
515
+
assert_eq!(
516
+
res.status(),
517
+
StatusCode::UNAUTHORIZED,
518
+
"DPoP token without proof should fail"
519
+
);
520
+
521
+
let www_auth = res
522
+
.headers()
523
+
.get("www-authenticate")
524
+
.map(|h| h.to_str().unwrap());
525
+
assert!(www_auth.is_some(), "Should have WWW-Authenticate header");
526
+
assert!(
527
+
www_auth.unwrap().contains("use_dpop_nonce"),
528
+
"WWW-Authenticate should indicate dpop nonce required"
529
+
);
530
+
531
+
let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap());
532
+
assert!(nonce.is_some(), "Should return DPoP-Nonce header");
533
+
534
+
let body: Value = res.json().await.unwrap();
535
+
assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce");
536
+
}
537
+
538
+
fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) {
539
+
use p256::ecdsa::{SigningKey, signature::Signer};
540
+
use p256::elliptic_curve::rand_core::OsRng;
541
+
542
+
let signing_key = SigningKey::random(&mut OsRng);
543
+
let verifying_key = signing_key.verifying_key();
544
+
let point = verifying_key.to_encoded_point(false);
545
+
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
546
+
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
547
+
548
+
let jwk = json!({
549
+
"kty": "EC",
550
+
"crv": "P-256",
551
+
"x": x,
552
+
"y": y
553
+
});
554
+
555
+
let header = {
556
+
let h = json!({
557
+
"typ": "dpop+jwt",
558
+
"alg": "ES256",
559
+
"jwk": jwk.clone()
560
+
});
561
+
h
562
+
};
563
+
564
+
let mut payload = json!({
565
+
"jti": uuid::Uuid::new_v4().to_string(),
566
+
"htm": method,
567
+
"htu": uri,
568
+
"iat": Utc::now().timestamp()
569
+
});
570
+
if let Some(n) = nonce {
571
+
payload["nonce"] = json!(n);
572
+
}
573
+
574
+
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
575
+
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
576
+
let signing_input = format!("{}.{}", header_b64, payload_b64);
577
+
578
+
let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
579
+
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
580
+
581
+
let proof = format!("{}.{}", signing_input, sig_b64);
582
+
(jwk, proof)
583
+
}
584
+
585
+
#[tokio::test]
586
+
async fn test_optional_service_auth_extractor_behavior() {
587
+
let url = base_url().await;
588
+
let http_client = client();
589
+
let (access_jwt, did) = create_account_and_login(&http_client).await;
590
+
591
+
let service_auth_res = http_client
592
+
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
593
+
.bearer_auth(&access_jwt)
594
+
.query(&[("aud", "did:web:test.example")])
595
+
.send()
596
+
.await
597
+
.unwrap();
598
+
assert_eq!(service_auth_res.status(), StatusCode::OK);
599
+
let service_body: Value = service_auth_res.json().await.unwrap();
600
+
let service_token = service_body["token"].as_str().unwrap();
601
+
602
+
let no_auth_res = http_client
603
+
.get(format!(
604
+
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid=bafyreifakecidfornowfakecidfornow1234567",
605
+
url, did
606
+
))
607
+
.send()
608
+
.await
609
+
.unwrap();
610
+
assert!(
611
+
no_auth_res.status() == StatusCode::NOT_FOUND
612
+
|| no_auth_res.status() == StatusCode::BAD_REQUEST,
613
+
"getBlob with no auth should reach handler (AuthAny optional path) - got {}",
614
+
no_auth_res.status()
615
+
);
616
+
617
+
let service_auth_blob_res = http_client
618
+
.get(format!(
619
+
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid=bafyreifakecidfornowfakecidfornow1234567",
620
+
url, did
621
+
))
622
+
.bearer_auth(service_token)
623
+
.send()
624
+
.await
625
+
.unwrap();
626
+
assert!(
627
+
service_auth_blob_res.status() == StatusCode::NOT_FOUND
628
+
|| service_auth_blob_res.status() == StatusCode::BAD_REQUEST,
629
+
"getBlob with service auth should reach handler (AuthAny service path) - got {}",
630
+
service_auth_blob_res.status()
631
+
);
632
+
633
+
let user_auth_blob_res = http_client
634
+
.get(format!(
635
+
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid=bafyreifakecidfornowfakecidfornow1234567",
636
+
url, did
637
+
))
638
+
.bearer_auth(&access_jwt)
639
+
.send()
640
+
.await
641
+
.unwrap();
642
+
assert!(
643
+
user_auth_blob_res.status() == StatusCode::NOT_FOUND
644
+
|| user_auth_blob_res.status() == StatusCode::BAD_REQUEST,
645
+
"getBlob with user auth should reach handler (AuthAny user path) - got {}",
646
+
user_auth_blob_res.status()
647
+
);
648
+
}
+3
-3
crates/tranquil-pds/tests/common/mod.rs
+3
-3
crates/tranquil-pds/tests/common/mod.rs
···
1
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
2
use aws_config::BehaviorVersion;
3
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
4
use aws_sdk_s3::Client as S3Client;
5
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
6
use aws_sdk_s3::config::Credentials;
7
use chrono::Utc;
8
use reqwest::{Client, StatusCode, header};
+8
-2
crates/tranquil-pds/tests/oauth_security.rs
+8
-2
crates/tranquil-pds/tests/oauth_security.rs
···
1373
.send()
1374
.await
1375
.unwrap();
1376
-
assert_eq!(token_res.status(), StatusCode::OK, "Token exchange should succeed");
1377
let tokens: Value = token_res.json().await.unwrap();
1378
1379
-
let sub = tokens["sub"].as_str().expect("Token response should have sub claim");
1380
1381
assert_eq!(
1382
sub, delegated_did,
···
1373
.send()
1374
.await
1375
.unwrap();
1376
+
assert_eq!(
1377
+
token_res.status(),
1378
+
StatusCode::OK,
1379
+
"Token exchange should succeed"
1380
+
);
1381
let tokens: Value = token_res.json().await.unwrap();
1382
1383
+
let sub = tokens["sub"]
1384
+
.as_str()
1385
+
.expect("Token response should have sub claim");
1386
1387
assert_eq!(
1388
sub, delegated_did,
+2
crates/tranquil-scopes/Cargo.toml
+2
crates/tranquil-scopes/Cargo.toml
···
7
[dependencies]
8
axum = { workspace = true }
9
futures = { workspace = true }
10
+
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
11
reqwest = { workspace = true }
12
serde = { workspace = true }
13
serde_json = { workspace = true }
14
tokio = { workspace = true }
15
tracing = { workspace = true }
16
+
urlencoding = "2"
+521
-59
crates/tranquil-scopes/src/permission_set.rs
+521
-59
crates/tranquil-scopes/src/permission_set.rs
···
1
use reqwest::Client;
2
use serde::Deserialize;
3
use std::collections::HashMap;
···
17
const CACHE_TTL_SECS: u64 = 3600;
18
19
#[derive(Debug, Deserialize)]
20
struct LexiconDoc {
21
defs: HashMap<String, LexiconDef>,
22
}
···
31
#[derive(Debug, Deserialize)]
32
struct PermissionEntry {
33
resource: String,
34
collection: Option<Vec<String>>,
35
}
36
37
pub async fn expand_include_scopes(scope_string: &str) -> String {
···
39
.split_whitespace()
40
.map(|scope| async move {
41
match scope.strip_prefix("include:") {
42
-
Some(nsid) => {
43
-
let nsid_base = nsid.split('?').next().unwrap_or(nsid);
44
-
expand_permission_set(nsid_base).await.unwrap_or_else(|e| {
45
-
warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original");
46
-
scope.to_string()
47
-
})
48
}
49
None => scope.to_string(),
50
}
···
54
futures::future::join_all(futures).await.join(" ")
55
}
56
57
-
async fn expand_permission_set(nsid: &str) -> Result<String, String> {
58
{
59
let cache = LEXICON_CACHE.read().await;
60
-
if let Some(cached) = cache.get(nsid)
61
&& cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS
62
{
63
debug!(nsid, "Using cached permission set expansion");
···
65
}
66
}
67
68
let parts: Vec<&str> = nsid.split('.').collect();
69
if parts.len() < 3 {
70
return Err(format!("Invalid NSID format: {}", nsid));
71
}
72
73
-
let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect();
74
-
let domain = domain_parts.join(".");
75
-
let path = parts[2..].join("/");
76
77
-
let url = format!("https://{}/lexicons/{}.json", domain, path);
78
-
debug!(nsid, url = %url, "Fetching permission set lexicon");
79
80
let client = Client::builder()
81
.timeout(std::time::Duration::from_secs(10))
82
.build()
83
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
84
85
let response = client
86
.get(&url)
87
.header("Accept", "application/json")
···
96
));
97
}
98
99
-
let lexicon: LexiconDoc = response
100
.json()
101
.await
102
-
.map_err(|e| format!("Failed to parse lexicon: {}", e))?;
103
104
-
let main_def = lexicon
105
-
.defs
106
-
.get("main")
107
-
.ok_or("Missing 'main' definition in lexicon")?;
108
109
-
if main_def.def_type != "permission-set" {
110
-
return Err(format!(
111
-
"Expected permission-set type, got: {}",
112
-
main_def.def_type
113
-
));
114
}
115
116
-
let permissions = main_def
117
-
.permissions
118
-
.as_ref()
119
-
.ok_or("Missing permissions in permission-set")?;
120
121
-
let mut collections: Vec<String> = permissions
122
.iter()
123
-
.filter(|perm| perm.resource == "repo")
124
-
.filter_map(|perm| perm.collection.as_ref())
125
-
.flatten()
126
-
.cloned()
127
-
.collect();
128
129
-
if collections.is_empty() {
130
-
return Err("No repo collections found in permission-set".to_string());
131
}
132
133
-
collections.sort();
134
135
-
let collection_params: Vec<String> = collections
136
.iter()
137
-
.map(|c| format!("collection={}", c))
138
-
.collect();
139
140
-
let expanded = format!("repo?{}", collection_params.join("&"));
141
142
-
{
143
-
let mut cache = LEXICON_CACHE.write().await;
144
-
cache.insert(
145
-
nsid.to_string(),
146
-
CachedLexicon {
147
-
expanded_scope: expanded.clone(),
148
-
cached_at: std::time::Instant::now(),
149
-
},
150
-
);
151
-
}
152
153
-
debug!(nsid, expanded = %expanded, "Successfully expanded permission set");
154
-
Ok(expanded)
155
}
156
157
#[cfg(test)]
158
mod tests {
159
#[test]
160
-
fn test_nsid_to_url() {
161
let nsid = "io.atcr.authFullApp";
162
let parts: Vec<&str> = nsid.split('.').collect();
163
-
let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect();
164
-
let domain = domain_parts.join(".");
165
-
let path = parts[2..].join("/");
166
167
-
assert_eq!(domain, "atcr.io");
168
-
assert_eq!(path, "authFullApp");
169
}
170
}
···
1
+
use hickory_resolver::TokioAsyncResolver;
2
use reqwest::Client;
3
use serde::Deserialize;
4
use std::collections::HashMap;
···
18
const CACHE_TTL_SECS: u64 = 3600;
19
20
#[derive(Debug, Deserialize)]
21
+
struct PlcDocument {
22
+
service: Vec<PlcService>,
23
+
}
24
+
25
+
#[derive(Debug, Deserialize)]
26
+
struct PlcService {
27
+
id: String,
28
+
#[serde(rename = "serviceEndpoint")]
29
+
service_endpoint: String,
30
+
}
31
+
32
+
#[derive(Debug, Deserialize)]
33
+
struct GetRecordResponse {
34
+
value: LexiconDoc,
35
+
}
36
+
37
+
#[derive(Debug, Deserialize)]
38
struct LexiconDoc {
39
defs: HashMap<String, LexiconDef>,
40
}
···
49
#[derive(Debug, Deserialize)]
50
struct PermissionEntry {
51
resource: String,
52
+
action: Option<Vec<String>>,
53
collection: Option<Vec<String>>,
54
+
lxm: Option<Vec<String>>,
55
+
aud: Option<String>,
56
}
57
58
pub async fn expand_include_scopes(scope_string: &str) -> String {
···
60
.split_whitespace()
61
.map(|scope| async move {
62
match scope.strip_prefix("include:") {
63
+
Some(rest) => {
64
+
let (nsid_base, aud) = parse_include_scope(rest);
65
+
expand_permission_set(nsid_base, aud)
66
+
.await
67
+
.unwrap_or_else(|e| {
68
+
warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original");
69
+
scope.to_string()
70
+
})
71
}
72
None => scope.to_string(),
73
}
···
77
futures::future::join_all(futures).await.join(" ")
78
}
79
80
+
fn parse_include_scope(rest: &str) -> (&str, Option<&str>) {
81
+
rest.split_once('?')
82
+
.map(|(nsid, params)| {
83
+
let aud = params.split('&').find_map(|p| p.strip_prefix("aud="));
84
+
(nsid, aud)
85
+
})
86
+
.unwrap_or((rest, None))
87
+
}
88
+
89
+
async fn expand_permission_set(nsid: &str, aud: Option<&str>) -> Result<String, String> {
90
+
let cache_key = match aud {
91
+
Some(a) => format!("{}?aud={}", nsid, a),
92
+
None => nsid.to_string(),
93
+
};
94
+
95
{
96
let cache = LEXICON_CACHE.read().await;
97
+
if let Some(cached) = cache.get(&cache_key)
98
&& cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS
99
{
100
debug!(nsid, "Using cached permission set expansion");
···
102
}
103
}
104
105
+
let lexicon = fetch_lexicon_via_atproto(nsid).await?;
106
+
107
+
let main_def = lexicon
108
+
.defs
109
+
.get("main")
110
+
.ok_or("Missing 'main' definition in lexicon")?;
111
+
112
+
if main_def.def_type != "permission-set" {
113
+
return Err(format!(
114
+
"Expected permission-set type, got: {}",
115
+
main_def.def_type
116
+
));
117
+
}
118
+
119
+
let permissions = main_def
120
+
.permissions
121
+
.as_ref()
122
+
.ok_or("Missing permissions in permission-set")?;
123
+
124
+
let namespace_authority = extract_namespace_authority(nsid);
125
+
let expanded = build_expanded_scopes(permissions, aud, &namespace_authority);
126
+
127
+
if expanded.is_empty() {
128
+
return Err("No valid permissions found in permission-set".to_string());
129
+
}
130
+
131
+
{
132
+
let mut cache = LEXICON_CACHE.write().await;
133
+
cache.insert(
134
+
cache_key,
135
+
CachedLexicon {
136
+
expanded_scope: expanded.clone(),
137
+
cached_at: std::time::Instant::now(),
138
+
},
139
+
);
140
+
}
141
+
142
+
debug!(nsid, expanded = %expanded, "Successfully expanded permission set");
143
+
Ok(expanded)
144
+
}
145
+
146
+
async fn fetch_lexicon_via_atproto(nsid: &str) -> Result<LexiconDoc, String> {
147
let parts: Vec<&str> = nsid.split('.').collect();
148
if parts.len() < 3 {
149
return Err(format!("Invalid NSID format: {}", nsid));
150
}
151
152
+
let authority = parts[..2]
153
+
.iter()
154
+
.rev()
155
+
.cloned()
156
+
.collect::<Vec<_>>()
157
+
.join(".");
158
+
debug!(nsid, authority = %authority, "Resolving lexicon DID authority via DNS");
159
160
+
let did = resolve_lexicon_did_authority(&authority).await?;
161
+
debug!(nsid, did = %did, "Resolved lexicon DID authority");
162
+
163
+
let pds_endpoint = resolve_did_to_pds(&did).await?;
164
+
debug!(nsid, pds = %pds_endpoint, "Resolved DID to PDS endpoint");
165
166
let client = Client::builder()
167
.timeout(std::time::Duration::from_secs(10))
168
.build()
169
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
170
171
+
let url = format!(
172
+
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
173
+
pds_endpoint,
174
+
urlencoding::encode(&did),
175
+
urlencoding::encode(nsid)
176
+
);
177
+
debug!(nsid, url = %url, "Fetching lexicon from PDS");
178
+
179
let response = client
180
.get(&url)
181
.header("Accept", "application/json")
···
190
));
191
}
192
193
+
let record: GetRecordResponse = response
194
.json()
195
.await
196
+
.map_err(|e| format!("Failed to parse lexicon response: {}", e))?;
197
198
+
Ok(record.value)
199
+
}
200
201
+
async fn resolve_lexicon_did_authority(authority: &str) -> Result<String, String> {
202
+
let resolver = TokioAsyncResolver::tokio_from_system_conf()
203
+
.map_err(|e| format!("Failed to create DNS resolver: {}", e))?;
204
+
205
+
let dns_name = format!("_lexicon.{}", authority);
206
+
debug!(dns_name = %dns_name, "Looking up DNS TXT record");
207
+
208
+
let txt_records = resolver
209
+
.txt_lookup(&dns_name)
210
+
.await
211
+
.map_err(|e| format!("DNS lookup failed for {}: {}", dns_name, e))?;
212
+
213
+
txt_records
214
+
.iter()
215
+
.flat_map(|record| record.iter())
216
+
.find_map(|data| {
217
+
let txt = String::from_utf8_lossy(data);
218
+
txt.strip_prefix("did=").map(|did| did.to_string())
219
+
})
220
+
.ok_or_else(|| format!("No valid did= TXT record found at {}", dns_name))
221
+
}
222
+
223
+
async fn resolve_did_to_pds(did: &str) -> Result<String, String> {
224
+
let client = Client::builder()
225
+
.timeout(std::time::Duration::from_secs(10))
226
+
.build()
227
+
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
228
+
229
+
let url = if did.starts_with("did:plc:") {
230
+
format!("https://plc.directory/{}", did)
231
+
} else if did.starts_with("did:web:") {
232
+
let domain = did.strip_prefix("did:web:").unwrap();
233
+
format!("https://{}/.well-known/did.json", domain)
234
+
} else {
235
+
return Err(format!("Unsupported DID method: {}", did));
236
+
};
237
+
238
+
let response = client
239
+
.get(&url)
240
+
.header("Accept", "application/json")
241
+
.send()
242
+
.await
243
+
.map_err(|e| format!("Failed to resolve DID: {}", e))?;
244
+
245
+
if !response.status().is_success() {
246
+
return Err(format!("Failed to resolve DID: HTTP {}", response.status()));
247
}
248
249
+
let doc: PlcDocument = response
250
+
.json()
251
+
.await
252
+
.map_err(|e| format!("Failed to parse DID document: {}", e))?;
253
254
+
doc.service
255
.iter()
256
+
.find(|s| s.id == "#atproto_pds")
257
+
.map(|s| s.service_endpoint.clone())
258
+
.ok_or_else(|| "No #atproto_pds service found in DID document".to_string())
259
+
}
260
261
+
fn extract_namespace_authority(nsid: &str) -> String {
262
+
let parts: Vec<&str> = nsid.split('.').collect();
263
+
if parts.len() >= 2 {
264
+
parts[..parts.len() - 1].join(".")
265
+
} else {
266
+
nsid.to_string()
267
}
268
+
}
269
270
+
fn is_under_authority(target_nsid: &str, authority: &str) -> bool {
271
+
target_nsid.starts_with(authority)
272
+
&& target_nsid
273
+
.chars()
274
+
.nth(authority.len())
275
+
.is_some_and(|c| c == '.')
276
+
}
277
+
278
+
const DEFAULT_ACTIONS: &[&str] = &["create", "update", "delete"];
279
+
280
+
fn build_expanded_scopes(
281
+
permissions: &[PermissionEntry],
282
+
default_aud: Option<&str>,
283
+
namespace_authority: &str,
284
+
) -> String {
285
+
let mut scopes: Vec<String> = Vec::new();
286
287
+
permissions
288
.iter()
289
+
.for_each(|perm| match perm.resource.as_str() {
290
+
"repo" => {
291
+
if let Some(collections) = &perm.collection {
292
+
let actions: Vec<&str> = perm
293
+
.action
294
+
.as_ref()
295
+
.map(|a| a.iter().map(String::as_str).collect())
296
+
.unwrap_or_else(|| DEFAULT_ACTIONS.to_vec());
297
298
+
collections
299
+
.iter()
300
+
.filter(|coll| is_under_authority(coll, namespace_authority))
301
+
.for_each(|coll| {
302
+
actions.iter().for_each(|action| {
303
+
scopes.push(format!("repo:{}?action={}", coll, action));
304
+
});
305
+
});
306
+
}
307
+
}
308
+
"rpc" => {
309
+
if let Some(lxms) = &perm.lxm {
310
+
let perm_aud = perm.aud.as_deref().or(default_aud);
311
312
+
lxms.iter().for_each(|lxm| {
313
+
let scope = match perm_aud {
314
+
Some(aud) => format!("rpc:{}?aud={}", lxm, aud),
315
+
None => format!("rpc:{}", lxm),
316
+
};
317
+
scopes.push(scope);
318
+
});
319
+
}
320
+
}
321
+
_ => {}
322
+
});
323
324
+
scopes.join(" ")
325
}
326
327
#[cfg(test)]
328
mod tests {
329
+
use super::*;
330
+
331
#[test]
332
+
fn test_parse_include_scope() {
333
+
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp");
334
+
assert_eq!(nsid, "io.atcr.authFullApp");
335
+
assert_eq!(aud, None);
336
+
337
+
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?aud=did:web:api.bsky.app");
338
+
assert_eq!(nsid, "io.atcr.authFullApp");
339
+
assert_eq!(aud, Some("did:web:api.bsky.app"));
340
+
}
341
+
342
+
#[test]
343
+
fn test_parse_include_scope_with_multiple_params() {
344
+
let (nsid, aud) =
345
+
parse_include_scope("io.atcr.authFullApp?foo=bar&aud=did:web:example.com&baz=qux");
346
+
assert_eq!(nsid, "io.atcr.authFullApp");
347
+
assert_eq!(aud, Some("did:web:example.com"));
348
+
}
349
+
350
+
#[test]
351
+
fn test_extract_namespace_authority() {
352
+
assert_eq!(
353
+
extract_namespace_authority("io.atcr.authFullApp"),
354
+
"io.atcr"
355
+
);
356
+
assert_eq!(
357
+
extract_namespace_authority("app.bsky.authFullApp"),
358
+
"app.bsky"
359
+
);
360
+
}
361
+
362
+
#[test]
363
+
fn test_extract_namespace_authority_deep_nesting() {
364
+
assert_eq!(
365
+
extract_namespace_authority("io.atcr.sailor.star.collection"),
366
+
"io.atcr.sailor.star"
367
+
);
368
+
}
369
+
370
+
#[test]
371
+
fn test_extract_namespace_authority_single_segment() {
372
+
assert_eq!(extract_namespace_authority("single"), "single");
373
+
}
374
+
375
+
#[test]
376
+
fn test_is_under_authority() {
377
+
assert!(is_under_authority("io.atcr.manifest", "io.atcr"));
378
+
assert!(is_under_authority("io.atcr.sailor.star", "io.atcr"));
379
+
assert!(!is_under_authority("app.bsky.feed.post", "io.atcr"));
380
+
assert!(!is_under_authority("io.atcr", "io.atcr"));
381
+
}
382
+
383
+
#[test]
384
+
fn test_is_under_authority_prefix_collision() {
385
+
assert!(!is_under_authority("io.atcritical.something", "io.atcr"));
386
+
assert!(is_under_authority("io.atcr.something", "io.atcr"));
387
+
}
388
+
389
+
#[test]
390
+
fn test_build_expanded_scopes_repo() {
391
+
let permissions = vec![PermissionEntry {
392
+
resource: "repo".to_string(),
393
+
action: Some(vec!["create".to_string(), "delete".to_string()]),
394
+
collection: Some(vec![
395
+
"io.atcr.manifest".to_string(),
396
+
"io.atcr.sailor.star".to_string(),
397
+
"app.bsky.feed.post".to_string(),
398
+
]),
399
+
lxm: None,
400
+
aud: None,
401
+
}];
402
+
403
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
404
+
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
405
+
assert!(expanded.contains("repo:io.atcr.manifest?action=delete"));
406
+
assert!(expanded.contains("repo:io.atcr.sailor.star?action=create"));
407
+
assert!(!expanded.contains("app.bsky.feed.post"));
408
+
}
409
+
410
+
#[test]
411
+
fn test_build_expanded_scopes_repo_default_actions() {
412
+
let permissions = vec![PermissionEntry {
413
+
resource: "repo".to_string(),
414
+
action: None,
415
+
collection: Some(vec!["io.atcr.manifest".to_string()]),
416
+
lxm: None,
417
+
aud: None,
418
+
}];
419
+
420
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
421
+
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
422
+
assert!(expanded.contains("repo:io.atcr.manifest?action=update"));
423
+
assert!(expanded.contains("repo:io.atcr.manifest?action=delete"));
424
+
}
425
+
426
+
#[test]
427
+
fn test_build_expanded_scopes_rpc() {
428
+
let permissions = vec![PermissionEntry {
429
+
resource: "rpc".to_string(),
430
+
action: None,
431
+
collection: None,
432
+
lxm: Some(vec![
433
+
"io.atcr.getManifest".to_string(),
434
+
"com.atproto.repo.getRecord".to_string(),
435
+
]),
436
+
aud: Some("*".to_string()),
437
+
}];
438
+
439
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
440
+
assert!(expanded.contains("rpc:io.atcr.getManifest?aud=*"));
441
+
assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*"));
442
+
}
443
+
444
+
#[test]
445
+
fn test_build_expanded_scopes_rpc_with_default_aud() {
446
+
let permissions = vec![PermissionEntry {
447
+
resource: "rpc".to_string(),
448
+
action: None,
449
+
collection: None,
450
+
lxm: Some(vec!["io.atcr.getManifest".to_string()]),
451
+
aud: None,
452
+
}];
453
+
454
+
let expanded =
455
+
build_expanded_scopes(&permissions, Some("did:web:api.example.com"), "io.atcr");
456
+
assert!(expanded.contains("rpc:io.atcr.getManifest?aud=did:web:api.example.com"));
457
+
}
458
+
459
+
#[test]
460
+
fn test_build_expanded_scopes_rpc_no_aud() {
461
+
let permissions = vec![PermissionEntry {
462
+
resource: "rpc".to_string(),
463
+
action: None,
464
+
collection: None,
465
+
lxm: Some(vec!["io.atcr.getManifest".to_string()]),
466
+
aud: None,
467
+
}];
468
+
469
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
470
+
assert_eq!(expanded, "rpc:io.atcr.getManifest");
471
+
}
472
+
473
+
#[test]
474
+
fn test_build_expanded_scopes_mixed_permissions() {
475
+
let permissions = vec![
476
+
PermissionEntry {
477
+
resource: "repo".to_string(),
478
+
action: Some(vec!["create".to_string()]),
479
+
collection: Some(vec!["io.atcr.manifest".to_string()]),
480
+
lxm: None,
481
+
aud: None,
482
+
},
483
+
PermissionEntry {
484
+
resource: "rpc".to_string(),
485
+
action: None,
486
+
collection: None,
487
+
lxm: Some(vec!["com.atproto.repo.getRecord".to_string()]),
488
+
aud: Some("*".to_string()),
489
+
},
490
+
];
491
+
492
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
493
+
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
494
+
assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*"));
495
+
}
496
+
497
+
#[test]
498
+
fn test_build_expanded_scopes_unknown_resource_ignored() {
499
+
let permissions = vec![PermissionEntry {
500
+
resource: "unknown".to_string(),
501
+
action: None,
502
+
collection: Some(vec!["io.atcr.manifest".to_string()]),
503
+
lxm: None,
504
+
aud: None,
505
+
}];
506
+
507
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
508
+
assert!(expanded.is_empty());
509
+
}
510
+
511
+
#[test]
512
+
fn test_build_expanded_scopes_empty_permissions() {
513
+
let permissions: Vec<PermissionEntry> = vec![];
514
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
515
+
assert!(expanded.is_empty());
516
+
}
517
+
518
+
#[tokio::test]
519
+
async fn test_expand_include_scopes_passthrough_non_include() {
520
+
let result = expand_include_scopes("atproto transition:generic").await;
521
+
assert_eq!(result, "atproto transition:generic");
522
+
}
523
+
524
+
#[tokio::test]
525
+
async fn test_expand_include_scopes_mixed_with_regular() {
526
+
let result = expand_include_scopes("atproto repo:app.bsky.feed.post?action=create").await;
527
+
assert!(result.contains("atproto"));
528
+
assert!(result.contains("repo:app.bsky.feed.post?action=create"));
529
+
}
530
+
531
+
#[tokio::test]
532
+
async fn test_cache_population_and_retrieval() {
533
+
let cache_key = "test.cached.scope";
534
+
let cached_value = "repo:test.cached.collection?action=create";
535
+
536
+
{
537
+
let mut cache = LEXICON_CACHE.write().await;
538
+
cache.insert(
539
+
cache_key.to_string(),
540
+
CachedLexicon {
541
+
expanded_scope: cached_value.to_string(),
542
+
cached_at: std::time::Instant::now(),
543
+
},
544
+
);
545
+
}
546
+
547
+
let result = expand_permission_set(cache_key, None).await;
548
+
assert!(result.is_ok());
549
+
assert_eq!(result.unwrap(), cached_value);
550
+
551
+
{
552
+
let mut cache = LEXICON_CACHE.write().await;
553
+
cache.remove(cache_key);
554
+
}
555
+
}
556
+
557
+
#[tokio::test]
558
+
async fn test_cache_with_aud_parameter() {
559
+
let nsid = "test.aud.scope";
560
+
let aud = "did:web:example.com";
561
+
let cache_key = format!("{}?aud={}", nsid, aud);
562
+
let cached_value = "rpc:test.aud.method?aud=did:web:example.com";
563
+
564
+
{
565
+
let mut cache = LEXICON_CACHE.write().await;
566
+
cache.insert(
567
+
cache_key.clone(),
568
+
CachedLexicon {
569
+
expanded_scope: cached_value.to_string(),
570
+
cached_at: std::time::Instant::now(),
571
+
},
572
+
);
573
+
}
574
+
575
+
let result = expand_permission_set(nsid, Some(aud)).await;
576
+
assert!(result.is_ok());
577
+
assert_eq!(result.unwrap(), cached_value);
578
+
579
+
{
580
+
let mut cache = LEXICON_CACHE.write().await;
581
+
cache.remove(&cache_key);
582
+
}
583
+
}
584
+
585
+
#[tokio::test]
586
+
async fn test_expired_cache_triggers_refresh() {
587
+
let cache_key = "test.expired.scope";
588
+
589
+
{
590
+
let mut cache = LEXICON_CACHE.write().await;
591
+
cache.insert(
592
+
cache_key.to_string(),
593
+
CachedLexicon {
594
+
expanded_scope: "old_value".to_string(),
595
+
cached_at: std::time::Instant::now()
596
+
- std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
597
+
},
598
+
);
599
+
}
600
+
601
+
let result = expand_permission_set(cache_key, None).await;
602
+
assert!(result.is_err());
603
+
604
+
{
605
+
let mut cache = LEXICON_CACHE.write().await;
606
+
cache.remove(cache_key);
607
+
}
608
+
}
609
+
610
+
#[test]
611
+
fn test_nsid_authority_extraction_for_dns() {
612
let nsid = "io.atcr.authFullApp";
613
let parts: Vec<&str> = nsid.split('.').collect();
614
+
let authority = parts[..2]
615
+
.iter()
616
+
.rev()
617
+
.cloned()
618
+
.collect::<Vec<_>>()
619
+
.join(".");
620
+
assert_eq!(authority, "atcr.io");
621
622
+
let nsid2 = "app.bsky.feed.post";
623
+
let parts2: Vec<&str> = nsid2.split('.').collect();
624
+
let authority2 = parts2[..2]
625
+
.iter()
626
+
.rev()
627
+
.cloned()
628
+
.collect::<Vec<_>>()
629
+
.join(".");
630
+
assert_eq!(authority2, "bsky.app");
631
}
632
}
+38
-3
crates/tranquil-scopes/src/permissions.rs
+38
-3
crates/tranquil-scopes/src/permissions.rs
···
126
return Ok(());
127
}
128
129
-
let has_permission = self.find_repo_scopes().any(|repo_scope| {
130
repo_scope.actions.contains(&action)
131
&& match &repo_scope.collection {
132
None => true,
···
140
}
141
});
142
143
-
if has_permission {
144
Ok(())
145
} else {
146
Err(ScopeError::InsufficientScope {
···
181
return Ok(());
182
}
183
184
let has_permission = self.find_rpc_scopes().any(|rpc_scope| {
185
let lxm_matches = match &rpc_scope.lxm {
186
None => true,
···
195
let aud_matches = match &rpc_scope.aud {
196
None => true,
197
Some(scope_aud) if scope_aud == "*" => true,
198
-
Some(scope_aud) => scope_aud == aud,
199
};
200
201
lxm_matches && aud_matches
···
520
assert!(perms.allows_repo(RepoAction::Update, "any.collection"));
521
assert!(perms.allows_blob("image/png"));
522
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
523
}
524
}
···
126
return Ok(());
127
}
128
129
+
let has_repo_permission = self.find_repo_scopes().any(|repo_scope| {
130
repo_scope.actions.contains(&action)
131
&& match &repo_scope.collection {
132
None => true,
···
140
}
141
});
142
143
+
if has_repo_permission {
144
Ok(())
145
} else {
146
Err(ScopeError::InsufficientScope {
···
181
return Ok(());
182
}
183
184
+
let aud_base = aud.split('#').next().unwrap_or(aud);
185
+
186
let has_permission = self.find_rpc_scopes().any(|rpc_scope| {
187
let lxm_matches = match &rpc_scope.lxm {
188
None => true,
···
197
let aud_matches = match &rpc_scope.aud {
198
None => true,
199
Some(scope_aud) if scope_aud == "*" => true,
200
+
Some(scope_aud) => {
201
+
let scope_aud_base = scope_aud.split('#').next().unwrap_or(scope_aud);
202
+
scope_aud_base == aud_base
203
+
}
204
};
205
206
lxm_matches && aud_matches
···
525
assert!(perms.allows_repo(RepoAction::Update, "any.collection"));
526
assert!(perms.allows_blob("image/png"));
527
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
528
+
}
529
+
530
+
#[test]
531
+
fn test_rpc_scope_with_did_fragment() {
532
+
let perms = ScopePermissions::from_scope_string(Some(
533
+
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app#bsky_appview",
534
+
));
535
+
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed"));
536
+
assert!(perms.allows_rpc(
537
+
"did:web:api.bsky.app#bsky_appview",
538
+
"app.bsky.feed.getAuthorFeed"
539
+
));
540
+
assert!(perms.allows_rpc(
541
+
"did:web:api.bsky.app#other_service",
542
+
"app.bsky.feed.getAuthorFeed"
543
+
));
544
+
assert!(!perms.allows_rpc("did:web:other.app", "app.bsky.feed.getAuthorFeed"));
545
+
assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
546
+
}
547
+
548
+
#[test]
549
+
fn test_rpc_scope_without_fragment_matches_with_fragment() {
550
+
let perms = ScopePermissions::from_scope_string(Some(
551
+
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app",
552
+
));
553
+
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed"));
554
+
assert!(perms.allows_rpc(
555
+
"did:web:api.bsky.app#bsky_appview",
556
+
"app.bsky.feed.getAuthorFeed"
557
+
));
558
}
559
}
+24
-16
crates/tranquil-storage/src/lib.rs
+24
-16
crates/tranquil-storage/src/lib.rs
···
22
const CID_SHARD_PREFIX_LEN: usize = 9;
23
24
fn split_cid_path(key: &str) -> Option<(&str, &str)> {
25
-
let is_cid = key.get(..3).map_or(false, |p| p.eq_ignore_ascii_case("baf"));
26
-
(key.len() > CID_SHARD_PREFIX_LEN && is_cid)
27
-
.then(|| key.split_at(CID_SHARD_PREFIX_LEN))
28
}
29
30
fn validate_key(key: &str) -> Result<(), StorageError> {
···
771
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
772
assert_eq!(
773
split_cid_path(cid),
774
-
Some(("bafkreihd", "wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"))
775
);
776
}
777
···
780
let cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje";
781
assert_eq!(
782
split_cid_path(cid),
783
-
Some(("bafyreigd", "mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje"))
784
);
785
}
786
···
810
let mixed = "BaFkReIhDwDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu";
811
assert_eq!(
812
split_cid_path(upper),
813
-
Some(("BAFKREIHD", "WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU"))
814
);
815
assert_eq!(
816
split_cid_path(mixed),
817
-
Some(("BaFkReIhD", "wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu"))
818
);
819
}
820
···
829
let base = PathBuf::from("/blobs");
830
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
831
832
-
let expected = PathBuf::from("/blobs/bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku");
833
-
let result = split_cid_path(cid).map_or_else(
834
-
|| base.join(cid),
835
-
|(dir, file)| base.join(dir).join(file),
836
-
);
837
assert_eq!(result, expected);
838
}
839
···
843
let key = "temp/abc123";
844
845
let expected = PathBuf::from("/blobs/temp/abc123");
846
-
let result = split_cid_path(key).map_or_else(
847
-
|| base.join(key),
848
-
|(dir, file)| base.join(dir).join(file),
849
-
);
850
assert_eq!(result, expected);
851
}
852
}
···
22
const CID_SHARD_PREFIX_LEN: usize = 9;
23
24
fn split_cid_path(key: &str) -> Option<(&str, &str)> {
25
+
let is_cid = key.get(..3).is_some_and(|p| p.eq_ignore_ascii_case("baf"));
26
+
(key.len() > CID_SHARD_PREFIX_LEN && is_cid).then(|| key.split_at(CID_SHARD_PREFIX_LEN))
27
}
28
29
fn validate_key(key: &str) -> Result<(), StorageError> {
···
770
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
771
assert_eq!(
772
split_cid_path(cid),
773
+
Some((
774
+
"bafkreihd",
775
+
"wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
776
+
))
777
);
778
}
779
···
782
let cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje";
783
assert_eq!(
784
split_cid_path(cid),
785
+
Some((
786
+
"bafyreigd",
787
+
"mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje"
788
+
))
789
);
790
}
791
···
815
let mixed = "BaFkReIhDwDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu";
816
assert_eq!(
817
split_cid_path(upper),
818
+
Some((
819
+
"BAFKREIHD",
820
+
"WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU"
821
+
))
822
);
823
assert_eq!(
824
split_cid_path(mixed),
825
+
Some((
826
+
"BaFkReIhD",
827
+
"wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu"
828
+
))
829
);
830
}
831
···
840
let base = PathBuf::from("/blobs");
841
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
842
843
+
let expected =
844
+
PathBuf::from("/blobs/bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku");
845
+
let result = split_cid_path(cid)
846
+
.map_or_else(|| base.join(cid), |(dir, file)| base.join(dir).join(file));
847
assert_eq!(result, expected);
848
}
849
···
853
let key = "temp/abc123";
854
855
let expected = PathBuf::from("/blobs/temp/abc123");
856
+
let result = split_cid_path(key)
857
+
.map_or_else(|| base.join(key), |(dir, file)| base.join(dir).join(file));
858
assert_eq!(result, expected);
859
}
860
}
+100
-35
frontend/src/lib/api.ts
+100
-35
frontend/src/lib/api.ts
···
16
unsafeAsISODate,
17
unsafeAsRefreshToken,
18
} from "./types/branded.ts";
19
import type {
20
AccountInfo,
21
ApiErrorCode,
···
91
}
92
}
93
94
-
let tokenRefreshCallback: (() => Promise<string | null>) | null = null;
95
96
export function setTokenRefreshCallback(
97
-
callback: () => Promise<string | null>,
98
) {
99
tokenRefreshCallback = callback;
100
}
101
102
interface XrpcOptions {
103
method?: "GET" | "POST";
104
params?: Record<string, string>;
105
body?: unknown;
106
-
token?: string;
107
skipRetry?: boolean;
108
}
109
110
async function xrpc<T>(method: string, options?: XrpcOptions): Promise<T> {
111
-
const { method: httpMethod = "GET", params, body, token, skipRetry } =
112
-
options ?? {};
113
let url = `${API_BASE}/${method}`;
114
if (params) {
115
const searchParams = new URLSearchParams(params);
116
url += `?${searchParams}`;
117
}
118
const headers: Record<string, string> = {};
119
-
if (token) {
120
-
headers["Authorization"] = `Bearer ${token}`;
121
-
}
122
if (body) {
123
headers["Content-Type"] = "application/json";
124
}
125
-
const res = await fetch(url, {
126
-
method: httpMethod,
127
-
headers,
128
-
body: body ? JSON.stringify(body) : undefined,
129
-
});
130
if (!res.ok) {
131
const errData = await res.json().catch(() => ({
132
error: "Unknown",
···
134
}));
135
if (
136
res.status === 401 &&
137
(errData.error === "AuthenticationFailed" ||
138
-
errData.error === "ExpiredToken") &&
139
-
token && tokenRefreshCallback && !skipRetry
140
) {
141
const newToken = await tokenRefreshCallback();
142
if (newToken && newToken !== token) {
···
536
token: AccessToken,
537
file: File,
538
): Promise<UploadBlobResponse> {
539
-
const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", {
540
method: "POST",
541
-
headers: {
542
-
"Authorization": `Bearer ${token}`,
543
-
"Content-Type": file.type,
544
-
},
545
body: file,
546
});
547
if (!res.ok) {
···
1084
},
1085
1086
async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> {
1087
-
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${
1088
-
encodeURIComponent(did)
1089
-
}`;
1090
-
const res = await fetch(url, {
1091
-
headers: { Authorization: `Bearer ${token}` },
1092
-
});
1093
if (!res.ok) {
1094
const errData = await res.json().catch(() => ({
1095
error: "Unknown",
···
1106
1107
async getBackup(token: AccessToken, id: string): Promise<Blob> {
1108
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`;
1109
-
const res = await fetch(url, {
1110
-
headers: { Authorization: `Bearer ${token}` },
1111
-
});
1112
if (!res.ok) {
1113
const errData = await res.json().catch(() => ({
1114
error: "Unknown",
···
1146
},
1147
1148
async importRepo(token: AccessToken, car: Uint8Array): Promise<void> {
1149
-
const url = `${API_BASE}/com.atproto.repo.importRepo`;
1150
-
const res = await fetch(url, {
1151
method: "POST",
1152
-
headers: {
1153
-
Authorization: `Bearer ${token}`,
1154
-
"Content-Type": "application/vnd.ipld.car",
1155
-
},
1156
body: car as unknown as BodyInit,
1157
});
1158
if (!res.ok) {
···
1162
}));
1163
throw new ApiError(res.status, errData.error, errData.message);
1164
}
1165
},
1166
};
1167
···
16
unsafeAsISODate,
17
unsafeAsRefreshToken,
18
} from "./types/branded.ts";
19
+
import {
20
+
createDPoPProofForRequest,
21
+
getDPoPNonce,
22
+
setDPoPNonce,
23
+
} from "./oauth.ts";
24
import type {
25
AccountInfo,
26
ApiErrorCode,
···
96
}
97
}
98
99
+
let tokenRefreshCallback: (() => Promise<AccessToken | null>) | null = null;
100
101
export function setTokenRefreshCallback(
102
+
callback: () => Promise<AccessToken | null>,
103
) {
104
tokenRefreshCallback = callback;
105
}
106
107
+
interface AuthenticatedFetchOptions {
108
+
method?: "GET" | "POST";
109
+
token: AccessToken | RefreshToken;
110
+
headers?: Record<string, string>;
111
+
body?: BodyInit;
112
+
}
113
+
114
+
async function authenticatedFetch(
115
+
url: string,
116
+
options: AuthenticatedFetchOptions,
117
+
): Promise<Response> {
118
+
const { method = "GET", token, headers = {}, body } = options;
119
+
const fullUrl = url.startsWith("http")
120
+
? url
121
+
: `${globalThis.location.origin}${url}`;
122
+
const dpopProof = await createDPoPProofForRequest(method, fullUrl, token);
123
+
const res = await fetch(url, {
124
+
method,
125
+
headers: {
126
+
...headers,
127
+
Authorization: `DPoP ${token}`,
128
+
DPoP: dpopProof,
129
+
},
130
+
body,
131
+
});
132
+
const dpopNonce = res.headers.get("DPoP-Nonce");
133
+
if (dpopNonce) {
134
+
setDPoPNonce(dpopNonce);
135
+
}
136
+
return res;
137
+
}
138
+
139
interface XrpcOptions {
140
method?: "GET" | "POST";
141
params?: Record<string, string>;
142
body?: unknown;
143
+
token?: AccessToken | RefreshToken;
144
skipRetry?: boolean;
145
+
skipDpopRetry?: boolean;
146
}
147
148
async function xrpc<T>(method: string, options?: XrpcOptions): Promise<T> {
149
+
const {
150
+
method: httpMethod = "GET",
151
+
params,
152
+
body,
153
+
token,
154
+
skipRetry,
155
+
skipDpopRetry,
156
+
} = options ?? {};
157
let url = `${API_BASE}/${method}`;
158
if (params) {
159
const searchParams = new URLSearchParams(params);
160
url += `?${searchParams}`;
161
}
162
const headers: Record<string, string> = {};
163
if (body) {
164
headers["Content-Type"] = "application/json";
165
}
166
+
const res = token
167
+
? await authenticatedFetch(url, {
168
+
method: httpMethod,
169
+
token,
170
+
headers,
171
+
body: body ? JSON.stringify(body) : undefined,
172
+
})
173
+
: await fetch(url, {
174
+
method: httpMethod,
175
+
headers,
176
+
body: body ? JSON.stringify(body) : undefined,
177
+
});
178
if (!res.ok) {
179
const errData = await res.json().catch(() => ({
180
error: "Unknown",
···
182
}));
183
if (
184
res.status === 401 &&
185
+
errData.error === "use_dpop_nonce" &&
186
+
token &&
187
+
!skipDpopRetry &&
188
+
getDPoPNonce()
189
+
) {
190
+
return xrpc(method, { ...options, skipDpopRetry: true });
191
+
}
192
+
if (
193
+
res.status === 401 &&
194
(errData.error === "AuthenticationFailed" ||
195
+
errData.error === "ExpiredToken" ||
196
+
errData.error === "OAuthExpiredToken") &&
197
+
token &&
198
+
tokenRefreshCallback &&
199
+
!skipRetry
200
) {
201
const newToken = await tokenRefreshCallback();
202
if (newToken && newToken !== token) {
···
596
token: AccessToken,
597
file: File,
598
): Promise<UploadBlobResponse> {
599
+
const res = await authenticatedFetch("/xrpc/com.atproto.repo.uploadBlob", {
600
method: "POST",
601
+
token,
602
+
headers: { "Content-Type": file.type },
603
body: file,
604
});
605
if (!res.ok) {
···
1142
},
1143
1144
async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> {
1145
+
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`;
1146
+
const res = await authenticatedFetch(url, { token });
1147
if (!res.ok) {
1148
const errData = await res.json().catch(() => ({
1149
error: "Unknown",
···
1160
1161
async getBackup(token: AccessToken, id: string): Promise<Blob> {
1162
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`;
1163
+
const res = await authenticatedFetch(url, { token });
1164
if (!res.ok) {
1165
const errData = await res.json().catch(() => ({
1166
error: "Unknown",
···
1198
},
1199
1200
async importRepo(token: AccessToken, car: Uint8Array): Promise<void> {
1201
+
const res = await authenticatedFetch(`${API_BASE}/com.atproto.repo.importRepo`, {
1202
method: "POST",
1203
+
token,
1204
+
headers: { "Content-Type": "application/vnd.ipld.car" },
1205
body: car as unknown as BodyInit,
1206
});
1207
if (!res.ok) {
···
1211
}));
1212
throw new ApiError(res.status, errData.error, errData.message);
1213
}
1214
+
},
1215
+
1216
+
async establishOAuthSession(token: AccessToken): Promise<{ success: boolean; device_id: string }> {
1217
+
const res = await authenticatedFetch("/oauth/establish-session", {
1218
+
method: "POST",
1219
+
token,
1220
+
headers: { "Content-Type": "application/json" },
1221
+
});
1222
+
if (!res.ok) {
1223
+
const errData = await res.json().catch(() => ({
1224
+
error: "Unknown",
1225
+
message: res.statusText,
1226
+
}));
1227
+
throw new ApiError(res.status, errData.error, errData.message);
1228
+
}
1229
+
return res.json();
1230
},
1231
};
1232
+1
-1
frontend/src/lib/auth.svelte.ts
+1
-1
frontend/src/lib/auth.svelte.ts
+18
-1
frontend/src/lib/migration/atproto-client.ts
+18
-1
frontend/src/lib/migration/atproto-client.ts
···
240
}&cid=${encodeURIComponent(cid)}`;
241
const headers: Record<string, string> = {};
242
if (this.accessToken) {
243
-
headers["Authorization"] = `Bearer ${this.accessToken}`;
244
}
245
const res = await fetch(url, { headers });
246
if (!res.ok) {
247
const err = await res.json().catch(() => ({
248
error: "Unknown",
···
240
}&cid=${encodeURIComponent(cid)}`;
241
const headers: Record<string, string> = {};
242
if (this.accessToken) {
243
+
if (this.dpopKeyPair) {
244
+
headers["Authorization"] = `DPoP ${this.accessToken}`;
245
+
const tokenHash = await computeAccessTokenHash(this.accessToken);
246
+
const dpopProof = await createDPoPProof(
247
+
this.dpopKeyPair,
248
+
"GET",
249
+
url.split("?")[0],
250
+
this.dpopNonce ?? undefined,
251
+
tokenHash,
252
+
);
253
+
headers["DPoP"] = dpopProof;
254
+
} else {
255
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
256
+
}
257
}
258
const res = await fetch(url, { headers });
259
+
const newNonce = res.headers.get("DPoP-Nonce");
260
+
if (newNonce) {
261
+
this.dpopNonce = newNonce;
262
+
}
263
if (!res.ok) {
264
const err = await res.json().catch(() => ({
265
error: "Unknown",
+3
-1
frontend/src/lib/migration/flow.svelte.ts
+3
-1
frontend/src/lib/migration/flow.svelte.ts
+3
-1
frontend/src/lib/migration/offline-flow.svelte.ts
+3
-1
frontend/src/lib/migration/offline-flow.svelte.ts
+3
-3
frontend/src/lib/oauth.ts
+3
-3
frontend/src/lib/oauth.ts
···
246
return base64UrlEncode(hash);
247
}
248
249
-
function getDPoPNonce(): string | null {
250
return sessionStorage.getItem(DPOP_NONCE_KEY);
251
}
252
253
-
function setDPoPNonce(nonce: string): void {
254
sessionStorage.setItem(DPOP_NONCE_KEY, nonce);
255
}
256
257
-
function extractDPoPNonceFromResponse(response: Response): void {
258
const nonce = response.headers.get("DPoP-Nonce");
259
if (nonce) {
260
setDPoPNonce(nonce);
···
246
return base64UrlEncode(hash);
247
}
248
249
+
export function getDPoPNonce(): string | null {
250
return sessionStorage.getItem(DPOP_NONCE_KEY);
251
}
252
253
+
export function setDPoPNonce(nonce: string): void {
254
sessionStorage.setItem(DPOP_NONCE_KEY, nonce);
255
}
256
257
+
export function extractDPoPNonceFromResponse(response: Response): void {
258
const nonce = response.headers.get("DPoP-Nonce");
259
if (nonce) {
260
setDPoPNonce(nonce);
+5
frontend/src/locales/en.json
+5
frontend/src/locales/en.json
···
779
"name": "Manage Account",
780
"description": "Manage account settings and preferences"
781
}
782
+
},
783
+
"unexpectedState": {
784
+
"title": "Unexpected State",
785
+
"description": "The consent page is in an unexpected state. Please check the browser console for errors.",
786
+
"reload": "Reload Page"
787
}
788
},
789
"accounts": {
+5
frontend/src/locales/fi.json
+5
frontend/src/locales/fi.json
···
785
"name": "Hallitse tiliรค",
786
"description": "Hallitse tilin asetuksia ja asetuksia"
787
}
788
+
},
789
+
"unexpectedState": {
790
+
"title": "Odottamaton tila",
791
+
"description": "Suostumussivulla on odottamaton tila. Tarkista selaimen konsoli virheiden varalta.",
792
+
"reload": "Lataa sivu uudelleen"
793
}
794
},
795
"accounts": {
+5
frontend/src/locales/ja.json
+5
frontend/src/locales/ja.json
···
778
"name": "ใขใซใฆใณใ็ฎก็",
779
"description": "ใขใซใฆใณใ่จญๅฎใจ่จญๅฎใ็ฎก็"
780
}
781
+
},
782
+
"unexpectedState": {
783
+
"title": "ไบๆใใชใ็ถๆ
",
784
+
"description": "ๅๆใใผใธใไบๆใใชใ็ถๆ
ใงใใใใฉใฆใถใฎใณใณใฝใผใซใงใจใฉใผใ็ขบ่ชใใฆใใ ใใใ",
785
+
"reload": "ใใผใธใๅ่ชญใฟ่พผใฟ"
786
}
787
},
788
"accounts": {
+5
frontend/src/locales/ko.json
+5
frontend/src/locales/ko.json
···
778
"name": "๊ณ์ ๊ด๋ฆฌ",
779
"description": "๊ณ์ ์ค์ ๋ฐ ํ๊ฒฝ์ค์ ๊ด๋ฆฌ"
780
}
781
+
},
782
+
"unexpectedState": {
783
+
"title": "์๊ธฐ์น ์์ ์ํ",
784
+
"description": "๋์ ํ์ด์ง๊ฐ ์๊ธฐ์น ์์ ์ํ์
๋๋ค. ๋ธ๋ผ์ฐ์ ์ฝ์์์ ์ค๋ฅ๋ฅผ ํ์ธํ์ธ์.",
785
+
"reload": "ํ์ด์ง ์๋ก๊ณ ์นจ"
786
}
787
},
788
"accounts": {
+5
frontend/src/locales/sv.json
+5
frontend/src/locales/sv.json
···
778
"name": "Hantera konto",
779
"description": "Hantera kontoinstรคllningar och preferenser"
780
}
781
+
},
782
+
"unexpectedState": {
783
+
"title": "Ovรคntat tillstรฅnd",
784
+
"description": "Samtyckes-sidan รคr i ett ovรคntat tillstรฅnd. Kontrollera webblรคsarens konsol fรถr fel.",
785
+
"reload": "Ladda om sidan"
786
}
787
},
788
"accounts": {
+5
frontend/src/locales/zh.json
+5
frontend/src/locales/zh.json
···
778
"name": "็ฎก็่ดฆๆท",
779
"description": "็ฎก็่ดฆๆท่ฎพ็ฝฎๅๅๅฅฝ"
780
}
781
+
},
782
+
"unexpectedState": {
783
+
"title": "ๆๅค็ถๆ",
784
+
"description": "ๅๆ้กต้ขๅคไบๆๅค็ถๆใ่ฏทๆฃๆฅๆต่งๅจๆงๅถๅฐไปฅๆฅ็้่ฏฏใ",
785
+
"reload": "้ๆฐๅ ่ฝฝ้กต้ข"
786
}
787
},
788
"accounts": {
+37
-16
frontend/src/routes/Migration.svelte
+37
-16
frontend/src/routes/Migration.svelte
···
2
import { setSession } from '../lib/auth.svelte'
3
import { navigate, routes } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import {
6
createInboundMigrationFlow,
7
createOfflineInboundMigrationFlow,
···
143
direction = 'select'
144
}
145
146
-
function handleInboundComplete() {
147
const session = inboundFlow?.getLocalSession()
148
if (session) {
149
-
setSession({
150
-
did: session.did,
151
-
handle: session.handle,
152
-
accessJwt: session.accessJwt,
153
-
refreshJwt: '',
154
-
})
155
}
156
-
navigate(routes.dashboard)
157
}
158
159
-
function handleOfflineComplete() {
160
const session = offlineFlow?.getLocalSession()
161
if (session) {
162
-
setSession({
163
-
did: session.did,
164
-
handle: session.handle,
165
-
accessJwt: session.accessJwt,
166
-
refreshJwt: '',
167
-
})
168
}
169
-
navigate(routes.dashboard)
170
}
171
</script>
172
···
2
import { setSession } from '../lib/auth.svelte'
3
import { navigate, routes } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
+
import { api } from '../lib/api'
6
+
import { startOAuthLogin } from '../lib/oauth'
7
+
import { unsafeAsAccessToken } from '../lib/types/branded'
8
import {
9
createInboundMigrationFlow,
10
createOfflineInboundMigrationFlow,
···
146
direction = 'select'
147
}
148
149
+
async function handleInboundComplete() {
150
const session = inboundFlow?.getLocalSession()
151
if (session) {
152
+
try {
153
+
await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt))
154
+
clearMigrationState()
155
+
await startOAuthLogin(session.handle)
156
+
} catch (e) {
157
+
console.error('Failed to establish OAuth session, falling back to direct login:', e)
158
+
setSession({
159
+
did: session.did,
160
+
handle: session.handle,
161
+
accessJwt: session.accessJwt,
162
+
refreshJwt: '',
163
+
})
164
+
navigate(routes.dashboard)
165
+
}
166
+
} else {
167
+
navigate(routes.dashboard)
168
}
169
}
170
171
+
async function handleOfflineComplete() {
172
const session = offlineFlow?.getLocalSession()
173
if (session) {
174
+
try {
175
+
await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt))
176
+
clearOfflineState()
177
+
await startOAuthLogin(session.handle)
178
+
} catch (e) {
179
+
console.error('Failed to establish OAuth session, falling back to direct login:', e)
180
+
setSession({
181
+
did: session.did,
182
+
handle: session.handle,
183
+
accessJwt: session.accessJwt,
184
+
refreshJwt: '',
185
+
})
186
+
navigate(routes.dashboard)
187
+
}
188
+
} else {
189
+
navigate(routes.dashboard)
190
}
191
}
192
</script>
193
+5
-31
frontend/src/routes/OAuthAccounts.svelte
+5
-31
frontend/src/routes/OAuthAccounts.svelte
···
196
display: flex;
197
align-items: center;
198
padding: var(--space-4);
199
-
background: var(--bg-card);
200
border: 1px solid var(--border-color);
201
border-radius: var(--radius-xl);
202
cursor: pointer;
203
text-align: left;
204
width: 100%;
205
-
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
206
}
207
208
.account-item:hover:not(.disabled) {
209
border-color: var(--accent);
210
-
box-shadow: var(--shadow-sm);
211
}
212
213
.account-item.disabled {
···
231
color: var(--text-secondary);
232
}
233
234
-
button {
235
-
padding: var(--space-3);
236
-
background: var(--accent);
237
-
color: var(--text-inverse);
238
-
border: none;
239
-
border-radius: var(--radius-md);
240
-
font-size: var(--text-base);
241
-
cursor: pointer;
242
-
}
243
-
244
-
button:hover:not(:disabled) {
245
-
background: var(--accent-hover);
246
-
}
247
-
248
-
button:disabled {
249
-
opacity: 0.6;
250
-
cursor: not-allowed;
251
-
}
252
-
253
-
button.secondary {
254
-
background: transparent;
255
-
color: var(--accent);
256
-
border: 1px solid var(--accent);
257
width: 100%;
258
-
}
259
-
260
-
button.secondary:hover:not(:disabled) {
261
-
background: var(--accent);
262
-
color: var(--text-inverse);
263
}
264
265
.different-account {
···
196
display: flex;
197
align-items: center;
198
padding: var(--space-4);
199
+
background: var(--bg-secondary);
200
border: 1px solid var(--border-color);
201
border-radius: var(--radius-xl);
202
cursor: pointer;
203
text-align: left;
204
width: 100%;
205
+
transition: border-color var(--transition-fast), background var(--transition-fast);
206
}
207
208
.account-item:hover:not(.disabled) {
209
border-color: var(--accent);
210
+
background: var(--bg-tertiary);
211
}
212
213
.account-item.disabled {
···
231
color: var(--text-secondary);
232
}
233
234
+
.different-account {
235
+
margin-top: var(--space-4);
236
width: 100%;
237
}
238
239
.different-account {
+38
-3
frontend/src/routes/OAuthConsent.svelte
+38
-3
frontend/src/routes/OAuthConsent.svelte
···
65
async function fetchConsentData() {
66
const requestUri = getRequestUri()
67
if (!requestUri) {
68
error = $_('oauth.error.genericError')
69
loading = false
70
return
···
74
const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
75
if (!response.ok) {
76
const data = await response.json()
77
error = data.error_description || data.error || $_('oauth.error.genericError')
78
loading = false
79
return
80
}
81
const data: ConsentData = await response.json()
82
consentData = data
83
84
scopeSelections = Object.fromEntries(
···
91
if (!data.show_consent) {
92
await submitConsent()
93
}
94
-
} catch {
95
error = $_('oauth.error.genericError')
96
} finally {
97
loading = false
···
104
}
105
106
async function submitConsent() {
107
-
if (!consentData) return
108
109
submitting = true
110
let approvedScopes = Object.entries(scopeSelections)
···
128
129
if (!response.ok) {
130
const data = await response.json()
131
error = data.error_description || data.error || $_('oauth.error.genericError')
132
submitting = false
133
return
···
136
const data = await response.json()
137
if (data.redirect_uri) {
138
window.location.href = data.redirect_uri
139
}
140
-
} catch {
141
error = $_('oauth.error.genericError')
142
submitting = false
143
}
···
249
<div class="spinner"></div>
250
<p>{$_('common.loading')}</p>
251
</div>
252
{/if}
253
</div>
254
{:else if error}
···
370
</button>
371
<button type="button" class="approve-btn" onclick={submitConsent} disabled={submitting}>
372
{submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')}
373
</button>
374
</div>
375
{/if}
···
65
async function fetchConsentData() {
66
const requestUri = getRequestUri()
67
if (!requestUri) {
68
+
console.error('[OAuthConsent] No request_uri in URL')
69
error = $_('oauth.error.genericError')
70
loading = false
71
return
···
75
const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
76
if (!response.ok) {
77
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
82
}
83
const data: ConsentData = await response.json()
84
+
85
+
if (!data.scopes || !Array.isArray(data.scopes)) {
86
+
console.error('[OAuthConsent] Invalid scopes data:', data.scopes)
87
+
error = 'Invalid consent data received'
88
+
loading = false
89
+
return
90
+
}
91
+
92
consentData = data
93
94
scopeSelections = Object.fromEntries(
···
101
if (!data.show_consent) {
102
await submitConsent()
103
}
104
+
} catch (e) {
105
+
console.error('[OAuthConsent] Error during consent fetch:', e)
106
error = $_('oauth.error.genericError')
107
} finally {
108
loading = false
···
115
}
116
117
async function submitConsent() {
118
+
if (!consentData) {
119
+
console.error('[OAuthConsent] submitConsent called but no consentData')
120
+
return
121
+
}
122
123
submitting = true
124
let approvedScopes = Object.entries(scopeSelections)
···
142
143
if (!response.ok) {
144
const data = await response.json()
145
+
console.error('[OAuthConsent] Submit failed:', data)
146
error = data.error_description || data.error || $_('oauth.error.genericError')
147
submitting = false
148
return
···
151
const data = await response.json()
152
if (data.redirect_uri) {
153
window.location.href = data.redirect_uri
154
+
} else {
155
+
console.error('[OAuthConsent] No redirect_uri in response')
156
+
error = 'Authorization failed - no redirect received'
157
+
submitting = false
158
}
159
+
} catch (e) {
160
+
console.error('[OAuthConsent] Submit error:', e)
161
error = $_('oauth.error.genericError')
162
submitting = false
163
}
···
269
<div class="spinner"></div>
270
<p>{$_('common.loading')}</p>
271
</div>
272
+
{:else}
273
+
<p style="color: var(--text-muted); font-size: 0.875rem;">Loading consent data...</p>
274
{/if}
275
</div>
276
{:else if error}
···
392
</button>
393
<button type="button" class="approve-btn" onclick={submitConsent} disabled={submitting}>
394
{submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')}
395
+
</button>
396
+
</div>
397
+
{:else}
398
+
<div class="error-container">
399
+
<h1>{$_('oauth.consent.unexpectedState.title')}</h1>
400
+
<p style="color: var(--text-secondary);">
401
+
{$_('oauth.consent.unexpectedState.description')}
402
+
</p>
403
+
<p style="color: var(--text-muted); font-size: 0.75rem; font-family: monospace;">
404
+
loading={loading}, error={error ? 'set' : 'null'}, consentData={consentData ? 'set' : 'null'}, submitting={submitting}
405
+
</p>
406
+
<button type="button" onclick={() => window.location.reload()}>
407
+
{$_('oauth.consent.unexpectedState.reload')}
408
</button>
409
</div>
410
{/if}
History
6 rounds
1 comment
expand 0 comments
pull request successfully merged
so three things:
crates/tranquil-pds/src/auth/auth_extractor.rs does not seem to be used at all anywhere?
crates/tranquil-pds/src/api/temp.rs feels like it just ... shouldnt excist based on the name
and i still dont really like these extractors. the separation of inter service auth is weird to me. inter-service auth is a form of user auth. it shouldnt be separated out from the other types. the AuthExtractor should just be AuthExtractor(pub AuthenticatedUser).
i also discovered https://docs.rs/axum/0.8.8/axum/extract/trait.OptionalFromRequestParts.html which we should be able to reduce optional vs not optional with just an AuthExtractor vs Option.
principly id want whether or not its required that the account is active or not and whether its an admin account or not to both also be type safe configurations on the extractor. probably with generics of some sort. but i cant think of a specific design i like right now so. if you come up with one feel free to do it. otherwise we can do it later