- 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
crates/tranquil-pds/src/api/error.rs
+7
crates/tranquil-pds/src/api/error.rs
···
543
543
crate::auth::extractor::AuthError::AccountDeactivated => Self::AccountDeactivated,
544
544
crate::auth::extractor::AuthError::AccountTakedown => Self::AccountTakedown,
545
545
crate::auth::extractor::AuthError::AdminRequired => Self::AdminRequired,
546
+
crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => {
547
+
Self::OAuthExpiredToken(Some(msg))
548
+
}
549
+
crate::auth::extractor::AuthError::UseDpopNonce(_)
550
+
| crate::auth::extractor::AuthError::InvalidDpopProof(_) => {
551
+
Self::AuthenticationFailed(None)
552
+
}
546
553
}
547
554
}
548
555
}
+4
-4
crates/tranquil-pds/src/api/identity/account.rs
+4
-4
crates/tranquil-pds/src/api/identity/account.rs
···
1
1
use super::did::verify_did_web;
2
2
use crate::api::error::ApiError;
3
3
use crate::api::repo::record::utils::create_signed_commit;
4
-
use crate::auth::{ServiceTokenVerifier, is_service_token};
4
+
use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
5
5
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
6
6
use crate::state::{AppState, RateLimitKind};
7
7
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
···
96
96
.into_response();
97
97
}
98
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
-
) {
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
102
let token = extracted.token;
103
103
if is_service_token(&token) {
104
104
let verifier = ServiceTokenVerifier::new();
+12
-3
crates/tranquil-pds/src/api/proxy.rs
+12
-3
crates/tranquil-pds/src/api/proxy.rs
···
267
267
}
268
268
}
269
269
Err(e) => {
270
-
warn!("Token validation failed: {:?}", e);
271
-
if matches!(e, crate::auth::TokenValidationError::OAuthTokenExpired) {
272
-
return ApiError::from(e).into_response();
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;
273
282
}
274
283
}
275
284
}
+17
-80
crates/tranquil-pds/src/api/repo/blob.rs
+17
-80
crates/tranquil-pds/src/api/repo/blob.rs
···
1
1
use crate::api::error::ApiError;
2
-
use crate::auth::{BearerAuthAllowDeactivated, ServiceTokenVerifier, is_service_token};
2
+
use crate::auth::{BearerAuthAllowDeactivated, BlobAuth, BlobAuthResult};
3
3
use crate::delegation::DelegationActionType;
4
4
use crate::state::AppState;
5
5
use crate::types::{CidLink, Did};
···
44
44
pub async fn upload_blob(
45
45
State(state): State<AppState>,
46
46
headers: axum::http::HeaderMap,
47
+
auth: BlobAuth,
47
48
body: Body,
48
49
) -> 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();
50
+
let (did, controller_did): (Did, Option<Did>) = match auth.0 {
51
+
BlobAuthResult::Service { did } => (did, None),
52
+
BlobAuthResult::User(auth_user) => {
53
+
let mime_type_for_check = headers
54
+
.get("content-type")
55
+
.and_then(|h| h.to_str().ok())
56
+
.unwrap_or("application/octet-stream");
57
+
if let Err(e) = crate::auth::scope_check::check_blob_scope(
58
+
auth_user.is_oauth,
59
+
auth_user.scope.as_deref(),
60
+
mime_type_for_check,
61
+
) {
62
+
return e;
128
63
}
64
+
let ctrl_did = auth_user.controller_did.clone();
65
+
(auth_user.did, ctrl_did)
129
66
}
130
67
};
131
68
+4
-12
crates/tranquil-pds/src/api/repo/record/delete.rs
+4
-12
crates/tranquil-pds/src/api/repo/record/delete.rs
···
1
1
use crate::api::error::ApiError;
2
2
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
3
3
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
4
+
use crate::auth::BearerAuth;
4
5
use crate::delegation::DelegationActionType;
5
6
use crate::repo::tracking::TrackingBlockStore;
6
7
use crate::state::AppState;
···
8
9
use axum::{
9
10
Json,
10
11
extract::State,
11
-
http::{HeaderMap, StatusCode},
12
+
http::StatusCode,
12
13
response::{IntoResponse, Response},
13
14
};
14
15
use cid::Cid;
···
39
40
40
41
pub async fn delete_record(
41
42
State(state): State<AppState>,
42
-
headers: HeaderMap,
43
-
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
43
+
auth: BearerAuth,
44
44
Json(input): Json<DeleteRecordInput>,
45
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
-
{
46
+
let auth = match prepare_repo_write(&state, auth.0, &input.repo).await {
55
47
Ok(res) => res,
56
48
Err(err_res) => return err_res,
57
49
};
+7
-47
crates/tranquil-pds/src/api/repo/record/write.rs
+7
-47
crates/tranquil-pds/src/api/repo/record/write.rs
···
3
3
use crate::api::repo::record::utils::{
4
4
CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids,
5
5
};
6
+
use crate::auth::{AuthenticatedUser, BearerAuth};
6
7
use crate::delegation::DelegationActionType;
7
8
use crate::repo::tracking::TrackingBlockStore;
8
9
use crate::state::AppState;
···
10
11
use axum::{
11
12
Json,
12
13
extract::State,
13
-
http::{HeaderMap, StatusCode},
14
+
http::StatusCode,
14
15
response::{IntoResponse, Response},
15
16
};
16
17
use cid::Cid;
···
33
34
34
35
pub async fn prepare_repo_write(
35
36
state: &AppState,
36
-
headers: &HeaderMap,
37
+
auth_user: AuthenticatedUser,
37
38
repo: &AtIdentifier,
38
-
http_method: &str,
39
-
http_uri: &str,
40
39
) -> 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
40
if repo.as_str() != auth_user.did.as_str() {
63
41
return Err(
64
42
ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(),
···
146
124
}
147
125
pub async fn create_record(
148
126
State(state): State<AppState>,
149
-
headers: HeaderMap,
150
-
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
127
+
auth: BearerAuth,
151
128
Json(input): Json<CreateRecordInput>,
152
129
) -> 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
-
{
130
+
let auth = match prepare_repo_write(&state, auth.0, &input.repo).await {
162
131
Ok(res) => res,
163
132
Err(err_res) => return err_res,
164
133
};
···
445
414
}
446
415
pub async fn put_record(
447
416
State(state): State<AppState>,
448
-
headers: HeaderMap,
449
-
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
417
+
auth: BearerAuth,
450
418
Json(input): Json<PutRecordInput>,
451
419
) -> 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
-
{
420
+
let auth = match prepare_repo_write(&state, auth.0, &input.repo).await {
461
421
Ok(res) => res,
462
422
Err(err_res) => return err_res,
463
423
};
+12
-123
crates/tranquil-pds/src/api/server/account_status.rs
+12
-123
crates/tranquil-pds/src/api/server/account_status.rs
···
40
40
41
41
pub async fn check_account_status(
42
42
State(state): State<AppState>,
43
-
headers: axum::http::HeaderMap,
43
+
auth: crate::auth::BearerAuthAllowDeactivated,
44
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
-
};
45
+
let did = auth.0.did;
72
46
let user_id = match state.user_repo.get_id_by_did(&did).await {
73
47
Ok(Some(id)) => id,
74
48
_ => {
···
331
305
332
306
pub async fn activate_account(
333
307
State(state): State<AppState>,
334
-
headers: axum::http::HeaderMap,
308
+
auth: crate::auth::BearerAuthAllowDeactivated,
335
309
) -> Response {
336
310
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
-
};
311
+
let auth_user = auth.0;
370
312
info!(
371
313
"[MIGRATION] activateAccount: Authenticated user did={}",
372
314
auth_user.did
···
528
470
529
471
pub async fn deactivate_account(
530
472
State(state): State<AppState>,
531
-
headers: axum::http::HeaderMap,
473
+
auth: crate::auth::BearerAuth,
532
474
Json(input): Json<DeactivateAccountInput>,
533
475
) -> 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
-
};
476
+
let auth_user = auth.0;
561
477
562
478
if let Err(e) = crate::auth::scope_check::check_account_scope(
563
479
auth_user.is_oauth,
···
607
523
608
524
pub async fn request_account_delete(
609
525
State(state): State<AppState>,
610
-
headers: axum::http::HeaderMap,
526
+
auth: crate::auth::BearerAuthAllowDeactivated,
611
527
) -> 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();
528
+
let did = &auth.0.did;
640
529
641
-
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &did).await {
530
+
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, did).await {
642
531
return crate::api::server::reauth::legacy_mfa_required_response(
643
532
&*state.user_repo,
644
533
&*state.session_repo,
645
-
&did,
534
+
did,
646
535
)
647
536
.await;
648
537
}
649
538
650
-
let user_id = match state.user_repo.get_id_by_did(&did).await {
539
+
let user_id = match state.user_repo.get_id_by_did(did).await {
651
540
Ok(Some(id)) => id,
652
541
_ => {
653
542
return ApiError::InternalError(None).into_response();
···
657
546
let expires_at = Utc::now() + Duration::minutes(15);
658
547
if let Err(e) = state
659
548
.infra_repo
660
-
.create_deletion_request(&confirmation_token, &did, expires_at)
549
+
.create_deletion_request(&confirmation_token, did, expires_at)
661
550
.await
662
551
{
663
552
error!("DB error creating deletion token: {:?}", e);
+5
-59
crates/tranquil-pds/src/api/server/migration.rs
+5
-59
crates/tranquil-pds/src/api/server/migration.rs
···
1
1
use crate::api::ApiError;
2
+
use crate::auth::BearerAuth;
2
3
use crate::state::AppState;
3
4
use axum::{
4
5
Json,
···
35
36
36
37
pub async fn update_did_document(
37
38
State(state): State<AppState>,
38
-
headers: axum::http::HeaderMap,
39
+
auth: BearerAuth,
39
40
Json(input): Json<UpdateDidDocumentInput>,
40
41
) -> 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
-
};
42
+
let auth_user = auth.0;
68
43
69
44
if !auth_user.did.starts_with("did:web:") {
70
45
return ApiError::InvalidRequest(
···
166
141
.into_response()
167
142
}
168
143
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
-
};
144
+
pub async fn get_did_document(State(state): State<AppState>, auth: BearerAuth) -> Response {
145
+
let auth_user = auth.0;
200
146
201
147
if !auth_user.did.starts_with("did:web:") {
202
148
return ApiError::InvalidRequest(
+5
-22
crates/tranquil-pds/src/api/temp.rs
+5
-22
crates/tranquil-pds/src/api/temp.rs
···
1
1
use crate::api::error::ApiError;
2
-
use crate::auth::{BearerAuth, extract_auth_token_from_header, validate_token_with_dpop};
2
+
use crate::auth::{BearerAuth, OptionalBearerAuth};
3
3
use crate::state::AppState;
4
4
use axum::{
5
5
Json,
6
6
extract::State,
7
-
http::HeaderMap,
8
7
response::{IntoResponse, Response},
9
8
};
10
9
use cid::Cid;
···
22
21
pub estimated_time_ms: Option<i64>,
23
22
}
24
23
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()))
24
+
pub async fn check_signup_queue(auth: OptionalBearerAuth) -> Response {
25
+
if let Some(user) = auth.0
26
+
&& user.is_oauth
28
27
{
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
-
}
28
+
return ApiError::Forbidden.into_response();
46
29
}
47
30
Json(CheckSignupQueueOutput {
48
31
activated: true,
+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 BearerAuth 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
+
}
+438
-144
crates/tranquil-pds/src/auth/extractor.rs
+438
-144
crates/tranquil-pds/src/auth/extractor.rs
···
1
1
use axum::{
2
2
extract::FromRequestParts,
3
-
http::{header::AUTHORIZATION, request::Parts},
3
+
http::{StatusCode, header::AUTHORIZATION, request::Parts},
4
4
response::{IntoResponse, Response},
5
5
};
6
+
use tracing::{debug, error, info};
6
7
7
8
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,
9
+
AccountStatus, AuthenticatedUser, ServiceTokenClaims, ServiceTokenVerifier, is_service_token,
10
+
validate_bearer_token, validate_bearer_token_allow_deactivated,
11
+
validate_bearer_token_allow_takendown,
11
12
};
12
13
use crate::api::error::ApiError;
13
14
use crate::state::AppState;
15
+
use crate::types::Did;
14
16
use crate::util::build_full_url;
15
17
16
18
pub struct BearerAuth(pub AuthenticatedUser);
···
24
26
AccountDeactivated,
25
27
AccountTakedown,
26
28
AdminRequired,
29
+
OAuthExpiredToken(String),
30
+
UseDpopNonce(String),
31
+
InvalidDpopProof(String),
27
32
}
28
33
29
34
impl IntoResponse for AuthError {
30
35
fn into_response(self) -> Response {
31
-
ApiError::from(self).into_response()
36
+
match self {
37
+
Self::UseDpopNonce(nonce) => (
38
+
StatusCode::UNAUTHORIZED,
39
+
[
40
+
("DPoP-Nonce", nonce.as_str()),
41
+
("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""),
42
+
],
43
+
axum::Json(serde_json::json!({
44
+
"error": "use_dpop_nonce",
45
+
"message": "DPoP nonce required"
46
+
})),
47
+
)
48
+
.into_response(),
49
+
Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(),
50
+
Self::InvalidDpopProof(msg) => (
51
+
StatusCode::UNAUTHORIZED,
52
+
[("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")],
53
+
axum::Json(serde_json::json!({
54
+
"error": "invalid_dpop_proof",
55
+
"message": msg
56
+
})),
57
+
)
58
+
.into_response(),
59
+
other => ApiError::from(other).into_response(),
60
+
}
32
61
}
33
62
}
34
63
···
107
136
None
108
137
}
109
138
139
+
#[derive(Default)]
140
+
struct StatusCheckFlags {
141
+
allow_deactivated: bool,
142
+
allow_takendown: bool,
143
+
}
144
+
145
+
async fn verify_oauth_token_and_build_user(
146
+
state: &AppState,
147
+
token: &str,
148
+
dpop_proof: Option<&str>,
149
+
method: &str,
150
+
uri: &str,
151
+
flags: StatusCheckFlags,
152
+
) -> Result<AuthenticatedUser, AuthError> {
153
+
match crate::oauth::verify::verify_oauth_access_token(
154
+
state.oauth_repo.as_ref(),
155
+
token,
156
+
dpop_proof,
157
+
method,
158
+
uri,
159
+
)
160
+
.await
161
+
{
162
+
Ok(result) => {
163
+
let user_info = state
164
+
.user_repo
165
+
.get_user_info_by_did(&result.did)
166
+
.await
167
+
.ok()
168
+
.flatten()
169
+
.ok_or(AuthError::AuthenticationFailed)?;
170
+
let status = AccountStatus::from_db_fields(
171
+
user_info.takedown_ref.as_deref(),
172
+
user_info.deactivated_at,
173
+
);
174
+
if !flags.allow_deactivated && status.is_deactivated() {
175
+
return Err(AuthError::AccountDeactivated);
176
+
}
177
+
if !flags.allow_takendown && status.is_takendown() {
178
+
return Err(AuthError::AccountTakedown);
179
+
}
180
+
Ok(AuthenticatedUser {
181
+
did: result.did,
182
+
key_bytes: user_info.key_bytes.and_then(|kb| {
183
+
crate::config::decrypt_key(&kb, user_info.encryption_version).ok()
184
+
}),
185
+
is_oauth: true,
186
+
is_admin: user_info.is_admin,
187
+
status,
188
+
scope: result.scope,
189
+
controller_did: None,
190
+
})
191
+
}
192
+
Err(crate::oauth::OAuthError::ExpiredToken(msg)) => Err(AuthError::OAuthExpiredToken(msg)),
193
+
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => Err(AuthError::UseDpopNonce(nonce)),
194
+
Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => {
195
+
Err(AuthError::InvalidDpopProof(msg))
196
+
}
197
+
Err(_) => Err(AuthError::AuthenticationFailed),
198
+
}
199
+
}
200
+
110
201
impl FromRequestParts<AppState> for BearerAuth {
111
202
type Rejection = AuthError;
112
203
···
124
215
let extracted =
125
216
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
126
217
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),
218
+
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
219
+
let method = parts.method.as_str();
220
+
let uri = build_full_url(&parts.uri.to_string());
221
+
222
+
match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await {
223
+
Ok(user) if !user.is_oauth => {
224
+
return if user.status.is_deactivated() {
225
+
Err(AuthError::AccountDeactivated)
226
+
} else if user.status.is_takendown() {
227
+
Err(AuthError::AccountTakedown)
228
+
} else {
229
+
Ok(BearerAuth(user))
230
+
};
150
231
}
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),
232
+
Ok(_) => {}
233
+
Err(super::TokenValidationError::AccountDeactivated) => {
234
+
return Err(AuthError::AccountDeactivated);
235
+
}
236
+
Err(super::TokenValidationError::AccountTakedown) => {
237
+
return Err(AuthError::AccountTakedown);
238
+
}
239
+
Err(super::TokenValidationError::TokenExpired) => {
240
+
info!("JWT access token expired in BearerAuth, returning ExpiredToken");
241
+
return Err(AuthError::TokenExpired);
164
242
}
243
+
Err(_) => {}
165
244
}
245
+
246
+
verify_oauth_token_and_build_user(
247
+
state,
248
+
&extracted.token,
249
+
dpop_proof,
250
+
method,
251
+
&uri,
252
+
StatusCheckFlags::default(),
253
+
)
254
+
.await
255
+
.map(BearerAuth)
166
256
}
167
257
}
168
258
···
185
275
let extracted =
186
276
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
187
277
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());
278
+
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
279
+
let method = parts.method.as_str();
280
+
let uri = build_full_url(&parts.uri.to_string());
192
281
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
-
)
282
+
match validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token)
204
283
.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),
284
+
{
285
+
Ok(user) if !user.is_oauth => {
286
+
return if user.status.is_takendown() {
287
+
Err(AuthError::AccountTakedown)
288
+
} else {
289
+
Ok(BearerAuthAllowDeactivated(user))
290
+
};
210
291
}
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),
292
+
Ok(_) => {}
293
+
Err(super::TokenValidationError::AccountTakedown) => {
294
+
return Err(AuthError::AccountTakedown);
295
+
}
296
+
Err(super::TokenValidationError::TokenExpired) => {
297
+
return Err(AuthError::TokenExpired);
223
298
}
299
+
Err(_) => {}
224
300
}
301
+
302
+
verify_oauth_token_and_build_user(
303
+
state,
304
+
&extracted.token,
305
+
dpop_proof,
306
+
method,
307
+
&uri,
308
+
StatusCheckFlags {
309
+
allow_deactivated: true,
310
+
allow_takendown: false,
311
+
},
312
+
)
313
+
.await
314
+
.map(BearerAuthAllowDeactivated)
225
315
}
226
316
}
227
317
···
244
334
let extracted =
245
335
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
246
336
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());
337
+
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
338
+
let method = parts.method.as_str();
339
+
let uri = build_full_url(&parts.uri.to_string());
251
340
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
-
)
341
+
match validate_bearer_token_allow_takendown(state.user_repo.as_ref(), &extracted.token)
263
342
.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),
343
+
{
344
+
Ok(user) if !user.is_oauth => {
345
+
return if user.status.is_deactivated() {
346
+
Err(AuthError::AccountDeactivated)
347
+
} else {
348
+
Ok(BearerAuthAllowTakendown(user))
349
+
};
269
350
}
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),
351
+
Ok(_) => {}
352
+
Err(super::TokenValidationError::AccountDeactivated) => {
353
+
return Err(AuthError::AccountDeactivated);
354
+
}
355
+
Err(super::TokenValidationError::TokenExpired) => {
356
+
return Err(AuthError::TokenExpired);
278
357
}
358
+
Err(_) => {}
279
359
}
360
+
361
+
verify_oauth_token_and_build_user(
362
+
state,
363
+
&extracted.token,
364
+
dpop_proof,
365
+
method,
366
+
&uri,
367
+
StatusCheckFlags {
368
+
allow_deactivated: false,
369
+
allow_takendown: true,
370
+
},
371
+
)
372
+
.await
373
+
.map(BearerAuthAllowTakendown)
280
374
}
281
375
}
282
376
···
299
393
let extracted =
300
394
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
301
395
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());
396
+
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
397
+
let method = parts.method.as_str();
398
+
let uri = build_full_url(&parts.uri.to_string());
306
399
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) => {
400
+
match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await {
401
+
Ok(user) if !user.is_oauth => {
402
+
if user.status.is_deactivated() {
322
403
return Err(AuthError::AccountDeactivated);
323
404
}
324
-
Err(TokenValidationError::AccountTakedown) => {
405
+
if user.status.is_takendown() {
325
406
return Err(AuthError::AccountTakedown);
326
407
}
327
-
Err(TokenValidationError::TokenExpired) => {
328
-
return Err(AuthError::TokenExpired);
408
+
if !user.is_admin {
409
+
return Err(AuthError::AdminRequired);
329
410
}
330
-
Err(_) => return Err(AuthError::AuthenticationFailed),
411
+
return Ok(BearerAuthAdmin(user));
331
412
}
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),
413
+
Ok(_) => {}
414
+
Err(super::TokenValidationError::AccountDeactivated) => {
415
+
return Err(AuthError::AccountDeactivated);
351
416
}
352
-
};
417
+
Err(super::TokenValidationError::AccountTakedown) => {
418
+
return Err(AuthError::AccountTakedown);
419
+
}
420
+
Err(super::TokenValidationError::TokenExpired) => {
421
+
return Err(AuthError::TokenExpired);
422
+
}
423
+
Err(_) => {}
424
+
}
425
+
426
+
let user = verify_oauth_token_and_build_user(
427
+
state,
428
+
&extracted.token,
429
+
dpop_proof,
430
+
method,
431
+
&uri,
432
+
StatusCheckFlags::default(),
433
+
)
434
+
.await?;
353
435
354
436
if !user.is_admin {
355
437
return Err(AuthError::AdminRequired);
···
358
440
}
359
441
}
360
442
443
+
pub struct OptionalBearerAuth(pub Option<AuthenticatedUser>);
444
+
445
+
impl FromRequestParts<AppState> for OptionalBearerAuth {
446
+
type Rejection = AuthError;
447
+
448
+
async fn from_request_parts(
449
+
parts: &mut Parts,
450
+
state: &AppState,
451
+
) -> Result<Self, Self::Rejection> {
452
+
let auth_header = match parts.headers.get(AUTHORIZATION) {
453
+
Some(h) => match h.to_str() {
454
+
Ok(s) => s,
455
+
Err(_) => return Ok(OptionalBearerAuth(None)),
456
+
},
457
+
None => return Ok(OptionalBearerAuth(None)),
458
+
};
459
+
460
+
let extracted = match extract_auth_token_from_header(Some(auth_header)) {
461
+
Some(e) => e,
462
+
None => return Ok(OptionalBearerAuth(None)),
463
+
};
464
+
465
+
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
466
+
let method = parts.method.as_str();
467
+
let uri = build_full_url(&parts.uri.to_string());
468
+
469
+
if let Ok(user) = validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await
470
+
&& !user.is_oauth
471
+
{
472
+
return if user.status.is_deactivated() || user.status.is_takendown() {
473
+
Ok(OptionalBearerAuth(None))
474
+
} else {
475
+
Ok(OptionalBearerAuth(Some(user)))
476
+
};
477
+
}
478
+
479
+
Ok(OptionalBearerAuth(
480
+
verify_oauth_token_and_build_user(
481
+
state,
482
+
&extracted.token,
483
+
dpop_proof,
484
+
method,
485
+
&uri,
486
+
StatusCheckFlags::default(),
487
+
)
488
+
.await
489
+
.ok(),
490
+
))
491
+
}
492
+
}
493
+
494
+
pub struct ServiceAuth {
495
+
pub claims: ServiceTokenClaims,
496
+
pub did: Did,
497
+
}
498
+
499
+
impl FromRequestParts<AppState> for ServiceAuth {
500
+
type Rejection = AuthError;
501
+
502
+
async fn from_request_parts(
503
+
parts: &mut Parts,
504
+
_state: &AppState,
505
+
) -> Result<Self, Self::Rejection> {
506
+
let auth_header = parts
507
+
.headers
508
+
.get(AUTHORIZATION)
509
+
.ok_or(AuthError::MissingToken)?
510
+
.to_str()
511
+
.map_err(|_| AuthError::InvalidFormat)?;
512
+
513
+
let extracted =
514
+
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
515
+
516
+
if !is_service_token(&extracted.token) {
517
+
return Err(AuthError::InvalidFormat);
518
+
}
519
+
520
+
let verifier = ServiceTokenVerifier::new();
521
+
let claims = verifier
522
+
.verify_service_token(&extracted.token, None)
523
+
.await
524
+
.map_err(|e| {
525
+
error!("Service token verification failed: {:?}", e);
526
+
AuthError::AuthenticationFailed
527
+
})?;
528
+
529
+
let did: Did = claims
530
+
.iss
531
+
.parse()
532
+
.map_err(|_| AuthError::AuthenticationFailed)?;
533
+
534
+
debug!("Service token verified for DID: {}", did);
535
+
536
+
Ok(ServiceAuth { claims, did })
537
+
}
538
+
}
539
+
540
+
pub struct OptionalServiceAuth(pub Option<ServiceTokenClaims>);
541
+
542
+
impl FromRequestParts<AppState> for OptionalServiceAuth {
543
+
type Rejection = std::convert::Infallible;
544
+
545
+
async fn from_request_parts(
546
+
parts: &mut Parts,
547
+
_state: &AppState,
548
+
) -> Result<Self, Self::Rejection> {
549
+
let auth_header = match parts.headers.get(AUTHORIZATION) {
550
+
Some(h) => match h.to_str() {
551
+
Ok(s) => s,
552
+
Err(_) => return Ok(OptionalServiceAuth(None)),
553
+
},
554
+
None => return Ok(OptionalServiceAuth(None)),
555
+
};
556
+
557
+
let extracted = match extract_auth_token_from_header(Some(auth_header)) {
558
+
Some(e) => e,
559
+
None => return Ok(OptionalServiceAuth(None)),
560
+
};
561
+
562
+
if !is_service_token(&extracted.token) {
563
+
return Ok(OptionalServiceAuth(None));
564
+
}
565
+
566
+
let verifier = ServiceTokenVerifier::new();
567
+
match verifier.verify_service_token(&extracted.token, None).await {
568
+
Ok(claims) => {
569
+
debug!("Service token verified for DID: {}", claims.iss);
570
+
Ok(OptionalServiceAuth(Some(claims)))
571
+
}
572
+
Err(e) => {
573
+
debug!("Service token verification failed (optional): {:?}", e);
574
+
Ok(OptionalServiceAuth(None))
575
+
}
576
+
}
577
+
}
578
+
}
579
+
580
+
pub enum BlobAuthResult {
581
+
Service { did: Did },
582
+
User(AuthenticatedUser),
583
+
}
584
+
585
+
pub struct BlobAuth(pub BlobAuthResult);
586
+
587
+
impl FromRequestParts<AppState> for BlobAuth {
588
+
type Rejection = AuthError;
589
+
590
+
async fn from_request_parts(
591
+
parts: &mut Parts,
592
+
state: &AppState,
593
+
) -> Result<Self, Self::Rejection> {
594
+
let auth_header = parts
595
+
.headers
596
+
.get(AUTHORIZATION)
597
+
.ok_or(AuthError::MissingToken)?
598
+
.to_str()
599
+
.map_err(|_| AuthError::InvalidFormat)?;
600
+
601
+
let extracted =
602
+
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
603
+
604
+
if is_service_token(&extracted.token) {
605
+
debug!("Verifying service token for blob upload");
606
+
let verifier = ServiceTokenVerifier::new();
607
+
let claims = verifier
608
+
.verify_service_token(&extracted.token, Some("com.atproto.repo.uploadBlob"))
609
+
.await
610
+
.map_err(|e| {
611
+
error!("Service token verification failed: {:?}", e);
612
+
AuthError::AuthenticationFailed
613
+
})?;
614
+
615
+
let did: Did = claims
616
+
.iss
617
+
.parse()
618
+
.map_err(|_| AuthError::AuthenticationFailed)?;
619
+
620
+
debug!("Service token verified for DID: {}", did);
621
+
return Ok(BlobAuth(BlobAuthResult::Service { did }));
622
+
}
623
+
624
+
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
625
+
let uri = build_full_url("/xrpc/com.atproto.repo.uploadBlob");
626
+
627
+
if let Ok(user) =
628
+
validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token)
629
+
.await
630
+
&& !user.is_oauth
631
+
{
632
+
return if user.status.is_takendown() {
633
+
Err(AuthError::AccountTakedown)
634
+
} else {
635
+
Ok(BlobAuth(BlobAuthResult::User(user)))
636
+
};
637
+
}
638
+
639
+
verify_oauth_token_and_build_user(
640
+
state,
641
+
&extracted.token,
642
+
dpop_proof,
643
+
"POST",
644
+
&uri,
645
+
StatusCheckFlags {
646
+
allow_deactivated: true,
647
+
allow_takendown: false,
648
+
},
649
+
)
650
+
.await
651
+
.map(|user| BlobAuth(BlobAuthResult::User(user)))
652
+
}
653
+
}
654
+
361
655
#[cfg(test)]
362
656
mod tests {
363
657
use super::*;
+2
-1
crates/tranquil-pds/src/auth/mod.rs
+2
-1
crates/tranquil-pds/src/auth/mod.rs
···
16
16
pub mod webauthn;
17
17
18
18
pub use extractor::{
19
-
AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,
19
+
AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, BlobAuth, BlobAuthResult,
20
+
ExtractedToken, OptionalBearerAuth, OptionalServiceAuth, ServiceAuth,
20
21
extract_auth_token_from_header, extract_bearer_token_from_header,
21
22
};
22
23
pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
+11
-2
crates/tranquil-pds/src/lib.rs
+11
-2
crates/tranquil-pds/src/lib.rs
···
528
528
));
529
529
let xrpc_service = ServiceBuilder::new()
530
530
.layer(XrpcProxyLayer::new(state.clone()))
531
-
.service(xrpc_router.with_state(state.clone()));
531
+
.service(
532
+
xrpc_router
533
+
.layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware))
534
+
.with_state(state.clone()),
535
+
);
532
536
533
537
let oauth_router = Router::new()
534
538
.route("/jwks", get(oauth::endpoints::oauth_jwks))
···
568
572
"/register/complete",
569
573
post(oauth::endpoints::register_complete),
570
574
)
575
+
.route(
576
+
"/establish-session",
577
+
post(oauth::endpoints::establish_session),
578
+
)
571
579
.route("/authorize/consent", get(oauth::endpoints::consent_get))
572
580
.route("/authorize/consent", post(oauth::endpoints::consent_post))
573
581
.route(
···
605
613
.route(
606
614
"/sso/check-handle-available",
607
615
get(sso::endpoints::check_handle_available),
608
-
);
616
+
)
617
+
.layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware));
609
618
610
619
let well_known_router = Router::new()
611
620
.route("/did.json", get(api::identity::well_known_did))
+4
-52
crates/tranquil-pds/src/oauth/endpoints/delegation.rs
+4
-52
crates/tranquil-pds/src/oauth/endpoints/delegation.rs
···
1
-
use crate::auth::{extract_auth_token_from_header, validate_token_with_dpop};
1
+
use crate::auth::BearerAuth;
2
2
use crate::delegation::DelegationActionType;
3
3
use crate::state::{AppState, RateLimitKind};
4
4
use crate::types::PlainPassword;
5
-
use crate::util::{build_full_url, extract_client_ip};
5
+
use crate::util::extract_client_ip;
6
6
use axum::{
7
7
Json,
8
8
extract::State,
···
463
463
pub async fn delegation_auth_token(
464
464
State(state): State<AppState>,
465
465
headers: HeaderMap,
466
+
auth: BearerAuth,
466
467
Json(form): Json<DelegationTokenAuthSubmit>,
467
468
) -> 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;
469
+
let controller_did = auth.0.did;
518
470
519
471
let delegated_did: Did = match form.delegated_did.parse() {
520
472
Ok(d) => d,
+31
-13
crates/tranquil-pds/src/oauth/verify.rs
+31
-13
crates/tranquil-pds/src/oauth/verify.rs
···
10
10
use sha2::Sha256;
11
11
use subtle::ConstantTimeEq;
12
12
use tranquil_db_traits::{OAuthRepository, UserRepository};
13
-
use tranquil_types::TokenId;
13
+
use tranquil_types::{ClientId, TokenId};
14
+
15
+
use crate::types::Did;
14
16
15
17
use super::scopes::ScopePermissions;
16
18
use super::{DPoPVerifier, OAuthError};
···
27
29
}
28
30
29
31
pub struct VerifyResult {
30
-
pub did: String,
31
-
pub token_id: String,
32
-
pub client_id: String,
32
+
pub did: Did,
33
+
pub token_id: TokenId,
34
+
pub client_id: ClientId,
33
35
pub scope: Option<String>,
34
36
}
35
37
···
91
93
));
92
94
}
93
95
}
96
+
let did: Did = token_data
97
+
.did
98
+
.parse()
99
+
.map_err(|_| OAuthError::InvalidToken("Invalid DID in token".to_string()))?;
94
100
Ok(VerifyResult {
95
-
did: token_data.did,
96
-
token_id: token_id.to_string(),
97
-
client_id: token_data.client_id,
101
+
did,
102
+
token_id,
103
+
client_id: ClientId::from(token_data.client_id),
98
104
scope: token_data.scope,
99
105
})
100
106
}
···
202
208
}
203
209
204
210
pub struct OAuthUser {
205
-
pub did: String,
206
-
pub client_id: Option<String>,
211
+
pub did: Did,
212
+
pub client_id: Option<ClientId>,
207
213
pub scope: Option<String>,
208
214
pub is_oauth: bool,
209
215
pub permissions: ScopePermissions,
···
382
388
}
383
389
384
390
struct LegacyAuthResult {
385
-
did: String,
391
+
did: Did,
386
392
}
387
393
388
394
async fn try_legacy_auth(
···
390
396
token: &str,
391
397
) -> Result<LegacyAuthResult, ()> {
392
398
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
-
}),
399
+
Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }),
396
400
_ => Err(()),
397
401
}
398
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
+
}
+583
crates/tranquil-pds/tests/auth_extractor.rs
+583
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
+
}
+3
-3
crates/tranquil-pds/tests/common/mod.rs
+3
-3
crates/tranquil-pds/tests/common/mod.rs
···
1
-
#[cfg(feature = "s3-storage")]
1
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
2
2
use aws_config::BehaviorVersion;
3
-
#[cfg(feature = "s3-storage")]
3
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
4
4
use aws_sdk_s3::Client as S3Client;
5
-
#[cfg(feature = "s3-storage")]
5
+
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
6
6
use aws_sdk_s3::config::Credentials;
7
7
use chrono::Utc;
8
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
1373
.send()
1374
1374
.await
1375
1375
.unwrap();
1376
-
assert_eq!(token_res.status(), StatusCode::OK, "Token exchange should succeed");
1376
+
assert_eq!(
1377
+
token_res.status(),
1378
+
StatusCode::OK,
1379
+
"Token exchange should succeed"
1380
+
);
1377
1381
let tokens: Value = token_res.json().await.unwrap();
1378
1382
1379
-
let sub = tokens["sub"].as_str().expect("Token response should have sub claim");
1383
+
let sub = tokens["sub"]
1384
+
.as_str()
1385
+
.expect("Token response should have sub claim");
1380
1386
1381
1387
assert_eq!(
1382
1388
sub, delegated_did,
+2
crates/tranquil-scopes/Cargo.toml
+2
crates/tranquil-scopes/Cargo.toml
···
7
7
[dependencies]
8
8
axum = { workspace = true }
9
9
futures = { workspace = true }
10
+
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
10
11
reqwest = { workspace = true }
11
12
serde = { workspace = true }
12
13
serde_json = { workspace = true }
13
14
tokio = { workspace = true }
14
15
tracing = { workspace = true }
16
+
urlencoding = "2"
+502
-60
crates/tranquil-scopes/src/permission_set.rs
+502
-60
crates/tranquil-scopes/src/permission_set.rs
···
1
+
use hickory_resolver::TokioAsyncResolver;
1
2
use reqwest::Client;
2
3
use serde::Deserialize;
3
4
use std::collections::HashMap;
···
16
17
17
18
const CACHE_TTL_SECS: u64 = 3600;
18
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
+
19
37
#[derive(Debug, Deserialize)]
20
38
struct LexiconDoc {
21
39
defs: HashMap<String, LexiconDef>,
···
31
49
#[derive(Debug, Deserialize)]
32
50
struct PermissionEntry {
33
51
resource: String,
52
+
action: Option<Vec<String>>,
34
53
collection: Option<Vec<String>>,
54
+
lxm: Option<Vec<String>>,
55
+
aud: Option<String>,
35
56
}
36
57
37
58
pub async fn expand_include_scopes(scope_string: &str) -> String {
···
39
60
.split_whitespace()
40
61
.map(|scope| async move {
41
62
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
-
})
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
+
})
48
71
}
49
72
None => scope.to_string(),
50
73
}
···
54
77
futures::future::join_all(futures).await.join(" ")
55
78
}
56
79
57
-
async fn expand_permission_set(nsid: &str) -> Result<String, String> {
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
+
58
95
{
59
96
let cache = LEXICON_CACHE.read().await;
60
-
if let Some(cached) = cache.get(nsid)
97
+
if let Some(cached) = cache.get(&cache_key)
61
98
&& cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS
62
99
{
63
100
debug!(nsid, "Using cached permission set expansion");
···
65
102
}
66
103
}
67
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> {
68
147
let parts: Vec<&str> = nsid.split('.').collect();
69
148
if parts.len() < 3 {
70
149
return Err(format!("Invalid NSID format: {}", nsid));
71
150
}
72
151
73
-
let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect();
74
-
let domain = domain_parts.join(".");
75
-
let path = parts[2..].join("/");
152
+
let authority = parts[..2].iter().rev().cloned().collect::<Vec<_>>().join(".");
153
+
debug!(nsid, authority = %authority, "Resolving lexicon DID authority via DNS");
76
154
77
-
let url = format!("https://{}/lexicons/{}.json", domain, path);
78
-
debug!(nsid, url = %url, "Fetching permission set lexicon");
155
+
let did = resolve_lexicon_did_authority(&authority).await?;
156
+
debug!(nsid, did = %did, "Resolved lexicon DID authority");
157
+
158
+
let pds_endpoint = resolve_did_to_pds(&did).await?;
159
+
debug!(nsid, pds = %pds_endpoint, "Resolved DID to PDS endpoint");
79
160
80
161
let client = Client::builder()
81
162
.timeout(std::time::Duration::from_secs(10))
82
163
.build()
83
164
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
84
165
166
+
let url = format!(
167
+
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
168
+
pds_endpoint,
169
+
urlencoding::encode(&did),
170
+
urlencoding::encode(nsid)
171
+
);
172
+
debug!(nsid, url = %url, "Fetching lexicon from PDS");
173
+
85
174
let response = client
86
175
.get(&url)
87
176
.header("Accept", "application/json")
···
96
185
));
97
186
}
98
187
99
-
let lexicon: LexiconDoc = response
188
+
let record: GetRecordResponse = response
100
189
.json()
101
190
.await
102
-
.map_err(|e| format!("Failed to parse lexicon: {}", e))?;
191
+
.map_err(|e| format!("Failed to parse lexicon response: {}", e))?;
103
192
104
-
let main_def = lexicon
105
-
.defs
106
-
.get("main")
107
-
.ok_or("Missing 'main' definition in lexicon")?;
193
+
Ok(record.value)
194
+
}
108
195
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
-
}
196
+
async fn resolve_lexicon_did_authority(authority: &str) -> Result<String, String> {
197
+
let resolver = TokioAsyncResolver::tokio_from_system_conf()
198
+
.map_err(|e| format!("Failed to create DNS resolver: {}", e))?;
115
199
116
-
let permissions = main_def
117
-
.permissions
118
-
.as_ref()
119
-
.ok_or("Missing permissions in permission-set")?;
200
+
let dns_name = format!("_lexicon.{}", authority);
201
+
debug!(dns_name = %dns_name, "Looking up DNS TXT record");
202
+
203
+
let txt_records = resolver
204
+
.txt_lookup(&dns_name)
205
+
.await
206
+
.map_err(|e| format!("DNS lookup failed for {}: {}", dns_name, e))?;
120
207
121
-
let mut collections: Vec<String> = permissions
208
+
txt_records
122
209
.iter()
123
-
.filter(|perm| perm.resource == "repo")
124
-
.filter_map(|perm| perm.collection.as_ref())
125
-
.flatten()
126
-
.cloned()
127
-
.collect();
210
+
.flat_map(|record| record.iter())
211
+
.find_map(|data| {
212
+
let txt = String::from_utf8_lossy(data);
213
+
txt.strip_prefix("did=").map(|did| did.to_string())
214
+
})
215
+
.ok_or_else(|| format!("No valid did= TXT record found at {}", dns_name))
216
+
}
217
+
218
+
async fn resolve_did_to_pds(did: &str) -> Result<String, String> {
219
+
let client = Client::builder()
220
+
.timeout(std::time::Duration::from_secs(10))
221
+
.build()
222
+
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
223
+
224
+
let url = if did.starts_with("did:plc:") {
225
+
format!("https://plc.directory/{}", did)
226
+
} else if did.starts_with("did:web:") {
227
+
let domain = did.strip_prefix("did:web:").unwrap();
228
+
format!("https://{}/.well-known/did.json", domain)
229
+
} else {
230
+
return Err(format!("Unsupported DID method: {}", did));
231
+
};
128
232
129
-
if collections.is_empty() {
130
-
return Err("No repo collections found in permission-set".to_string());
233
+
let response = client
234
+
.get(&url)
235
+
.header("Accept", "application/json")
236
+
.send()
237
+
.await
238
+
.map_err(|e| format!("Failed to resolve DID: {}", e))?;
239
+
240
+
if !response.status().is_success() {
241
+
return Err(format!("Failed to resolve DID: HTTP {}", response.status()));
131
242
}
132
243
133
-
collections.sort();
244
+
let doc: PlcDocument = response
245
+
.json()
246
+
.await
247
+
.map_err(|e| format!("Failed to parse DID document: {}", e))?;
134
248
135
-
let collection_params: Vec<String> = collections
249
+
doc.service
136
250
.iter()
137
-
.map(|c| format!("collection={}", c))
138
-
.collect();
139
-
140
-
let expanded = format!("repo?{}", collection_params.join("&"));
251
+
.find(|s| s.id == "#atproto_pds")
252
+
.map(|s| s.service_endpoint.clone())
253
+
.ok_or_else(|| "No #atproto_pds service found in DID document".to_string())
254
+
}
141
255
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
-
);
256
+
fn extract_namespace_authority(nsid: &str) -> String {
257
+
let parts: Vec<&str> = nsid.split('.').collect();
258
+
if parts.len() >= 2 {
259
+
parts[..parts.len() - 1].join(".")
260
+
} else {
261
+
nsid.to_string()
151
262
}
263
+
}
152
264
153
-
debug!(nsid, expanded = %expanded, "Successfully expanded permission set");
154
-
Ok(expanded)
265
+
fn is_under_authority(target_nsid: &str, authority: &str) -> bool {
266
+
target_nsid.starts_with(authority)
267
+
&& target_nsid
268
+
.chars()
269
+
.nth(authority.len())
270
+
.is_some_and(|c| c == '.')
271
+
}
272
+
273
+
const DEFAULT_ACTIONS: &[&str] = &["create", "update", "delete"];
274
+
275
+
fn build_expanded_scopes(
276
+
permissions: &[PermissionEntry],
277
+
default_aud: Option<&str>,
278
+
namespace_authority: &str,
279
+
) -> String {
280
+
let mut scopes: Vec<String> = Vec::new();
281
+
282
+
permissions.iter().for_each(|perm| match perm.resource.as_str() {
283
+
"repo" => {
284
+
if let Some(collections) = &perm.collection {
285
+
let actions: Vec<&str> = perm
286
+
.action
287
+
.as_ref()
288
+
.map(|a| a.iter().map(String::as_str).collect())
289
+
.unwrap_or_else(|| DEFAULT_ACTIONS.to_vec());
290
+
291
+
collections
292
+
.iter()
293
+
.filter(|coll| is_under_authority(coll, namespace_authority))
294
+
.for_each(|coll| {
295
+
actions.iter().for_each(|action| {
296
+
scopes.push(format!("repo:{}?action={}", coll, action));
297
+
});
298
+
});
299
+
}
300
+
}
301
+
"rpc" => {
302
+
if let Some(lxms) = &perm.lxm {
303
+
let perm_aud = perm.aud.as_deref().or(default_aud);
304
+
305
+
lxms.iter().for_each(|lxm| {
306
+
let scope = match perm_aud {
307
+
Some(aud) => format!("rpc:{}?aud={}", lxm, aud),
308
+
None => format!("rpc:{}", lxm),
309
+
};
310
+
scopes.push(scope);
311
+
});
312
+
}
313
+
}
314
+
_ => {}
315
+
});
316
+
317
+
scopes.join(" ")
155
318
}
156
319
157
320
#[cfg(test)]
158
321
mod tests {
322
+
use super::*;
323
+
324
+
#[test]
325
+
fn test_parse_include_scope() {
326
+
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp");
327
+
assert_eq!(nsid, "io.atcr.authFullApp");
328
+
assert_eq!(aud, None);
329
+
330
+
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?aud=did:web:api.bsky.app");
331
+
assert_eq!(nsid, "io.atcr.authFullApp");
332
+
assert_eq!(aud, Some("did:web:api.bsky.app"));
333
+
}
334
+
335
+
#[test]
336
+
fn test_parse_include_scope_with_multiple_params() {
337
+
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?foo=bar&aud=did:web:example.com&baz=qux");
338
+
assert_eq!(nsid, "io.atcr.authFullApp");
339
+
assert_eq!(aud, Some("did:web:example.com"));
340
+
}
341
+
342
+
#[test]
343
+
fn test_extract_namespace_authority() {
344
+
assert_eq!(
345
+
extract_namespace_authority("io.atcr.authFullApp"),
346
+
"io.atcr"
347
+
);
348
+
assert_eq!(
349
+
extract_namespace_authority("app.bsky.authFullApp"),
350
+
"app.bsky"
351
+
);
352
+
}
353
+
354
+
#[test]
355
+
fn test_extract_namespace_authority_deep_nesting() {
356
+
assert_eq!(
357
+
extract_namespace_authority("io.atcr.sailor.star.collection"),
358
+
"io.atcr.sailor.star"
359
+
);
360
+
}
361
+
362
+
#[test]
363
+
fn test_extract_namespace_authority_single_segment() {
364
+
assert_eq!(extract_namespace_authority("single"), "single");
365
+
}
366
+
367
+
#[test]
368
+
fn test_is_under_authority() {
369
+
assert!(is_under_authority("io.atcr.manifest", "io.atcr"));
370
+
assert!(is_under_authority("io.atcr.sailor.star", "io.atcr"));
371
+
assert!(!is_under_authority("app.bsky.feed.post", "io.atcr"));
372
+
assert!(!is_under_authority("io.atcr", "io.atcr"));
373
+
}
374
+
375
+
#[test]
376
+
fn test_is_under_authority_prefix_collision() {
377
+
assert!(!is_under_authority("io.atcritical.something", "io.atcr"));
378
+
assert!(is_under_authority("io.atcr.something", "io.atcr"));
379
+
}
380
+
381
+
#[test]
382
+
fn test_build_expanded_scopes_repo() {
383
+
let permissions = vec![PermissionEntry {
384
+
resource: "repo".to_string(),
385
+
action: Some(vec!["create".to_string(), "delete".to_string()]),
386
+
collection: Some(vec![
387
+
"io.atcr.manifest".to_string(),
388
+
"io.atcr.sailor.star".to_string(),
389
+
"app.bsky.feed.post".to_string(),
390
+
]),
391
+
lxm: None,
392
+
aud: None,
393
+
}];
394
+
395
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
396
+
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
397
+
assert!(expanded.contains("repo:io.atcr.manifest?action=delete"));
398
+
assert!(expanded.contains("repo:io.atcr.sailor.star?action=create"));
399
+
assert!(!expanded.contains("app.bsky.feed.post"));
400
+
}
401
+
402
+
#[test]
403
+
fn test_build_expanded_scopes_repo_default_actions() {
404
+
let permissions = vec![PermissionEntry {
405
+
resource: "repo".to_string(),
406
+
action: None,
407
+
collection: Some(vec!["io.atcr.manifest".to_string()]),
408
+
lxm: None,
409
+
aud: None,
410
+
}];
411
+
412
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
413
+
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
414
+
assert!(expanded.contains("repo:io.atcr.manifest?action=update"));
415
+
assert!(expanded.contains("repo:io.atcr.manifest?action=delete"));
416
+
}
417
+
418
+
#[test]
419
+
fn test_build_expanded_scopes_rpc() {
420
+
let permissions = vec![PermissionEntry {
421
+
resource: "rpc".to_string(),
422
+
action: None,
423
+
collection: None,
424
+
lxm: Some(vec![
425
+
"io.atcr.getManifest".to_string(),
426
+
"com.atproto.repo.getRecord".to_string(),
427
+
]),
428
+
aud: Some("*".to_string()),
429
+
}];
430
+
431
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
432
+
assert!(expanded.contains("rpc:io.atcr.getManifest?aud=*"));
433
+
assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*"));
434
+
}
435
+
436
+
#[test]
437
+
fn test_build_expanded_scopes_rpc_with_default_aud() {
438
+
let permissions = vec![PermissionEntry {
439
+
resource: "rpc".to_string(),
440
+
action: None,
441
+
collection: None,
442
+
lxm: Some(vec!["io.atcr.getManifest".to_string()]),
443
+
aud: None,
444
+
}];
445
+
446
+
let expanded = build_expanded_scopes(&permissions, Some("did:web:api.example.com"), "io.atcr");
447
+
assert!(expanded.contains("rpc:io.atcr.getManifest?aud=did:web:api.example.com"));
448
+
}
449
+
450
+
#[test]
451
+
fn test_build_expanded_scopes_rpc_no_aud() {
452
+
let permissions = vec![PermissionEntry {
453
+
resource: "rpc".to_string(),
454
+
action: None,
455
+
collection: None,
456
+
lxm: Some(vec!["io.atcr.getManifest".to_string()]),
457
+
aud: None,
458
+
}];
459
+
460
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
461
+
assert_eq!(expanded, "rpc:io.atcr.getManifest");
462
+
}
463
+
464
+
#[test]
465
+
fn test_build_expanded_scopes_mixed_permissions() {
466
+
let permissions = vec![
467
+
PermissionEntry {
468
+
resource: "repo".to_string(),
469
+
action: Some(vec!["create".to_string()]),
470
+
collection: Some(vec!["io.atcr.manifest".to_string()]),
471
+
lxm: None,
472
+
aud: None,
473
+
},
474
+
PermissionEntry {
475
+
resource: "rpc".to_string(),
476
+
action: None,
477
+
collection: None,
478
+
lxm: Some(vec!["com.atproto.repo.getRecord".to_string()]),
479
+
aud: Some("*".to_string()),
480
+
},
481
+
];
482
+
483
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
484
+
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
485
+
assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*"));
486
+
}
487
+
488
+
#[test]
489
+
fn test_build_expanded_scopes_unknown_resource_ignored() {
490
+
let permissions = vec![PermissionEntry {
491
+
resource: "unknown".to_string(),
492
+
action: None,
493
+
collection: Some(vec!["io.atcr.manifest".to_string()]),
494
+
lxm: None,
495
+
aud: None,
496
+
}];
497
+
498
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
499
+
assert!(expanded.is_empty());
500
+
}
501
+
502
+
#[test]
503
+
fn test_build_expanded_scopes_empty_permissions() {
504
+
let permissions: Vec<PermissionEntry> = vec![];
505
+
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
506
+
assert!(expanded.is_empty());
507
+
}
508
+
509
+
#[tokio::test]
510
+
async fn test_expand_include_scopes_passthrough_non_include() {
511
+
let result = expand_include_scopes("atproto transition:generic").await;
512
+
assert_eq!(result, "atproto transition:generic");
513
+
}
514
+
515
+
#[tokio::test]
516
+
async fn test_expand_include_scopes_mixed_with_regular() {
517
+
let result = expand_include_scopes("atproto repo:app.bsky.feed.post?action=create").await;
518
+
assert!(result.contains("atproto"));
519
+
assert!(result.contains("repo:app.bsky.feed.post?action=create"));
520
+
}
521
+
522
+
#[tokio::test]
523
+
async fn test_cache_population_and_retrieval() {
524
+
let cache_key = "test.cached.scope";
525
+
let cached_value = "repo:test.cached.collection?action=create";
526
+
527
+
{
528
+
let mut cache = LEXICON_CACHE.write().await;
529
+
cache.insert(
530
+
cache_key.to_string(),
531
+
CachedLexicon {
532
+
expanded_scope: cached_value.to_string(),
533
+
cached_at: std::time::Instant::now(),
534
+
},
535
+
);
536
+
}
537
+
538
+
let result = expand_permission_set(cache_key, None).await;
539
+
assert!(result.is_ok());
540
+
assert_eq!(result.unwrap(), cached_value);
541
+
542
+
{
543
+
let mut cache = LEXICON_CACHE.write().await;
544
+
cache.remove(cache_key);
545
+
}
546
+
}
547
+
548
+
#[tokio::test]
549
+
async fn test_cache_with_aud_parameter() {
550
+
let nsid = "test.aud.scope";
551
+
let aud = "did:web:example.com";
552
+
let cache_key = format!("{}?aud={}", nsid, aud);
553
+
let cached_value = "rpc:test.aud.method?aud=did:web:example.com";
554
+
555
+
{
556
+
let mut cache = LEXICON_CACHE.write().await;
557
+
cache.insert(
558
+
cache_key.clone(),
559
+
CachedLexicon {
560
+
expanded_scope: cached_value.to_string(),
561
+
cached_at: std::time::Instant::now(),
562
+
},
563
+
);
564
+
}
565
+
566
+
let result = expand_permission_set(nsid, Some(aud)).await;
567
+
assert!(result.is_ok());
568
+
assert_eq!(result.unwrap(), cached_value);
569
+
570
+
{
571
+
let mut cache = LEXICON_CACHE.write().await;
572
+
cache.remove(&cache_key);
573
+
}
574
+
}
575
+
576
+
#[tokio::test]
577
+
async fn test_expired_cache_triggers_refresh() {
578
+
let cache_key = "test.expired.scope";
579
+
580
+
{
581
+
let mut cache = LEXICON_CACHE.write().await;
582
+
cache.insert(
583
+
cache_key.to_string(),
584
+
CachedLexicon {
585
+
expanded_scope: "old_value".to_string(),
586
+
cached_at: std::time::Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
587
+
},
588
+
);
589
+
}
590
+
591
+
let result = expand_permission_set(cache_key, None).await;
592
+
assert!(result.is_err());
593
+
594
+
{
595
+
let mut cache = LEXICON_CACHE.write().await;
596
+
cache.remove(cache_key);
597
+
}
598
+
}
599
+
159
600
#[test]
160
-
fn test_nsid_to_url() {
601
+
fn test_nsid_authority_extraction_for_dns() {
161
602
let nsid = "io.atcr.authFullApp";
162
603
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("/");
604
+
let authority = parts[..2].iter().rev().cloned().collect::<Vec<_>>().join(".");
605
+
assert_eq!(authority, "atcr.io");
166
606
167
-
assert_eq!(domain, "atcr.io");
168
-
assert_eq!(path, "authFullApp");
607
+
let nsid2 = "app.bsky.feed.post";
608
+
let parts2: Vec<&str> = nsid2.split('.').collect();
609
+
let authority2 = parts2[..2].iter().rev().cloned().collect::<Vec<_>>().join(".");
610
+
assert_eq!(authority2, "bsky.app");
169
611
}
170
612
}
+39
-3
crates/tranquil-scopes/src/permissions.rs
+39
-3
crates/tranquil-scopes/src/permissions.rs
···
126
126
return Ok(());
127
127
}
128
128
129
-
let has_permission = self.find_repo_scopes().any(|repo_scope| {
129
+
let has_repo_permission = self.find_repo_scopes().any(|repo_scope| {
130
130
repo_scope.actions.contains(&action)
131
131
&& match &repo_scope.collection {
132
132
None => true,
···
140
140
}
141
141
});
142
142
143
-
if has_permission {
143
+
if has_repo_permission {
144
144
Ok(())
145
145
} else {
146
146
Err(ScopeError::InsufficientScope {
···
181
181
return Ok(());
182
182
}
183
183
184
+
let aud_base = aud.split('#').next().unwrap_or(aud);
185
+
184
186
let has_permission = self.find_rpc_scopes().any(|rpc_scope| {
185
187
let lxm_matches = match &rpc_scope.lxm {
186
188
None => true,
···
195
197
let aud_matches = match &rpc_scope.aud {
196
198
None => true,
197
199
Some(scope_aud) if scope_aud == "*" => true,
198
-
Some(scope_aud) => scope_aud == aud,
200
+
Some(scope_aud) => {
201
+
let scope_aud_base = scope_aud.split('#').next().unwrap_or(scope_aud);
202
+
scope_aud_base == aud_base
203
+
}
199
204
};
200
205
201
206
lxm_matches && aud_matches
···
521
526
assert!(perms.allows_blob("image/png"));
522
527
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
523
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
+
524
560
}
+24
-16
crates/tranquil-storage/src/lib.rs
+24
-16
crates/tranquil-storage/src/lib.rs
···
22
22
const CID_SHARD_PREFIX_LEN: usize = 9;
23
23
24
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))
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))
28
27
}
29
28
30
29
fn validate_key(key: &str) -> Result<(), StorageError> {
···
771
770
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
772
771
assert_eq!(
773
772
split_cid_path(cid),
774
-
Some(("bafkreihd", "wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"))
773
+
Some((
774
+
"bafkreihd",
775
+
"wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
776
+
))
775
777
);
776
778
}
777
779
···
780
782
let cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje";
781
783
assert_eq!(
782
784
split_cid_path(cid),
783
-
Some(("bafyreigd", "mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje"))
785
+
Some((
786
+
"bafyreigd",
787
+
"mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje"
788
+
))
784
789
);
785
790
}
786
791
···
810
815
let mixed = "BaFkReIhDwDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu";
811
816
assert_eq!(
812
817
split_cid_path(upper),
813
-
Some(("BAFKREIHD", "WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU"))
818
+
Some((
819
+
"BAFKREIHD",
820
+
"WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU"
821
+
))
814
822
);
815
823
assert_eq!(
816
824
split_cid_path(mixed),
817
-
Some(("BaFkReIhD", "wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu"))
825
+
Some((
826
+
"BaFkReIhD",
827
+
"wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu"
828
+
))
818
829
);
819
830
}
820
831
···
829
840
let base = PathBuf::from("/blobs");
830
841
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
831
842
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
-
);
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));
837
847
assert_eq!(result, expected);
838
848
}
839
849
···
843
853
let key = "temp/abc123";
844
854
845
855
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
-
);
856
+
let result = split_cid_path(key)
857
+
.map_or_else(|| base.join(key), |(dir, file)| base.join(dir).join(file));
850
858
assert_eq!(result, expected);
851
859
}
852
860
}
+100
-35
frontend/src/lib/api.ts
+100
-35
frontend/src/lib/api.ts
···
16
16
unsafeAsISODate,
17
17
unsafeAsRefreshToken,
18
18
} from "./types/branded.ts";
19
+
import {
20
+
createDPoPProofForRequest,
21
+
getDPoPNonce,
22
+
setDPoPNonce,
23
+
} from "./oauth.ts";
19
24
import type {
20
25
AccountInfo,
21
26
ApiErrorCode,
···
91
96
}
92
97
}
93
98
94
-
let tokenRefreshCallback: (() => Promise<string | null>) | null = null;
99
+
let tokenRefreshCallback: (() => Promise<AccessToken | null>) | null = null;
95
100
96
101
export function setTokenRefreshCallback(
97
-
callback: () => Promise<string | null>,
102
+
callback: () => Promise<AccessToken | null>,
98
103
) {
99
104
tokenRefreshCallback = callback;
100
105
}
101
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
+
102
139
interface XrpcOptions {
103
140
method?: "GET" | "POST";
104
141
params?: Record<string, string>;
105
142
body?: unknown;
106
-
token?: string;
143
+
token?: AccessToken | RefreshToken;
107
144
skipRetry?: boolean;
145
+
skipDpopRetry?: boolean;
108
146
}
109
147
110
148
async function xrpc<T>(method: string, options?: XrpcOptions): Promise<T> {
111
-
const { method: httpMethod = "GET", params, body, token, skipRetry } =
112
-
options ?? {};
149
+
const {
150
+
method: httpMethod = "GET",
151
+
params,
152
+
body,
153
+
token,
154
+
skipRetry,
155
+
skipDpopRetry,
156
+
} = options ?? {};
113
157
let url = `${API_BASE}/${method}`;
114
158
if (params) {
115
159
const searchParams = new URLSearchParams(params);
116
160
url += `?${searchParams}`;
117
161
}
118
162
const headers: Record<string, string> = {};
119
-
if (token) {
120
-
headers["Authorization"] = `Bearer ${token}`;
121
-
}
122
163
if (body) {
123
164
headers["Content-Type"] = "application/json";
124
165
}
125
-
const res = await fetch(url, {
126
-
method: httpMethod,
127
-
headers,
128
-
body: body ? JSON.stringify(body) : undefined,
129
-
});
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
+
});
130
178
if (!res.ok) {
131
179
const errData = await res.json().catch(() => ({
132
180
error: "Unknown",
133
181
message: res.statusText,
134
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
+
}
135
192
if (
136
193
res.status === 401 &&
137
194
(errData.error === "AuthenticationFailed" ||
138
-
errData.error === "ExpiredToken") &&
139
-
token && tokenRefreshCallback && !skipRetry
195
+
errData.error === "ExpiredToken" ||
196
+
errData.error === "OAuthExpiredToken") &&
197
+
token &&
198
+
tokenRefreshCallback &&
199
+
!skipRetry
140
200
) {
141
201
const newToken = await tokenRefreshCallback();
142
202
if (newToken && newToken !== token) {
···
536
596
token: AccessToken,
537
597
file: File,
538
598
): Promise<UploadBlobResponse> {
539
-
const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", {
599
+
const res = await authenticatedFetch("/xrpc/com.atproto.repo.uploadBlob", {
540
600
method: "POST",
541
-
headers: {
542
-
"Authorization": `Bearer ${token}`,
543
-
"Content-Type": file.type,
544
-
},
601
+
token,
602
+
headers: { "Content-Type": file.type },
545
603
body: file,
546
604
});
547
605
if (!res.ok) {
···
1084
1142
},
1085
1143
1086
1144
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
-
});
1145
+
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`;
1146
+
const res = await authenticatedFetch(url, { token });
1093
1147
if (!res.ok) {
1094
1148
const errData = await res.json().catch(() => ({
1095
1149
error: "Unknown",
···
1106
1160
1107
1161
async getBackup(token: AccessToken, id: string): Promise<Blob> {
1108
1162
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`;
1109
-
const res = await fetch(url, {
1110
-
headers: { Authorization: `Bearer ${token}` },
1111
-
});
1163
+
const res = await authenticatedFetch(url, { token });
1112
1164
if (!res.ok) {
1113
1165
const errData = await res.json().catch(() => ({
1114
1166
error: "Unknown",
···
1146
1198
},
1147
1199
1148
1200
async importRepo(token: AccessToken, car: Uint8Array): Promise<void> {
1149
-
const url = `${API_BASE}/com.atproto.repo.importRepo`;
1150
-
const res = await fetch(url, {
1201
+
const res = await authenticatedFetch(`${API_BASE}/com.atproto.repo.importRepo`, {
1151
1202
method: "POST",
1152
-
headers: {
1153
-
Authorization: `Bearer ${token}`,
1154
-
"Content-Type": "application/vnd.ipld.car",
1155
-
},
1203
+
token,
1204
+
headers: { "Content-Type": "application/vnd.ipld.car" },
1156
1205
body: car as unknown as BodyInit,
1157
1206
});
1158
1207
if (!res.ok) {
···
1163
1212
throw new ApiError(res.status, errData.error, errData.message);
1164
1213
}
1165
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
+
},
1166
1231
};
1167
1232
1168
1233
export const typedApi = {
+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
240
}&cid=${encodeURIComponent(cid)}`;
241
241
const headers: Record<string, string> = {};
242
242
if (this.accessToken) {
243
-
headers["Authorization"] = `Bearer ${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
+
}
244
257
}
245
258
const res = await fetch(url, { headers });
259
+
const newNonce = res.headers.get("DPoP-Nonce");
260
+
if (newNonce) {
261
+
this.dpopNonce = newNonce;
262
+
}
246
263
if (!res.ok) {
247
264
const err = await res.json().catch(() => ({
248
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
246
return base64UrlEncode(hash);
247
247
}
248
248
249
-
function getDPoPNonce(): string | null {
249
+
export function getDPoPNonce(): string | null {
250
250
return sessionStorage.getItem(DPOP_NONCE_KEY);
251
251
}
252
252
253
-
function setDPoPNonce(nonce: string): void {
253
+
export function setDPoPNonce(nonce: string): void {
254
254
sessionStorage.setItem(DPOP_NONCE_KEY, nonce);
255
255
}
256
256
257
-
function extractDPoPNonceFromResponse(response: Response): void {
257
+
export function extractDPoPNonceFromResponse(response: Response): void {
258
258
const nonce = response.headers.get("DPoP-Nonce");
259
259
if (nonce) {
260
260
setDPoPNonce(nonce);
+5
frontend/src/locales/en.json
+5
frontend/src/locales/en.json
···
779
779
"name": "Manage Account",
780
780
"description": "Manage account settings and preferences"
781
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"
782
787
}
783
788
},
784
789
"accounts": {
+5
frontend/src/locales/fi.json
+5
frontend/src/locales/fi.json
···
785
785
"name": "Hallitse tiliรค",
786
786
"description": "Hallitse tilin asetuksia ja asetuksia"
787
787
}
788
+
},
789
+
"unexpectedState": {
790
+
"title": "Odottamaton tila",
791
+
"description": "Suostumussivulla on odottamaton tila. Tarkista selaimen konsoli virheiden varalta.",
792
+
"reload": "Lataa sivu uudelleen"
788
793
}
789
794
},
790
795
"accounts": {
+5
frontend/src/locales/ja.json
+5
frontend/src/locales/ja.json
···
778
778
"name": "ใขใซใฆใณใ็ฎก็",
779
779
"description": "ใขใซใฆใณใ่จญๅฎใจ่จญๅฎใ็ฎก็"
780
780
}
781
+
},
782
+
"unexpectedState": {
783
+
"title": "ไบๆใใชใ็ถๆ
",
784
+
"description": "ๅๆใใผใธใไบๆใใชใ็ถๆ
ใงใใใใฉใฆใถใฎใณใณใฝใผใซใงใจใฉใผใ็ขบ่ชใใฆใใ ใใใ",
785
+
"reload": "ใใผใธใๅ่ชญใฟ่พผใฟ"
781
786
}
782
787
},
783
788
"accounts": {
+5
frontend/src/locales/ko.json
+5
frontend/src/locales/ko.json
···
778
778
"name": "๊ณ์ ๊ด๋ฆฌ",
779
779
"description": "๊ณ์ ์ค์ ๋ฐ ํ๊ฒฝ์ค์ ๊ด๋ฆฌ"
780
780
}
781
+
},
782
+
"unexpectedState": {
783
+
"title": "์๊ธฐ์น ์์ ์ํ",
784
+
"description": "๋์ ํ์ด์ง๊ฐ ์๊ธฐ์น ์์ ์ํ์
๋๋ค. ๋ธ๋ผ์ฐ์ ์ฝ์์์ ์ค๋ฅ๋ฅผ ํ์ธํ์ธ์.",
785
+
"reload": "ํ์ด์ง ์๋ก๊ณ ์นจ"
781
786
}
782
787
},
783
788
"accounts": {
+5
frontend/src/locales/sv.json
+5
frontend/src/locales/sv.json
···
778
778
"name": "Hantera konto",
779
779
"description": "Hantera kontoinstรคllningar och preferenser"
780
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"
781
786
}
782
787
},
783
788
"accounts": {
+5
frontend/src/locales/zh.json
+5
frontend/src/locales/zh.json
···
778
778
"name": "็ฎก็่ดฆๆท",
779
779
"description": "็ฎก็่ดฆๆท่ฎพ็ฝฎๅๅๅฅฝ"
780
780
}
781
+
},
782
+
"unexpectedState": {
783
+
"title": "ๆๅค็ถๆ",
784
+
"description": "ๅๆ้กต้ขๅคไบๆๅค็ถๆใ่ฏทๆฃๆฅๆต่งๅจๆงๅถๅฐไปฅๆฅ็้่ฏฏใ",
785
+
"reload": "้ๆฐๅ ่ฝฝ้กต้ข"
781
786
}
782
787
},
783
788
"accounts": {
+37
-16
frontend/src/routes/Migration.svelte
+37
-16
frontend/src/routes/Migration.svelte
···
2
2
import { setSession } from '../lib/auth.svelte'
3
3
import { navigate, routes } from '../lib/router.svelte'
4
4
import { _ } from '../lib/i18n'
5
+
import { api } from '../lib/api'
6
+
import { startOAuthLogin } from '../lib/oauth'
7
+
import { unsafeAsAccessToken } from '../lib/types/branded'
5
8
import {
6
9
createInboundMigrationFlow,
7
10
createOfflineInboundMigrationFlow,
···
143
146
direction = 'select'
144
147
}
145
148
146
-
function handleInboundComplete() {
149
+
async function handleInboundComplete() {
147
150
const session = inboundFlow?.getLocalSession()
148
151
if (session) {
149
-
setSession({
150
-
did: session.did,
151
-
handle: session.handle,
152
-
accessJwt: session.accessJwt,
153
-
refreshJwt: '',
154
-
})
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)
155
168
}
156
-
navigate(routes.dashboard)
157
169
}
158
170
159
-
function handleOfflineComplete() {
171
+
async function handleOfflineComplete() {
160
172
const session = offlineFlow?.getLocalSession()
161
173
if (session) {
162
-
setSession({
163
-
did: session.did,
164
-
handle: session.handle,
165
-
accessJwt: session.accessJwt,
166
-
refreshJwt: '',
167
-
})
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)
168
190
}
169
-
navigate(routes.dashboard)
170
191
}
171
192
</script>
172
193
+5
-31
frontend/src/routes/OAuthAccounts.svelte
+5
-31
frontend/src/routes/OAuthAccounts.svelte
···
196
196
display: flex;
197
197
align-items: center;
198
198
padding: var(--space-4);
199
-
background: var(--bg-card);
199
+
background: var(--bg-secondary);
200
200
border: 1px solid var(--border-color);
201
201
border-radius: var(--radius-xl);
202
202
cursor: pointer;
203
203
text-align: left;
204
204
width: 100%;
205
-
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
205
+
transition: border-color var(--transition-fast), background var(--transition-fast);
206
206
}
207
207
208
208
.account-item:hover:not(.disabled) {
209
209
border-color: var(--accent);
210
-
box-shadow: var(--shadow-sm);
210
+
background: var(--bg-tertiary);
211
211
}
212
212
213
213
.account-item.disabled {
···
231
231
color: var(--text-secondary);
232
232
}
233
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);
234
+
.different-account {
235
+
margin-top: var(--space-4);
257
236
width: 100%;
258
237
}
259
238
260
-
button.secondary:hover:not(:disabled) {
261
-
background: var(--accent);
262
-
color: var(--text-inverse);
263
-
}
264
-
265
239
.different-account {
266
240
margin-top: var(--space-4);
267
241
}
+38
-3
frontend/src/routes/OAuthConsent.svelte
+38
-3
frontend/src/routes/OAuthConsent.svelte
···
65
65
async function fetchConsentData() {
66
66
const requestUri = getRequestUri()
67
67
if (!requestUri) {
68
+
console.error('[OAuthConsent] No request_uri in URL')
68
69
error = $_('oauth.error.genericError')
69
70
loading = false
70
71
return
···
74
75
const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
75
76
if (!response.ok) {
76
77
const data = await response.json()
78
+
console.error('[OAuthConsent] Consent fetch failed:', data)
77
79
error = data.error_description || data.error || $_('oauth.error.genericError')
78
80
loading = false
79
81
return
80
82
}
81
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
+
82
92
consentData = data
83
93
84
94
scopeSelections = Object.fromEntries(
···
91
101
if (!data.show_consent) {
92
102
await submitConsent()
93
103
}
94
-
} catch {
104
+
} catch (e) {
105
+
console.error('[OAuthConsent] Error during consent fetch:', e)
95
106
error = $_('oauth.error.genericError')
96
107
} finally {
97
108
loading = false
···
104
115
}
105
116
106
117
async function submitConsent() {
107
-
if (!consentData) return
118
+
if (!consentData) {
119
+
console.error('[OAuthConsent] submitConsent called but no consentData')
120
+
return
121
+
}
108
122
109
123
submitting = true
110
124
let approvedScopes = Object.entries(scopeSelections)
···
128
142
129
143
if (!response.ok) {
130
144
const data = await response.json()
145
+
console.error('[OAuthConsent] Submit failed:', data)
131
146
error = data.error_description || data.error || $_('oauth.error.genericError')
132
147
submitting = false
133
148
return
···
136
151
const data = await response.json()
137
152
if (data.redirect_uri) {
138
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
139
158
}
140
-
} catch {
159
+
} catch (e) {
160
+
console.error('[OAuthConsent] Submit error:', e)
141
161
error = $_('oauth.error.genericError')
142
162
submitting = false
143
163
}
···
249
269
<div class="spinner"></div>
250
270
<p>{$_('common.loading')}</p>
251
271
</div>
272
+
{:else}
273
+
<p style="color: var(--text-muted); font-size: 0.875rem;">Loading consent data...</p>
252
274
{/if}
253
275
</div>
254
276
{:else if error}
···
372
394
{submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')}
373
395
</button>
374
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>
375
410
{/if}
376
411
</div>
377
412
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