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