+22
.sqlx/query-1add22e111d5eff8beadbd832b4b8146d95da0a0ce8ce31dc9a2f930a26cc9ce.json
+22
.sqlx/query-1add22e111d5eff8beadbd832b4b8146d95da0a0ce8ce31dc9a2f930a26cc9ce.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT takedown_ref FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "takedown_ref",
9
+
"type_info": "Text"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
true
19
+
]
20
+
},
21
+
"hash": "1add22e111d5eff8beadbd832b4b8146d95da0a0ce8ce31dc9a2f930a26cc9ce"
22
+
}
+28
.sqlx/query-90bcc8fb97f73a0b5f427971aca891936b3f906c2d4cdb4bf203dd6a4c9aa060.json
+28
.sqlx/query-90bcc8fb97f73a0b5f427971aca891936b3f906c2d4cdb4bf203dd6a4c9aa060.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "key_bytes",
9
+
"type_info": "Bytea"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "encryption_version",
14
+
"type_info": "Int4"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"Text"
20
+
]
21
+
},
22
+
"nullable": [
23
+
false,
24
+
true
25
+
]
26
+
},
27
+
"hash": "90bcc8fb97f73a0b5f427971aca891936b3f906c2d4cdb4bf203dd6a4c9aa060"
28
+
}
+52
.sqlx/query-bee4276cbb537512cced16f7017d8f7c068d30f319ef965fa9ec9fb1a3490151.json
+52
.sqlx/query-bee4276cbb537512cced16f7017d8f7c068d30f319ef965fa9ec9fb1a3490151.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref,\n k.key_bytes as \"key_bytes?\", k.encryption_version as \"encryption_version?\"\n FROM oauth_token t\n JOIN users u ON t.did = u.did\n LEFT JOIN user_keys k ON u.id = k.user_id\n WHERE t.token_id = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "did",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "expires_at",
14
+
"type_info": "Timestamptz"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "deactivated_at",
19
+
"type_info": "Timestamptz"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "takedown_ref",
24
+
"type_info": "Text"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "key_bytes?",
29
+
"type_info": "Bytea"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "encryption_version?",
34
+
"type_info": "Int4"
35
+
}
36
+
],
37
+
"parameters": {
38
+
"Left": [
39
+
"Text"
40
+
]
41
+
},
42
+
"nullable": [
43
+
false,
44
+
false,
45
+
true,
46
+
true,
47
+
false,
48
+
true
49
+
]
50
+
},
51
+
"hash": "bee4276cbb537512cced16f7017d8f7c068d30f319ef965fa9ec9fb1a3490151"
52
+
}
-40
.sqlx/query-efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07.json
-40
.sqlx/query-efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref\n FROM oauth_token t\n JOIN users u ON t.did = u.did\n WHERE t.token_id = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "did",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "expires_at",
14
-
"type_info": "Timestamptz"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "deactivated_at",
19
-
"type_info": "Timestamptz"
20
-
},
21
-
{
22
-
"ordinal": 3,
23
-
"name": "takedown_ref",
24
-
"type_info": "Text"
25
-
}
26
-
],
27
-
"parameters": {
28
-
"Left": [
29
-
"Text"
30
-
]
31
-
},
32
-
"nullable": [
33
-
false,
34
-
false,
35
-
true,
36
-
true
37
-
]
38
-
},
39
-
"hash": "efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07"
40
-
}
+23
-15
src/api/actor/profile.rs
+23
-15
src/api/actor/profile.rs
···
73
73
async fn proxy_to_appview(
74
74
method: &str,
75
75
params: &HashMap<String, String>,
76
-
auth_header: Option<&str>,
76
+
auth_did: &str,
77
+
auth_key_bytes: Option<&[u8]>,
77
78
) -> Result<(StatusCode, Value), Response> {
78
79
let appview_url = match std::env::var("APPVIEW_URL") {
79
80
Ok(url) => url,
···
87
88
info!("Proxying GET request to {}", target_url);
88
89
let client = proxy_client();
89
90
let mut request_builder = client.get(&target_url).query(params);
90
-
if let Some(auth) = auth_header {
91
-
request_builder = request_builder.header("Authorization", auth);
91
+
if let Some(key_bytes) = auth_key_bytes {
92
+
let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
93
+
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
94
+
Ok(service_token) => {
95
+
request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token));
96
+
}
97
+
Err(e) => {
98
+
error!("Failed to create service token: {:?}", e);
99
+
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response());
100
+
}
101
+
}
92
102
}
93
103
match request_builder.send().await {
94
104
Ok(resp) => {
···
118
128
Query(params): Query<GetProfileParams>,
119
129
) -> Response {
120
130
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
121
-
let auth_did = if let Some(h) = auth_header {
131
+
let auth_user = if let Some(h) = auth_header {
122
132
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
123
-
match crate::auth::validate_bearer_token(&state.db, &token).await {
124
-
Ok(user) => Some(user.did),
125
-
Err(_) => None,
126
-
}
133
+
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
127
134
} else {
128
135
None
129
136
}
130
137
} else {
131
138
None
132
139
};
140
+
let auth_did = auth_user.as_ref().map(|u| u.did.clone());
141
+
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
133
142
let mut query_params = HashMap::new();
134
143
query_params.insert("actor".to_string(), params.actor.clone());
135
-
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfile", &query_params, auth_header).await {
144
+
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfile", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
136
145
Ok(r) => r,
137
146
Err(e) => return e,
138
147
};
···
161
170
Query(params): Query<GetProfilesParams>,
162
171
) -> Response {
163
172
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
164
-
let auth_did = if let Some(h) = auth_header {
173
+
let auth_user = if let Some(h) = auth_header {
165
174
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
166
-
match crate::auth::validate_bearer_token(&state.db, &token).await {
167
-
Ok(user) => Some(user.did),
168
-
Err(_) => None,
169
-
}
175
+
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
170
176
} else {
171
177
None
172
178
}
173
179
} else {
174
180
None
175
181
};
182
+
let auth_did = auth_user.as_ref().map(|u| u.did.clone());
183
+
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
176
184
let mut query_params = HashMap::new();
177
185
query_params.insert("actors".to_string(), params.actors.clone());
178
-
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfiles", &query_params, auth_header).await {
186
+
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfiles", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
179
187
Ok(r) => r,
180
188
Err(e) => return e,
181
189
};
+12
-13
src/api/feed/actor_likes.rs
+12
-13
src/api/feed/actor_likes.rs
···
66
66
Query(params): Query<GetActorLikesParams>,
67
67
) -> Response {
68
68
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
69
-
let auth_did = if let Some(h) = auth_header {
69
+
let auth_user = if let Some(h) = auth_header {
70
70
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
71
-
match crate::auth::validate_bearer_token(&state.db, &token).await {
72
-
Ok(user) => Some(user.did),
73
-
Err(_) => None,
74
-
}
71
+
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
75
72
} else {
76
73
None
77
74
}
78
75
} else {
79
76
None
80
77
};
78
+
let auth_did = auth_user.as_ref().map(|u| u.did.clone());
79
+
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
81
80
let mut query_params = HashMap::new();
82
81
query_params.insert("actor".to_string(), params.actor.clone());
83
82
if let Some(limit) = params.limit {
···
87
86
query_params.insert("cursor".to_string(), cursor.clone());
88
87
}
89
88
let proxy_result =
90
-
match proxy_to_appview("app.bsky.feed.getActorLikes", &query_params, auth_header).await {
89
+
match proxy_to_appview("app.bsky.feed.getActorLikes", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
91
90
Ok(r) => r,
92
91
Err(e) => return e,
93
92
};
94
93
if !proxy_result.status.is_success() {
95
-
return (proxy_result.status, proxy_result.body).into_response();
94
+
return proxy_result.into_response();
96
95
}
97
96
let rev = match extract_repo_rev(&proxy_result.headers) {
98
97
Some(r) => r,
99
-
None => return (proxy_result.status, proxy_result.body).into_response(),
98
+
None => return proxy_result.into_response(),
100
99
};
101
100
let mut feed_output: FeedOutput = match serde_json::from_slice(&proxy_result.body) {
102
101
Ok(f) => f,
103
102
Err(e) => {
104
103
warn!("Failed to parse actor likes response: {:?}", e);
105
-
return (proxy_result.status, proxy_result.body).into_response();
104
+
return proxy_result.into_response();
106
105
}
107
106
};
108
-
let requester_did = match auth_did {
109
-
Some(d) => d,
107
+
let requester_did = match &auth_did {
108
+
Some(d) => d.clone(),
110
109
None => return (StatusCode::OK, Json(feed_output)).into_response(),
111
110
};
112
111
let actor_did = if params.actor.starts_with("did:") {
···
127
126
Ok(None) => return (StatusCode::OK, Json(feed_output)).into_response(),
128
127
Err(e) => {
129
128
warn!("Database error resolving actor handle: {:?}", e);
130
-
return (proxy_result.status, proxy_result.body).into_response();
129
+
return proxy_result.into_response();
131
130
}
132
131
}
133
132
};
···
138
137
Ok(r) => r,
139
138
Err(e) => {
140
139
warn!("Failed to get local records: {}", e);
141
-
return (proxy_result.status, proxy_result.body).into_response();
140
+
return proxy_result.into_response();
142
141
}
143
142
};
144
143
if local_records.likes.is_empty() {
+14
-5
src/api/feed/custom_feed.rs
+14
-5
src/api/feed/custom_feed.rs
···
30
30
Some(t) => t,
31
31
None => return ApiError::AuthenticationRequired.into_response(),
32
32
};
33
-
if let Err(e) = crate::auth::validate_bearer_token(&state.db, &token).await {
34
-
return ApiError::from(e).into_response();
33
+
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
34
+
Ok(user) => user,
35
+
Err(e) => return ApiError::from(e).into_response(),
35
36
};
36
37
if let Err(e) = validate_at_uri(¶ms.feed) {
37
38
return ApiError::InvalidRequest(format!("Invalid feed URI: {}", e)).into_response();
38
39
}
39
-
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
40
40
let appview_url = match std::env::var("APPVIEW_URL") {
41
41
Ok(url) => url,
42
42
Err(_) => {
···
60
60
info!(target = %target_url, feed = %params.feed, "Proxying getFeed request");
61
61
let client = proxy_client();
62
62
let mut request_builder = client.get(&target_url).query(&query_params);
63
-
if let Some(auth) = auth_header {
64
-
request_builder = request_builder.header("Authorization", auth);
63
+
if let Some(key_bytes) = auth_user.key_bytes.as_ref() {
64
+
let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
65
+
match crate::auth::create_service_token(&auth_user.did, &appview_did, "app.bsky.feed.getFeed", key_bytes) {
66
+
Ok(service_token) => {
67
+
request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token));
68
+
}
69
+
Err(e) => {
70
+
error!(error = ?e, "Failed to create service token for getFeed");
71
+
return ApiError::InternalError.into_response();
72
+
}
73
+
}
65
74
}
66
75
match request_builder.send().await {
67
76
Ok(resp) => {
+9
-10
src/api/feed/post_thread.rs
+9
-10
src/api/feed/post_thread.rs
···
126
126
Query(params): Query<GetPostThreadParams>,
127
127
) -> Response {
128
128
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
129
-
let auth_did = if let Some(h) = auth_header {
129
+
let auth_user = if let Some(h) = auth_header {
130
130
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
131
-
match crate::auth::validate_bearer_token(&state.db, &token).await {
132
-
Ok(user) => Some(user.did),
133
-
Err(_) => None,
134
-
}
131
+
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
135
132
} else {
136
133
None
137
134
}
138
135
} else {
139
136
None
140
137
};
138
+
let auth_did = auth_user.as_ref().map(|u| u.did.clone());
139
+
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
141
140
let mut query_params = HashMap::new();
142
141
query_params.insert("uri".to_string(), params.uri.clone());
143
142
if let Some(depth) = params.depth {
···
147
146
query_params.insert("parentHeight".to_string(), parent_height.to_string());
148
147
}
149
148
let proxy_result =
150
-
match proxy_to_appview("app.bsky.feed.getPostThread", &query_params, auth_header).await {
149
+
match proxy_to_appview("app.bsky.feed.getPostThread", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
151
150
Ok(r) => r,
152
151
Err(e) => return e,
153
152
};
···
155
154
return handle_not_found(&state, ¶ms.uri, auth_did, &proxy_result.headers).await;
156
155
}
157
156
if !proxy_result.status.is_success() {
158
-
return (proxy_result.status, proxy_result.body).into_response();
157
+
return proxy_result.into_response();
159
158
}
160
159
let rev = match extract_repo_rev(&proxy_result.headers) {
161
160
Some(r) => r,
162
-
None => return (proxy_result.status, proxy_result.body).into_response(),
161
+
None => return proxy_result.into_response(),
163
162
};
164
163
let mut thread_output: PostThreadOutput = match serde_json::from_slice(&proxy_result.body) {
165
164
Ok(t) => t,
166
165
Err(e) => {
167
166
warn!("Failed to parse post thread response: {:?}", e);
168
-
return (proxy_result.status, proxy_result.body).into_response();
167
+
return proxy_result.into_response();
169
168
}
170
169
};
171
170
let requester_did = match auth_did {
···
176
175
Ok(r) => r,
177
176
Err(e) => {
178
177
warn!("Failed to get local records: {}", e);
179
-
return (proxy_result.status, proxy_result.body).into_response();
178
+
return proxy_result.into_response();
180
179
}
181
180
};
182
181
if local_records.posts.is_empty() {
+8
-9
src/api/feed/timeline.rs
+8
-9
src/api/feed/timeline.rs
···
52
52
};
53
53
match std::env::var("APPVIEW_URL") {
54
54
Ok(url) if !url.starts_with("http://127.0.0.1") => {
55
-
return get_timeline_with_appview(&state, &headers, ¶ms, &auth_user.did).await;
55
+
return get_timeline_with_appview(&state, ¶ms, &auth_user.did, auth_user.key_bytes.as_deref()).await;
56
56
}
57
57
_ => {}
58
58
}
···
61
61
62
62
async fn get_timeline_with_appview(
63
63
state: &AppState,
64
-
headers: &axum::http::HeaderMap,
65
64
params: &GetTimelineParams,
66
65
auth_did: &str,
66
+
auth_key_bytes: Option<&[u8]>,
67
67
) -> Response {
68
-
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
69
68
let mut query_params = HashMap::new();
70
69
if let Some(algo) = ¶ms.algorithm {
71
70
query_params.insert("algorithm".to_string(), algo.clone());
···
77
76
query_params.insert("cursor".to_string(), cursor.clone());
78
77
}
79
78
let proxy_result =
80
-
match proxy_to_appview("app.bsky.feed.getTimeline", &query_params, auth_header).await {
79
+
match proxy_to_appview("app.bsky.feed.getTimeline", &query_params, auth_did, auth_key_bytes).await {
81
80
Ok(r) => r,
82
81
Err(e) => return e,
83
82
};
84
83
if !proxy_result.status.is_success() {
85
-
return (proxy_result.status, proxy_result.body).into_response();
84
+
return proxy_result.into_response();
86
85
}
87
86
let rev = extract_repo_rev(&proxy_result.headers);
88
87
if rev.is_none() {
89
-
return (proxy_result.status, proxy_result.body).into_response();
88
+
return proxy_result.into_response();
90
89
}
91
90
let rev = rev.unwrap();
92
91
let mut feed_output: FeedOutput = match serde_json::from_slice(&proxy_result.body) {
93
92
Ok(f) => f,
94
93
Err(e) => {
95
94
warn!("Failed to parse timeline response: {:?}", e);
96
-
return (proxy_result.status, proxy_result.body).into_response();
95
+
return proxy_result.into_response();
97
96
}
98
97
};
99
98
let local_records = match get_records_since_rev(state, auth_did, &rev).await {
100
99
Ok(r) => r,
101
100
Err(e) => {
102
101
warn!("Failed to get local records: {}", e);
103
-
return (proxy_result.status, proxy_result.body).into_response();
102
+
return proxy_result.into_response();
104
103
}
105
104
};
106
105
if local_records.count == 0 {
107
-
return (proxy_result.status, proxy_result.body).into_response();
106
+
return proxy_result.into_response();
108
107
}
109
108
let handle = match sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth_did)
110
109
.fetch_optional(&state.db)
+37
-11
src/api/proxy.rs
+37
-11
src/api/proxy.rs
···
7
7
};
8
8
use crate::api::proxy_client::proxy_client;
9
9
use std::collections::HashMap;
10
-
use tracing::{error, info};
10
+
use tracing::error;
11
+
12
+
fn resolve_service_did(did_with_fragment: &str) -> Option<(String, String)> {
13
+
if did_with_fragment.starts_with("did:web:") {
14
+
let without_prefix = &did_with_fragment[8..];
15
+
let host = without_prefix.split('#').next()?;
16
+
let url = format!("https://{}", host);
17
+
let did_without_fragment = format!("did:web:{}", host);
18
+
Some((url, did_without_fragment))
19
+
} else if did_with_fragment.starts_with("did:plc:") {
20
+
None
21
+
} else {
22
+
None
23
+
}
24
+
}
11
25
12
26
pub async fn proxy_handler(
13
27
State(state): State<AppState>,
···
21
35
.get("atproto-proxy")
22
36
.and_then(|h| h.to_str().ok())
23
37
.map(|s| s.to_string());
24
-
let appview_url = match &proxy_header {
25
-
Some(url) => url.clone(),
26
-
None => match std::env::var("APPVIEW_URL") {
27
-
Ok(url) => url,
28
-
Err(_) => {
29
-
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured").into_response();
30
-
}
31
-
},
38
+
let (appview_url, service_aud) = match &proxy_header {
39
+
Some(did_str) => {
40
+
let (url, did_without_fragment) = match resolve_service_did(did_str) {
41
+
Some(resolved) => resolved,
42
+
None => {
43
+
error!(did = %did_str, "Could not resolve service DID");
44
+
return (StatusCode::BAD_GATEWAY, "Could not resolve service DID").into_response();
45
+
}
46
+
};
47
+
(url, Some(did_without_fragment))
48
+
}
49
+
None => {
50
+
let url = match std::env::var("APPVIEW_URL") {
51
+
Ok(url) => url,
52
+
Err(_) => {
53
+
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured").into_response();
54
+
}
55
+
};
56
+
let aud = std::env::var("APPVIEW_DID").ok();
57
+
(url, aud)
58
+
}
32
59
};
33
60
let target_url = format!("{}/xrpc/{}", appview_url, method);
34
-
info!("Proxying {} request to {}", method_verb, target_url);
35
61
let client = proxy_client();
36
62
let mut request_builder = client.request(method_verb, &target_url).query(¶ms);
37
63
let mut auth_header_val = headers.get("Authorization").map(|h| h.clone());
38
-
if let Some(aud) = &proxy_header {
64
+
if let Some(aud) = &service_aud {
39
65
if let Some(token) = crate::auth::extract_bearer_token_from_header(
40
66
headers.get("Authorization").and_then(|h| h.to_str().ok())
41
67
) {
+23
-3
src/api/read_after_write.rs
+23
-3
src/api/read_after_write.rs
···
229
229
pub body: bytes::Bytes,
230
230
}
231
231
232
+
impl ProxyResponse {
233
+
pub fn into_response(self) -> Response {
234
+
let mut response = Response::builder().status(self.status);
235
+
for (key, value) in self.headers.iter() {
236
+
response = response.header(key, value);
237
+
}
238
+
response.body(axum::body::Body::from(self.body)).unwrap()
239
+
}
240
+
}
241
+
232
242
pub async fn proxy_to_appview(
233
243
method: &str,
234
244
params: &HashMap<String, String>,
235
-
auth_header: Option<&str>,
245
+
auth_did: &str,
246
+
auth_key_bytes: Option<&[u8]>,
236
247
) -> Result<ProxyResponse, Response> {
237
248
let appview_url = std::env::var("APPVIEW_URL").map_err(|_| {
238
249
ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()).into_response()
···
246
257
info!(target = %target_url, "Proxying request to appview");
247
258
let client = proxy_client();
248
259
let mut request_builder = client.get(&target_url).query(params);
249
-
if let Some(auth) = auth_header {
250
-
request_builder = request_builder.header("Authorization", auth);
260
+
if let Some(key_bytes) = auth_key_bytes {
261
+
let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
262
+
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
263
+
Ok(service_token) => {
264
+
request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token));
265
+
}
266
+
Err(e) => {
267
+
error!(error = ?e, "Failed to create service token");
268
+
return Err(ApiError::InternalError.into_response());
269
+
}
270
+
}
251
271
}
252
272
match request_builder.send().await {
253
273
Ok(resp) => {
+1
src/api/repo/blob.rs
+1
src/api/repo/blob.rs
+52
-8
src/api/server/account_status.rs
+52
-8
src/api/server/account_status.rs
···
31
31
State(state): State<AppState>,
32
32
headers: axum::http::HeaderMap,
33
33
) -> Response {
34
-
let token = match crate::auth::extract_bearer_token_from_header(
34
+
let extracted = match crate::auth::extract_auth_token_from_header(
35
35
headers.get("Authorization").and_then(|h| h.to_str().ok())
36
36
) {
37
37
Some(t) => t,
38
38
None => return ApiError::AuthenticationRequired.into_response(),
39
39
};
40
-
let did = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
40
+
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
41
+
let http_uri = format!("https://{}/xrpc/com.atproto.server.checkAccountStatus",
42
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
43
+
let did = match crate::auth::validate_token_with_dpop(
44
+
&state.db,
45
+
&extracted.token,
46
+
extracted.is_dpop,
47
+
dpop_proof,
48
+
"GET",
49
+
&http_uri,
50
+
true,
51
+
).await {
41
52
Ok(user) => user.did,
42
53
Err(e) => return ApiError::from(e).into_response(),
43
54
};
···
101
112
State(state): State<AppState>,
102
113
headers: axum::http::HeaderMap,
103
114
) -> Response {
104
-
let token = match crate::auth::extract_bearer_token_from_header(
115
+
let extracted = match crate::auth::extract_auth_token_from_header(
105
116
headers.get("Authorization").and_then(|h| h.to_str().ok())
106
117
) {
107
118
Some(t) => t,
108
119
None => return ApiError::AuthenticationRequired.into_response(),
109
120
};
110
-
let did = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
121
+
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
122
+
let http_uri = format!("https://{}/xrpc/com.atproto.server.activateAccount",
123
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
124
+
let did = match crate::auth::validate_token_with_dpop(
125
+
&state.db,
126
+
&extracted.token,
127
+
extracted.is_dpop,
128
+
dpop_proof,
129
+
"POST",
130
+
&http_uri,
131
+
true,
132
+
).await {
111
133
Ok(user) => user.did,
112
134
Err(e) => return ApiError::from(e).into_response(),
113
135
};
···
148
170
headers: axum::http::HeaderMap,
149
171
Json(_input): Json<DeactivateAccountInput>,
150
172
) -> Response {
151
-
let token = match crate::auth::extract_bearer_token_from_header(
173
+
let extracted = match crate::auth::extract_auth_token_from_header(
152
174
headers.get("Authorization").and_then(|h| h.to_str().ok())
153
175
) {
154
176
Some(t) => t,
155
177
None => return ApiError::AuthenticationRequired.into_response(),
156
178
};
157
-
let did = match crate::auth::validate_bearer_token(&state.db, &token).await {
179
+
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
180
+
let http_uri = format!("https://{}/xrpc/com.atproto.server.deactivateAccount",
181
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
182
+
let did = match crate::auth::validate_token_with_dpop(
183
+
&state.db,
184
+
&extracted.token,
185
+
extracted.is_dpop,
186
+
dpop_proof,
187
+
"POST",
188
+
&http_uri,
189
+
false,
190
+
).await {
158
191
Ok(user) => user.did,
159
192
Err(e) => return ApiError::from(e).into_response(),
160
193
};
···
188
221
State(state): State<AppState>,
189
222
headers: axum::http::HeaderMap,
190
223
) -> Response {
191
-
let token = match crate::auth::extract_bearer_token_from_header(
224
+
let extracted = match crate::auth::extract_auth_token_from_header(
192
225
headers.get("Authorization").and_then(|h| h.to_str().ok())
193
226
) {
194
227
Some(t) => t,
195
228
None => return ApiError::AuthenticationRequired.into_response(),
196
229
};
197
-
let did = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
230
+
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
231
+
let http_uri = format!("https://{}/xrpc/com.atproto.server.requestAccountDelete",
232
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
233
+
let did = match crate::auth::validate_token_with_dpop(
234
+
&state.db,
235
+
&extracted.token,
236
+
extracted.is_dpop,
237
+
dpop_proof,
238
+
"POST",
239
+
&http_uri,
240
+
true,
241
+
).await {
198
242
Ok(user) => user.did,
199
243
Err(e) => return ApiError::from(e).into_response(),
200
244
};
+23
-5
src/api/server/session.rs
+23
-5
src/api/server/session.rs
···
29
29
"unknown".to_string()
30
30
}
31
31
32
+
fn normalize_handle(identifier: &str, pds_hostname: &str) -> String {
33
+
let suffix = format!(".{}", pds_hostname);
34
+
if identifier.ends_with(&suffix) {
35
+
identifier[..identifier.len() - suffix.len()].to_string()
36
+
} else {
37
+
identifier.to_string()
38
+
}
39
+
}
40
+
32
41
#[derive(Deserialize)]
33
42
pub struct CreateSessionInput {
34
43
pub identifier: String,
···
62
71
)
63
72
.into_response();
64
73
}
74
+
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
75
+
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
65
76
let row = match sqlx::query!(
66
77
r#"SELECT
67
78
u.id, u.did, u.handle, u.password_hash,
···
70
81
FROM users u
71
82
JOIN user_keys k ON u.id = k.user_id
72
83
WHERE u.handle = $1 OR u.email = $1"#,
73
-
input.identifier
84
+
normalized_identifier
74
85
)
75
86
.fetch_optional(&state.db)
76
87
.await
···
152
163
error!("Failed to insert session: {:?}", e);
153
164
return ApiError::InternalError.into_response();
154
165
}
166
+
let full_handle = format!("{}.{}", row.handle, pds_hostname);
155
167
Json(CreateSessionOutput {
156
168
access_jwt: access_meta.token,
157
169
refresh_jwt: refresh_meta.token,
158
-
handle: row.handle,
170
+
handle: full_handle,
159
171
did: row.did,
160
172
}).into_response()
161
173
}
···
182
194
crate::notifications::NotificationChannel::Telegram => ("telegram", row.telegram_verified),
183
195
crate::notifications::NotificationChannel::Signal => ("signal", row.signal_verified),
184
196
};
197
+
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
198
+
let full_handle = format!("{}.{}", row.handle, pds_hostname);
185
199
Json(json!({
186
-
"handle": row.handle,
200
+
"handle": full_handle,
187
201
"did": auth_user.did,
188
202
"email": row.email,
189
203
"emailConfirmed": row.email_confirmed,
190
204
"preferredChannel": preferred_channel,
191
205
"preferredChannelVerified": preferred_channel_verified,
206
+
"active": true,
192
207
"didDoc": {}
193
208
})).into_response()
194
209
}
···
381
396
crate::notifications::NotificationChannel::Telegram => ("telegram", u.telegram_verified),
382
397
crate::notifications::NotificationChannel::Signal => ("signal", u.signal_verified),
383
398
};
399
+
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
400
+
let full_handle = format!("{}.{}", u.handle, pds_hostname);
384
401
Json(json!({
385
402
"accessJwt": new_access_meta.token,
386
403
"refreshJwt": new_refresh_meta.token,
387
-
"handle": u.handle,
404
+
"handle": full_handle,
388
405
"did": session_row.did,
389
406
"email": u.email,
390
407
"emailConfirmed": u.email_confirmed,
391
408
"preferredChannel": preferred_channel,
392
-
"preferredChannelVerified": preferred_channel_verified
409
+
"preferredChannelVerified": preferred_channel_verified,
410
+
"active": true
393
411
})).into_response()
394
412
}
395
413
Ok(None) => {
+28
src/auth/extractor.rs
+28
src/auth/extractor.rs
···
94
94
Some(token.to_string())
95
95
}
96
96
97
+
pub struct ExtractedToken {
98
+
pub token: String,
99
+
pub is_dpop: bool,
100
+
}
101
+
102
+
pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<ExtractedToken> {
103
+
let header = auth_header?;
104
+
let header = header.trim();
105
+
106
+
if header.len() >= 7 && header[..7].eq_ignore_ascii_case("bearer ") {
107
+
let token = header[7..].trim();
108
+
if token.is_empty() {
109
+
return None;
110
+
}
111
+
return Some(ExtractedToken { token: token.to_string(), is_dpop: false });
112
+
}
113
+
114
+
if header.len() >= 5 && header[..5].eq_ignore_ascii_case("dpop ") {
115
+
let token = header[5..].trim();
116
+
if token.is_empty() {
117
+
return None;
118
+
}
119
+
return Some(ExtractedToken { token: token.to_string(), is_dpop: true });
120
+
}
121
+
122
+
None
123
+
}
124
+
97
125
impl FromRequestParts<AppState> for BearerAuth {
98
126
type Rejection = AuthError;
99
127
+73
-3
src/auth/mod.rs
+73
-3
src/auth/mod.rs
···
10
10
pub mod token;
11
11
pub mod verify;
12
12
13
-
pub use extractor::{BearerAuth, BearerAuthAllowDeactivated, AuthError, extract_bearer_token_from_header};
13
+
pub use extractor::{BearerAuth, BearerAuthAllowDeactivated, AuthError, extract_bearer_token_from_header, extract_auth_token_from_header, ExtractedToken};
14
14
pub use token::{
15
15
create_access_token, create_refresh_token, create_service_token,
16
16
create_access_token_with_metadata, create_refresh_token_with_metadata,
···
195
195
196
196
if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) {
197
197
if let Some(oauth_token) = sqlx::query!(
198
-
r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref
198
+
r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref,
199
+
k.key_bytes as "key_bytes?", k.encryption_version as "encryption_version?"
199
200
FROM oauth_token t
200
201
JOIN users u ON t.did = u.did
202
+
LEFT JOIN user_keys k ON u.id = k.user_id
201
203
WHERE t.token_id = $1"#,
202
204
oauth_info.token_id
203
205
)
···
216
218
217
219
let now = chrono::Utc::now();
218
220
if oauth_token.expires_at > now {
221
+
let key_bytes = if let (Some(kb), Some(ev)) = (&oauth_token.key_bytes, oauth_token.encryption_version) {
222
+
crate::config::decrypt_key(kb, Some(ev)).ok()
223
+
} else {
224
+
None
225
+
};
219
226
return Ok(AuthenticatedUser {
220
227
did: oauth_token.did,
221
-
key_bytes: None,
228
+
key_bytes,
222
229
is_oauth: true,
223
230
});
224
231
}
···
231
238
pub async fn invalidate_auth_cache(cache: &Arc<dyn Cache>, did: &str) {
232
239
let key_cache_key = format!("auth:key:{}", did);
233
240
let _ = cache.delete(&key_cache_key).await;
241
+
}
242
+
243
+
pub async fn validate_token_with_dpop(
244
+
db: &PgPool,
245
+
token: &str,
246
+
is_dpop_token: bool,
247
+
dpop_proof: Option<&str>,
248
+
http_method: &str,
249
+
http_uri: &str,
250
+
allow_deactivated: bool,
251
+
) -> Result<AuthenticatedUser, TokenValidationError> {
252
+
if !is_dpop_token {
253
+
if allow_deactivated {
254
+
return validate_bearer_token_allow_deactivated(db, token).await;
255
+
} else {
256
+
return validate_bearer_token(db, token).await;
257
+
}
258
+
}
259
+
match crate::oauth::verify::verify_oauth_access_token(db, token, dpop_proof, http_method, http_uri).await {
260
+
Ok(result) => {
261
+
if !allow_deactivated {
262
+
let deactivated = sqlx::query_scalar!(
263
+
"SELECT deactivated_at FROM users WHERE did = $1",
264
+
result.did
265
+
)
266
+
.fetch_optional(db)
267
+
.await
268
+
.ok()
269
+
.flatten()
270
+
.flatten();
271
+
if deactivated.is_some() {
272
+
return Err(TokenValidationError::AccountDeactivated);
273
+
}
274
+
}
275
+
let takedown = sqlx::query_scalar!(
276
+
"SELECT takedown_ref FROM users WHERE did = $1",
277
+
result.did
278
+
)
279
+
.fetch_optional(db)
280
+
.await
281
+
.ok()
282
+
.flatten()
283
+
.flatten();
284
+
if takedown.is_some() {
285
+
return Err(TokenValidationError::AccountTakedown);
286
+
}
287
+
let key_bytes = sqlx::query!(
288
+
"SELECT k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1",
289
+
result.did
290
+
)
291
+
.fetch_optional(db)
292
+
.await
293
+
.ok()
294
+
.flatten()
295
+
.and_then(|row| crate::config::decrypt_key(&row.key_bytes, row.encryption_version).ok());
296
+
Ok(AuthenticatedUser {
297
+
did: result.did,
298
+
key_bytes,
299
+
is_oauth: true,
300
+
})
301
+
}
302
+
Err(_) => Err(TokenValidationError::AuthenticationFailed),
303
+
}
234
304
}
235
305
236
306
#[derive(Debug, Serialize, Deserialize)]
+95
-13
src/oauth/client.rs
+95
-13
src/oauth/client.rs
···
86
86
}
87
87
}
88
88
89
+
fn is_loopback_client(client_id: &str) -> bool {
90
+
if let Ok(url) = reqwest::Url::parse(client_id) {
91
+
url.scheme() == "http"
92
+
&& url.host_str() == Some("localhost")
93
+
&& url.port().is_none()
94
+
} else {
95
+
false
96
+
}
97
+
}
98
+
99
+
fn build_loopback_metadata(client_id: &str) -> Result<ClientMetadata, OAuthError> {
100
+
let url = reqwest::Url::parse(client_id).map_err(|_| {
101
+
OAuthError::InvalidClient("Invalid loopback client_id URL".to_string())
102
+
})?;
103
+
let mut redirect_uris = Vec::new();
104
+
for (key, value) in url.query_pairs() {
105
+
if key == "redirect_uri" {
106
+
redirect_uris.push(value.to_string());
107
+
}
108
+
}
109
+
if redirect_uris.is_empty() {
110
+
redirect_uris.push("http://127.0.0.1/callback".to_string());
111
+
redirect_uris.push("http://localhost/callback".to_string());
112
+
}
113
+
let scope = Some("atproto transition:generic transition:chat.bsky".to_string());
114
+
Ok(ClientMetadata {
115
+
client_id: client_id.to_string(),
116
+
client_name: Some("Loopback Client".to_string()),
117
+
client_uri: None,
118
+
logo_uri: None,
119
+
redirect_uris,
120
+
grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()],
121
+
response_types: vec!["code".to_string()],
122
+
scope,
123
+
token_endpoint_auth_method: Some("none".to_string()),
124
+
dpop_bound_access_tokens: Some(false),
125
+
jwks: None,
126
+
jwks_uri: None,
127
+
application_type: Some("native".to_string()),
128
+
})
129
+
}
130
+
89
131
pub async fn get(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> {
132
+
if Self::is_loopback_client(client_id) {
133
+
return Self::build_loopback_metadata(client_id);
134
+
}
90
135
{
91
136
let cache = self.cache.read().await;
92
137
if let Some(cached) = cache.get(client_id) {
···
250
295
metadata: &ClientMetadata,
251
296
redirect_uri: &str,
252
297
) -> Result<(), OAuthError> {
253
-
if !metadata.redirect_uris.contains(&redirect_uri.to_string()) {
254
-
return Err(OAuthError::InvalidRequest(
255
-
"redirect_uri not registered for client".to_string(),
256
-
));
298
+
if metadata.redirect_uris.contains(&redirect_uri.to_string()) {
299
+
return Ok(());
257
300
}
258
-
Ok(())
301
+
if Self::is_loopback_client(&metadata.client_id) {
302
+
if let Ok(req_url) = reqwest::Url::parse(redirect_uri) {
303
+
let req_host = req_url.host_str().unwrap_or("");
304
+
let is_loopback_redirect = req_url.scheme() == "http"
305
+
&& (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]");
306
+
if is_loopback_redirect {
307
+
for registered in &metadata.redirect_uris {
308
+
if let Ok(reg_url) = reqwest::Url::parse(registered) {
309
+
let reg_host = reg_url.host_str().unwrap_or("");
310
+
let hosts_match = (req_host == "localhost" && reg_host == "localhost")
311
+
|| (req_host == "127.0.0.1" && reg_host == "127.0.0.1")
312
+
|| (req_host == "[::1]" && reg_host == "[::1]")
313
+
|| (req_host == "localhost" && reg_host == "127.0.0.1")
314
+
|| (req_host == "127.0.0.1" && reg_host == "localhost");
315
+
if hosts_match && req_url.path() == reg_url.path() {
316
+
return Ok(());
317
+
}
318
+
}
319
+
}
320
+
}
321
+
}
322
+
}
323
+
Err(OAuthError::InvalidRequest(
324
+
"redirect_uri not registered for client".to_string(),
325
+
))
259
326
}
260
327
261
328
fn validate_redirect_uri_format(&self, uri: &str) -> Result<(), OAuthError> {
···
344
411
metadata: &ClientMetadata,
345
412
client_assertion: &str,
346
413
) -> Result<(), OAuthError> {
347
-
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
414
+
use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD, STANDARD}};
348
415
let parts: Vec<&str> = client_assertion.split('.').collect();
349
416
if parts.len() != 3 {
350
417
return Err(OAuthError::InvalidClient("Invalid client_assertion format".to_string()));
351
418
}
352
419
let header_bytes = URL_SAFE_NO_PAD
353
420
.decode(parts[0])
421
+
.or_else(|_| STANDARD.decode(parts[0]))
354
422
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header encoding".to_string()))?;
355
423
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
356
424
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header JSON".to_string()))?;
···
366
434
let kid = header.get("kid").and_then(|k| k.as_str());
367
435
let payload_bytes = URL_SAFE_NO_PAD
368
436
.decode(parts[1])
369
-
.map_err(|_| OAuthError::InvalidClient("Invalid assertion payload encoding".to_string()))?;
437
+
.or_else(|_| STANDARD.decode(parts[1]))
438
+
.map_err(|e| {
439
+
tracing::warn!(error = %e, payload_part = parts[1], "Invalid assertion payload encoding");
440
+
OAuthError::InvalidClient("Invalid assertion payload encoding".to_string())
441
+
})?;
370
442
let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
371
443
.map_err(|_| OAuthError::InvalidClient("Invalid assertion payload JSON".to_string()))?;
372
444
let iss = payload.get("iss").and_then(|i| i.as_str()).ok_or_else(|| {
···
385
457
"client_assertion sub does not match client_id".to_string(),
386
458
));
387
459
}
388
-
let exp = payload.get("exp").and_then(|e| e.as_i64()).ok_or_else(|| {
389
-
OAuthError::InvalidClient("Missing exp in client_assertion".to_string())
390
-
})?;
391
460
let now = chrono::Utc::now().timestamp();
392
-
if exp < now {
393
-
return Err(OAuthError::InvalidClient("client_assertion has expired".to_string()));
461
+
let exp = payload.get("exp").and_then(|e| e.as_i64());
462
+
let iat = payload.get("iat").and_then(|i| i.as_i64());
463
+
if let Some(exp) = exp {
464
+
if exp < now {
465
+
return Err(OAuthError::InvalidClient("client_assertion has expired".to_string()));
466
+
}
467
+
} else if let Some(iat) = iat {
468
+
let max_age_secs = 300;
469
+
if now - iat > max_age_secs {
470
+
tracing::warn!(iat = iat, now = now, "client_assertion too old (no exp, using iat)");
471
+
return Err(OAuthError::InvalidClient("client_assertion is too old".to_string()));
472
+
}
473
+
} else {
474
+
return Err(OAuthError::InvalidClient(
475
+
"client_assertion must have exp or iat claim".to_string(),
476
+
));
394
477
}
395
-
let iat = payload.get("iat").and_then(|i| i.as_i64());
396
478
if let Some(iat) = iat {
397
479
if iat > now + 60 {
398
480
return Err(OAuthError::InvalidClient(
+11
src/oauth/endpoints/metadata.rs
+11
src/oauth/endpoints/metadata.rs
···
31
31
#[serde(skip_serializing_if = "Option::is_none")]
32
32
pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
33
33
#[serde(skip_serializing_if = "Option::is_none")]
34
+
pub token_endpoint_auth_signing_alg_values_supported: Option<Vec<String>>,
35
+
#[serde(skip_serializing_if = "Option::is_none")]
34
36
pub code_challenge_methods_supported: Option<Vec<String>>,
35
37
#[serde(skip_serializing_if = "Option::is_none")]
36
38
pub pushed_authorization_request_endpoint: Option<String>,
···
44
46
pub revocation_endpoint: Option<String>,
45
47
#[serde(skip_serializing_if = "Option::is_none")]
46
48
pub introspection_endpoint: Option<String>,
49
+
#[serde(skip_serializing_if = "Option::is_none")]
50
+
pub client_id_metadata_document_supported: Option<bool>,
47
51
}
48
52
49
53
pub async fn oauth_protected_resource(
···
86
90
"none".to_string(),
87
91
"private_key_jwt".to_string(),
88
92
]),
93
+
token_endpoint_auth_signing_alg_values_supported: Some(vec![
94
+
"ES256".to_string(),
95
+
"ES384".to_string(),
96
+
"ES512".to_string(),
97
+
"EdDSA".to_string(),
98
+
]),
89
99
code_challenge_methods_supported: Some(vec!["S256".to_string()]),
90
100
pushed_authorization_request_endpoint: Some(format!("{}/oauth/par", issuer)),
91
101
require_pushed_authorization_requests: Some(true),
···
98
108
authorization_response_iss_parameter_supported: Some(true),
99
109
revocation_endpoint: Some(format!("{}/oauth/revoke", issuer)),
100
110
introspection_endpoint: Some(format!("{}/oauth/introspect", issuer)),
111
+
client_id_metadata_document_supported: Some(true),
101
112
})
102
113
}
103
114
+9
-13
src/oauth/endpoints/par.rs
+9
-13
src/oauth/endpoints/par.rs
···
50
50
State(state): State<AppState>,
51
51
headers: HeaderMap,
52
52
Form(request): Form<ParRequest>,
53
-
) -> Result<Json<ParResponse>, OAuthError> {
53
+
) -> Result<(axum::http::StatusCode, Json<ParResponse>), OAuthError> {
54
54
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
55
55
if !state.check_rate_limit(RateLimitKind::OAuthPar, &client_ip).await {
56
56
tracing::warn!(ip = %client_ip, "OAuth PAR rate limit exceeded");
···
63
63
}
64
64
let code_challenge = request.code_challenge.as_ref()
65
65
.filter(|s| !s.is_empty())
66
-
.ok_or_else(|| OAuthError::InvalidRequest(
67
-
"code_challenge is required".to_string(),
68
-
))?;
66
+
.ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?;
69
67
let code_challenge_method = request.code_challenge_method.as_deref().unwrap_or("");
70
68
if code_challenge_method != "S256" {
71
69
return Err(OAuthError::InvalidRequest(
···
76
74
let client_metadata = client_cache.get(&request.client_id).await?;
77
75
client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
78
76
let client_auth = determine_client_auth(&request)?;
79
-
if client_metadata.requires_dpop() && request.dpop_jkt.is_none() {
80
-
return Err(OAuthError::InvalidRequest(
81
-
"dpop_jkt is required for this client".to_string(),
82
-
));
83
-
}
84
77
let validated_scope = validate_scope(&request.scope, &client_metadata)?;
85
78
let request_id = RequestId::generate();
86
79
let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS);
···
114
107
}
115
108
}
116
109
});
117
-
Ok(Json(ParResponse {
118
-
request_uri: request_id.0,
119
-
expires_in: PAR_EXPIRY_SECONDS as u64,
120
-
}))
110
+
Ok((
111
+
axum::http::StatusCode::CREATED,
112
+
Json(ParResponse {
113
+
request_uri: request_id.0,
114
+
expires_in: PAR_EXPIRY_SECONDS as u64,
115
+
}),
116
+
))
121
117
}
122
118
123
119
fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
+17
-4
src/oauth/endpoints/token/grants.rs
+17
-4
src/oauth/endpoints/token/grants.rs
···
42
42
.did
43
43
.ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?;
44
44
let client_metadata_cache = ClientMetadataCache::new(3600);
45
-
let client_metadata = client_metadata_cache
46
-
.get(&auth_request.client_id)
47
-
.await?;
48
-
let client_auth = auth_request.client_auth.clone().unwrap_or(ClientAuth::None);
45
+
let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?;
46
+
let client_auth = if let (Some(assertion), Some(assertion_type)) = (&request.client_assertion, &request.client_assertion_type) {
47
+
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
48
+
return Err(OAuthError::InvalidClient(
49
+
"Unsupported client_assertion_type".to_string(),
50
+
));
51
+
}
52
+
ClientAuth::PrivateKeyJwt {
53
+
client_assertion: assertion.clone(),
54
+
}
55
+
} else if let Some(secret) = &request.client_secret {
56
+
ClientAuth::SecretPost {
57
+
client_secret: secret.clone(),
58
+
}
59
+
} else {
60
+
ClientAuth::None
61
+
};
49
62
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
50
63
verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?;
51
64
if let Some(redirect_uri) = &request.redirect_uri {
+1
-1
src/oauth/templates.rs
+1
-1
src/oauth/templates.rs
···
394
394
<label for="remember_device">Remember this device</label>
395
395
</div>
396
396
<div class="buttons">
397
-
<button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button>
398
397
<button type="submit" class="btn btn-primary">Sign in</button>
398
+
<button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button>
399
399
</div>
400
400
</form>
401
401
<div class="footer">