this repo has no description

General fixes to make more things work

lewis 004315ce 7e0d55c4

+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
··· 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
··· 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
··· 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
··· 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
··· 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() {
+12 -13
src/api/feed/author_feed.rs
··· 44 44 Query(params): Query<GetAuthorFeedParams>, 45 45 ) -> Response { 46 46 let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); 47 - let auth_did = if let Some(h) = auth_header { 47 + let auth_user = if let Some(h) = auth_header { 48 48 if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) { 49 - match crate::auth::validate_bearer_token(&state.db, &token).await { 50 - Ok(user) => Some(user.did), 51 - Err(_) => None, 52 - } 49 + crate::auth::validate_bearer_token(&state.db, &token).await.ok() 53 50 } else { 54 51 None 55 52 } 56 53 } else { 57 54 None 58 55 }; 56 + let auth_did = auth_user.as_ref().map(|u| u.did.clone()); 57 + let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone()); 59 58 let mut query_params = HashMap::new(); 60 59 query_params.insert("actor".to_string(), params.actor.clone()); 61 60 if let Some(limit) = params.limit { ··· 71 70 query_params.insert("includePins".to_string(), include_pins.to_string()); 72 71 } 73 72 let proxy_result = 74 - match proxy_to_appview("app.bsky.feed.getAuthorFeed", &query_params, auth_header).await { 73 + match proxy_to_appview("app.bsky.feed.getAuthorFeed", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await { 75 74 Ok(r) => r, 76 75 Err(e) => return e, 77 76 }; 78 77 if !proxy_result.status.is_success() { 79 - return (proxy_result.status, proxy_result.body).into_response(); 78 + return proxy_result.into_response(); 80 79 } 81 80 let rev = match extract_repo_rev(&proxy_result.headers) { 82 81 Some(r) => r, 83 - None => return (proxy_result.status, proxy_result.body).into_response(), 82 + None => return proxy_result.into_response(), 84 83 }; 85 84 let mut feed_output: FeedOutput = match serde_json::from_slice(&proxy_result.body) { 86 85 Ok(f) => f, 87 86 Err(e) => { 88 87 warn!("Failed to parse author feed response: {:?}", e); 89 - return (proxy_result.status, proxy_result.body).into_response(); 88 + return proxy_result.into_response(); 90 89 } 91 90 }; 92 - let requester_did = match auth_did { 93 - Some(d) => d, 91 + let requester_did = match &auth_did { 92 + Some(d) => d.clone(), 94 93 None => return (StatusCode::OK, Json(feed_output)).into_response(), 95 94 }; 96 95 let actor_did = if params.actor.starts_with("did:") { ··· 111 110 Ok(None) => return (StatusCode::OK, Json(feed_output)).into_response(), 112 111 Err(e) => { 113 112 warn!("Database error resolving actor handle: {:?}", e); 114 - return (proxy_result.status, proxy_result.body).into_response(); 113 + return proxy_result.into_response(); 115 114 } 116 115 } 117 116 }; ··· 122 121 Ok(r) => r, 123 122 Err(e) => { 124 123 warn!("Failed to get local records: {}", e); 125 - return (proxy_result.status, proxy_result.body).into_response(); 124 + return proxy_result.into_response(); 126 125 } 127 126 }; 128 127 if local_records.count == 0 {
+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(&params.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
··· 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, &params.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
··· 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, &params, &auth_user.did).await; 55 + return get_timeline_with_appview(&state, &params, &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) = &params.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
··· 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(&params); 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
··· 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
··· 147 147 } 148 148 Json(json!({ 149 149 "blob": { 150 + "$type": "blob", 150 151 "ref": { 151 152 "$link": cid_str 152 153 },
+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
··· 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
··· 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
··· 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
··· 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(
+25 -8
src/oauth/endpoints/authorize.rs
··· 1 1 use axum::{ 2 2 Form, Json, 3 3 extract::{Query, State}, 4 - http::{HeaderMap, header::SET_COOKIE}, 4 + http::{HeaderMap, StatusCode, header::{SET_COOKIE, LOCATION}}, 5 5 response::{IntoResponse, Redirect, Response, Html}, 6 6 }; 7 7 use chrono::Utc; ··· 13 13 use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code}; 14 14 15 15 const DEVICE_COOKIE_NAME: &str = "oauth_device_id"; 16 + 17 + fn redirect_see_other(uri: &str) -> Response { 18 + (StatusCode::SEE_OTHER, [(LOCATION, uri.to_string())]).into_response() 19 + } 16 20 17 21 fn extract_device_cookie(headers: &HeaderMap) -> Option<String> { 18 22 headers ··· 346 350 Some(&form.username), 347 351 )).into_response() 348 352 }; 353 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 354 + let normalized_username = form.username.trim(); 355 + let normalized_username = normalized_username.strip_prefix('@').unwrap_or(normalized_username); 356 + let normalized_username = if let Some(bare_handle) = normalized_username.strip_suffix(&format!(".{}", pds_hostname)) { 357 + bare_handle.to_string() 358 + } else { 359 + normalized_username.to_string() 360 + }; 361 + tracing::debug!( 362 + original_username = %form.username, 363 + normalized_username = %normalized_username, 364 + pds_hostname = %pds_hostname, 365 + "Normalized username for lookup" 366 + ); 349 367 let user = match sqlx::query!( 350 368 r#" 351 369 SELECT id, did, email, password_hash, two_factor_enabled, ··· 354 372 FROM users 355 373 WHERE handle = $1 OR email = $1 356 374 "#, 357 - form.username 375 + normalized_username 358 376 ) 359 377 .fetch_optional(&state.db) 360 378 .await ··· 447 465 &code.0, 448 466 request_data.parameters.state.as_deref(), 449 467 ); 450 - let redirect = Redirect::temporary(&redirect_url); 451 468 if let Some(cookie) = new_cookie { 452 - ([(SET_COOKIE, cookie)], redirect).into_response() 469 + (StatusCode::SEE_OTHER, [(SET_COOKIE, cookie), (LOCATION, redirect_url)]).into_response() 453 470 } else { 454 - redirect.into_response() 471 + redirect_see_other(&redirect_url) 455 472 } 456 473 } 457 474 ··· 586 603 &code.0, 587 604 request_data.parameters.state.as_deref(), 588 605 ); 589 - Redirect::temporary(&redirect_url).into_response() 606 + redirect_see_other(&redirect_url) 590 607 } 591 608 592 609 fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) -> String { ··· 625 642 if let Some(state) = &request_data.parameters.state { 626 643 redirect_url.push_str(&format!("&state={}", url_encode(state))); 627 644 } 628 - Ok(Redirect::temporary(&redirect_url).into_response()) 645 + Ok(redirect_see_other(&redirect_url)) 629 646 } 630 647 631 648 #[derive(Debug, Deserialize)] ··· 812 829 &code.0, 813 830 request_data.parameters.state.as_deref(), 814 831 ); 815 - Redirect::temporary(&redirect_url).into_response() 832 + redirect_see_other(&redirect_url) 816 833 }
+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
··· 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
··· 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
··· 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">