Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

General linting, document react-native-streamplace-oauth-problem

+213 -177
+23
KNOWN_ISSUES.md
··· 1 + # Known Issues 2 + 3 + ## stream.place iOS app OAuth flow fails 4 + 5 + OAuth flow with stream.place's iOS app (using expo-web-browser's ASWebAuthenticationSession) does not complete. After user approves consent, the redirect from our PDS to stream.place's callback URL is not followed by ASWebAuthenticationSession. 6 + 7 + What does work with stream.place: everything else :P 8 + - Desktop browsers 9 + - ios safari (regular browser) 10 + - ASWebAuthenticationSession using the reference pds 11 + 12 + What fails: 13 + - ASWebAuthenticationSession with this pds 14 + 15 + Attempted fixes (all failed): 16 + - HTTP 302/303/307 redirects 17 + - JavaScript navigation 18 + - Meta refresh 19 + - Form auto-submit 20 + - Removing CORS headers 21 + - HTTP/1.1 instead of HTTP/2 22 + - Minimal response headers 23 +
+1 -1
frontend/src/components/migration/ChooseHandleStep.svelte
··· 111 111 </div> 112 112 113 113 <div class="field"> 114 - <label>{$_('migration.inbound.chooseHandle.authMethod')}</label> 114 + <span class="field-label">{$_('migration.inbound.chooseHandle.authMethod')}</span> 115 115 <div class="auth-method-options"> 116 116 <label class="auth-option" class:selected={authMethod === 'password'}> 117 117 <input
-18
frontend/src/routes/Admin.svelte
··· 674 674 padding: var(--space-7); 675 675 } 676 676 677 - .message { 678 - padding: var(--space-3); 679 - border-radius: var(--radius-md); 680 - margin-bottom: var(--space-4); 681 - } 682 - 683 - .message.error { 684 - background: var(--error-bg); 685 - border: 1px solid var(--error-border); 686 - color: var(--error-text); 687 - } 688 - 689 - .message.success { 690 - background: var(--success-bg); 691 - border: 1px solid var(--success-border); 692 - color: var(--success-text); 693 - } 694 - 695 677 .config-form { 696 678 max-width: 500px; 697 679 }
-9
frontend/src/routes/AppPasswords.svelte
··· 234 234 margin-bottom: var(--space-7); 235 235 } 236 236 237 - .error { 238 - padding: var(--space-3); 239 - background: var(--error-bg); 240 - border: 1px solid var(--error-border); 241 - border-radius: var(--radius-md); 242 - color: var(--error-text); 243 - margin-bottom: var(--space-4); 244 - } 245 - 246 237 .created-password { 247 238 display: flex; 248 239 flex-direction: column;
-6
frontend/src/routes/Comms.svelte
··· 412 412 margin: var(--space-2) 0 0 0; 413 413 } 414 414 415 - .loading { 416 - text-align: center; 417 - color: var(--text-secondary); 418 - padding: var(--space-7); 419 - } 420 - 421 415 .split-layout { 422 416 display: grid; 423 417 grid-template-columns: 1fr;
-19
frontend/src/routes/Controllers.svelte
··· 453 453 margin: var(--space-2) 0 0 0; 454 454 } 455 455 456 - .loading, 457 456 .empty { 458 457 text-align: center; 459 458 color: var(--text-secondary); 460 459 padding: var(--space-4); 461 - } 462 - 463 - .message { 464 - padding: var(--space-3); 465 - border-radius: var(--radius-md); 466 - margin-bottom: var(--space-4); 467 - } 468 - 469 - .message.error { 470 - background: var(--error-bg); 471 - border: 1px solid var(--error-border); 472 - color: var(--error-text); 473 - } 474 - 475 - .message.success { 476 - background: var(--success-bg); 477 - border: 1px solid var(--success-border); 478 - color: var(--success-text); 479 460 } 480 461 481 462 .constraint-notice {
-10
frontend/src/routes/DelegationAudit.svelte
··· 216 216 margin: var(--space-2) 0 0 0; 217 217 } 218 218 219 - .loading, 220 219 .empty { 221 220 text-align: center; 222 221 color: var(--text-secondary); 223 222 padding: var(--space-7); 224 - } 225 - 226 - .message.error { 227 - padding: var(--space-3); 228 - background: var(--error-bg); 229 - border: 1px solid var(--error-border); 230 - border-radius: var(--radius-md); 231 - color: var(--error-text); 232 - margin-bottom: var(--space-4); 233 223 } 234 224 235 225 .audit-list {
-6
frontend/src/routes/DidDocumentEditor.svelte
··· 439 439 margin-top: var(--space-6); 440 440 } 441 441 442 - .loading { 443 - text-align: center; 444 - padding: var(--space-9); 445 - color: var(--text-secondary); 446 - } 447 - 448 442 @media (max-width: 600px) { 449 443 .field-row { 450 444 flex-direction: column;
-9
frontend/src/routes/InviteCodes.svelte
··· 192 192 margin-bottom: var(--space-7); 193 193 } 194 194 195 - .error { 196 - padding: var(--space-3); 197 - background: var(--error-bg); 198 - border: 1px solid var(--error-border); 199 - border-radius: var(--radius-md); 200 - color: var(--error-text); 201 - margin-bottom: var(--space-4); 202 - } 203 - 204 195 .created-code { 205 196 padding: var(--space-6); 206 197 background: var(--success-bg);
+10 -8
frontend/src/routes/Migration.svelte
··· 74 74 75 75 if (!hasOAuthCallback) { 76 76 if (hasPendingMigration()) { 77 - resumeInfo = getResumeInfo() 78 - if (resumeInfo) { 79 - if (resumeInfo.step === 'success') { 77 + const info = getResumeInfo() 78 + if (info) { 79 + if (info.step === 'success') { 80 80 clearMigrationState() 81 - resumeInfo = null 82 81 } else { 82 + resumeInfo = info 83 83 const stored = loadMigrationState() 84 84 if (stored && stored.direction === 'inbound') { 85 85 direction = 'inbound' 86 - inboundFlow = createInboundMigrationFlow() 87 - inboundFlow.resumeFromState(stored) 86 + const flow = createInboundMigrationFlow() 87 + flow.resumeFromState(stored) 88 + inboundFlow = flow 88 89 } 89 90 } 90 91 } ··· 94 95 clearOfflineState() 95 96 } else { 96 97 direction = 'offline-inbound' 97 - offlineFlow = createOfflineInboundMigrationFlow() 98 - offlineFlow.tryResume() 98 + const flow = createOfflineInboundMigrationFlow() 99 + flow.tryResume() 100 + offlineFlow = flow 99 101 } 100 102 } 101 103 }
+2 -2
frontend/src/routes/OAuthConsent.svelte
··· 93 93 body: JSON.stringify({ 94 94 request_uri: consentData.request_uri, 95 95 approved_scopes: approvedScopes, 96 - remember: rememberChoice 97 - }) 96 + remember: rememberChoice, 97 + }), 98 98 }) 99 99 100 100 if (!response.ok) {
-6
frontend/src/routes/Register.svelte
··· 516 516 color: var(--error-text); 517 517 } 518 518 519 - .section-hint { 520 - font-size: var(--text-sm); 521 - color: var(--text-secondary); 522 - margin: 0 0 var(--space-5) 0; 523 - } 524 - 525 519 .radio-group { 526 520 display: flex; 527 521 flex-direction: column;
-6
frontend/src/routes/RepoExplorer.svelte
··· 599 599 color: var(--success-text); 600 600 } 601 601 602 - .loading-text { 603 - text-align: center; 604 - color: var(--text-secondary); 605 - padding: var(--space-7); 606 - } 607 - 608 602 .toolbar { 609 603 display: flex; 610 604 gap: var(--space-2);
-6
frontend/src/routes/Security.svelte
··· 795 795 margin: var(--space-2) 0 0 0; 796 796 } 797 797 798 - .loading { 799 - text-align: center; 800 - color: var(--text-secondary); 801 - padding: var(--space-7); 802 - } 803 - 804 798 section { 805 799 padding: var(--space-6); 806 800 background: var(--bg-secondary);
+1 -2
frontend/src/routes/Settings.svelte
··· 960 960 font-size: var(--text-xs); 961 961 } 962 962 963 - .empty, 964 - .loading { 963 + .empty { 965 964 color: var(--text-secondary); 966 965 font-size: var(--text-sm); 967 966 margin-bottom: var(--space-4);
-6
frontend/src/routes/TrustedDevices.svelte
··· 244 244 font-size: var(--text-sm); 245 245 } 246 246 247 - .loading { 248 - text-align: center; 249 - padding: var(--space-7); 250 - color: var(--text-secondary); 251 - } 252 - 253 247 .empty-state { 254 248 text-align: center; 255 249 padding: var(--space-8) var(--space-4);
+8
frontend/src/styles/migration.css
··· 3 3 margin: 0 auto; 4 4 } 5 5 6 + .field-label { 7 + display: block; 8 + font-size: var(--text-sm); 9 + font-weight: var(--font-medium); 10 + color: var(--text-primary); 11 + margin-bottom: var(--space-2); 12 + } 13 + 6 14 .step-indicator { 7 15 display: flex; 8 16 align-items: center;
+4
src/lib.rs
··· 550 550 .route("/authorize/deny", post(oauth::endpoints::authorize_deny)) 551 551 .route("/authorize/consent", get(oauth::endpoints::consent_get)) 552 552 .route("/authorize/consent", post(oauth::endpoints::consent_post)) 553 + .route( 554 + "/authorize/redirect", 555 + get(oauth::endpoints::authorize_redirect), 556 + ) 553 557 .route("/delegation/auth", post(oauth::endpoints::delegation_auth)) 554 558 .route( 555 559 "/delegation/totp",
+129 -62
src/oauth/endpoints/authorize.rs
··· 22 22 const DEVICE_COOKIE_NAME: &str = "oauth_device_id"; 23 23 24 24 fn redirect_see_other(uri: &str) -> Response { 25 - (StatusCode::SEE_OTHER, [(LOCATION, uri.to_string())]).into_response() 25 + ( 26 + StatusCode::SEE_OTHER, 27 + [ 28 + (LOCATION, uri.to_string()), 29 + (axum::http::header::CACHE_CONTROL, "no-store".to_string()), 30 + ( 31 + SET_COOKIE, 32 + "bfCacheBypass=foo; max-age=1; SameSite=Lax".to_string(), 33 + ), 34 + ], 35 + ) 36 + .into_response() 26 37 } 27 38 28 39 fn redirect_to_frontend_error(error: &str, description: &str) -> Response { ··· 783 794 { 784 795 return show_login_error("An error occurred. Please try again.", json_response); 785 796 } 786 - let redirect_url = build_success_redirect( 787 - &request_data.parameters.redirect_uri, 788 - &code.0, 789 - request_data.parameters.state.as_deref(), 790 - request_data.parameters.response_mode.as_deref(), 791 - ); 792 797 if json_response { 798 + let redirect_url = build_intermediate_redirect_url( 799 + &request_data.parameters.redirect_uri, 800 + &code.0, 801 + request_data.parameters.state.as_deref(), 802 + request_data.parameters.response_mode.as_deref(), 803 + ); 793 804 if let Some(cookie) = new_cookie { 794 805 ( 795 806 StatusCode::OK, ··· 800 811 } else { 801 812 Json(serde_json::json!({"redirect_uri": redirect_url})).into_response() 802 813 } 803 - } else if let Some(cookie) = new_cookie { 804 - ( 805 - StatusCode::SEE_OTHER, 806 - [(SET_COOKIE, cookie), (LOCATION, redirect_url)], 807 - ) 808 - .into_response() 809 814 } else { 810 - redirect_see_other(&redirect_url) 815 + let redirect_url = build_success_redirect( 816 + &request_data.parameters.redirect_uri, 817 + &code.0, 818 + request_data.parameters.state.as_deref(), 819 + request_data.parameters.response_mode.as_deref(), 820 + ); 821 + if let Some(cookie) = new_cookie { 822 + ( 823 + StatusCode::SEE_OTHER, 824 + [(SET_COOKIE, cookie), (LOCATION, redirect_url)], 825 + ) 826 + .into_response() 827 + } else { 828 + redirect_see_other(&redirect_url) 829 + } 811 830 } 812 831 } 813 832 ··· 984 1003 "An error occurred. Please try again.", 985 1004 ); 986 1005 } 987 - let redirect_url = build_success_redirect( 1006 + let redirect_url = build_intermediate_redirect_url( 988 1007 &request_data.parameters.redirect_uri, 989 1008 &code.0, 990 1009 request_data.parameters.state.as_deref(), ··· 1012 1031 '?' 1013 1032 }; 1014 1033 redirect_url.push(separator); 1015 - redirect_url.push_str(&format!("code={}", url_encode(code))); 1016 - if let Some(req_state) = state { 1017 - redirect_url.push_str(&format!("&state={}", url_encode(req_state))); 1018 - } 1019 1034 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1020 1035 redirect_url.push_str(&format!( 1021 - "&iss={}", 1036 + "iss={}", 1022 1037 url_encode(&format!("https://{}", pds_hostname)) 1023 1038 )); 1039 + if let Some(req_state) = state { 1040 + redirect_url.push_str(&format!("&state={}", url_encode(req_state))); 1041 + } 1042 + redirect_url.push_str(&format!("&code={}", url_encode(code))); 1024 1043 redirect_url 1025 1044 } 1026 1045 1046 + fn build_intermediate_redirect_url( 1047 + redirect_uri: &str, 1048 + code: &str, 1049 + state: Option<&str>, 1050 + response_mode: Option<&str>, 1051 + ) -> String { 1052 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1053 + let mut url = format!( 1054 + "https://{}/oauth/authorize/redirect?redirect_uri={}&code={}", 1055 + pds_hostname, 1056 + url_encode(redirect_uri), 1057 + url_encode(code) 1058 + ); 1059 + if let Some(s) = state { 1060 + url.push_str(&format!("&state={}", url_encode(s))); 1061 + } 1062 + if let Some(rm) = response_mode { 1063 + url.push_str(&format!("&response_mode={}", url_encode(rm))); 1064 + } 1065 + url 1066 + } 1067 + 1068 + #[derive(Debug, Deserialize)] 1069 + pub struct AuthorizeRedirectParams { 1070 + redirect_uri: String, 1071 + code: String, 1072 + state: Option<String>, 1073 + response_mode: Option<String>, 1074 + } 1075 + 1076 + pub async fn authorize_redirect(Query(params): Query<AuthorizeRedirectParams>) -> Response { 1077 + let final_url = build_success_redirect( 1078 + &params.redirect_uri, 1079 + &params.code, 1080 + params.state.as_deref(), 1081 + params.response_mode.as_deref(), 1082 + ); 1083 + tracing::info!( 1084 + final_url = %final_url, 1085 + client_redirect = %params.redirect_uri, 1086 + "authorize_redirect performing 303 redirect" 1087 + ); 1088 + ( 1089 + StatusCode::SEE_OTHER, 1090 + [ 1091 + (axum::http::header::LOCATION, final_url), 1092 + (axum::http::header::CACHE_CONTROL, "no-store".to_string()), 1093 + ], 1094 + ) 1095 + .into_response() 1096 + } 1097 + 1027 1098 #[derive(Debug, Serialize)] 1028 1099 pub struct AuthorizeDenyResponse { 1029 1100 pub error: String, ··· 1367 1438 } 1368 1439 }; 1369 1440 1370 - if let Some(err_response) = validate_auth_flow_state(&flow_state, true) { 1371 - if flow_state.is_expired() { 1372 - let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; 1373 - } 1374 - return err_response; 1441 + if flow_state.is_expired() { 1442 + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; 1443 + return json_error( 1444 + StatusCode::BAD_REQUEST, 1445 + "invalid_request", 1446 + "Authorization request has expired", 1447 + ); 1448 + } 1449 + if flow_state.is_pending() { 1450 + return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated"); 1375 1451 } 1376 1452 1377 1453 let did = flow_state.did().unwrap().to_string(); ··· 1420 1496 && !has_granular_scopes 1421 1497 && !form.approved_scopes.contains(&"atproto".to_string()) 1422 1498 { 1423 - return ( 1499 + return json_error( 1424 1500 StatusCode::BAD_REQUEST, 1425 - Json(serde_json::json!({ 1426 - "error": "invalid_request", 1427 - "error_description": "The atproto scope was requested and must be approved" 1428 - })), 1429 - ) 1430 - .into_response(); 1501 + "invalid_request", 1502 + "The atproto scope was requested and must be approved", 1503 + ); 1431 1504 } 1432 1505 let final_approved: Vec<String> = if user_denied_some_granular { 1433 1506 form.approved_scopes ··· 1439 1512 form.approved_scopes.clone() 1440 1513 }; 1441 1514 if final_approved.is_empty() { 1442 - return ( 1515 + return json_error( 1443 1516 StatusCode::BAD_REQUEST, 1444 - Json(serde_json::json!({ 1445 - "error": "invalid_request", 1446 - "error_description": "At least one scope must be approved" 1447 - })), 1448 - ) 1449 - .into_response(); 1517 + "invalid_request", 1518 + "At least one scope must be approved", 1519 + ); 1450 1520 } 1451 1521 let approved_scope_str = final_approved.join(" "); 1452 1522 let has_valid_scope = final_approved.iter().all(|s| { ··· 1462 1532 || s.starts_with("include:") 1463 1533 }); 1464 1534 if !has_valid_scope { 1465 - return ( 1535 + return json_error( 1466 1536 StatusCode::BAD_REQUEST, 1467 - Json(serde_json::json!({ 1468 - "error": "invalid_request", 1469 - "error_description": "Invalid scope format" 1470 - })), 1471 - ) 1472 - .into_response(); 1537 + "invalid_request", 1538 + "Invalid scope format", 1539 + ); 1473 1540 } 1474 1541 if form.remember { 1475 1542 let preferences: Vec<db::ScopePreference> = requested_scopes ··· 1503 1570 .await 1504 1571 .is_err() 1505 1572 { 1506 - return ( 1573 + return json_error( 1507 1574 StatusCode::INTERNAL_SERVER_ERROR, 1508 - Json(serde_json::json!({ 1509 - "error": "server_error", 1510 - "error_description": "Failed to complete authorization" 1511 - })), 1512 - ) 1513 - .into_response(); 1575 + "server_error", 1576 + "Failed to complete authorization", 1577 + ); 1514 1578 } 1515 - let redirect_url = build_success_redirect( 1516 - &request_data.parameters.redirect_uri, 1579 + let redirect_uri = &request_data.parameters.redirect_uri; 1580 + let intermediate_url = build_intermediate_redirect_url( 1581 + redirect_uri, 1517 1582 &code.0, 1518 1583 request_data.parameters.state.as_deref(), 1519 1584 request_data.parameters.response_mode.as_deref(), 1520 1585 ); 1521 - Json(serde_json::json!({ 1522 - "redirect_uri": redirect_url 1523 - })) 1524 - .into_response() 1586 + tracing::info!( 1587 + intermediate_url = %intermediate_url, 1588 + client_redirect = %redirect_uri, 1589 + "consent_post returning JSON with intermediate URL (for 303 redirect)" 1590 + ); 1591 + Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response() 1525 1592 } 1526 1593 1527 1594 pub async fn authorize_2fa_post( ··· 1630 1697 "An error occurred. Please try again.", 1631 1698 ); 1632 1699 } 1633 - let redirect_url = build_success_redirect( 1700 + let redirect_url = build_intermediate_redirect_url( 1634 1701 &request_data.parameters.redirect_uri, 1635 1702 &code.0, 1636 1703 request_data.parameters.state.as_deref(), ··· 1725 1792 "An error occurred. Please try again.", 1726 1793 ); 1727 1794 } 1728 - let redirect_url = build_success_redirect( 1795 + let redirect_url = build_intermediate_redirect_url( 1729 1796 &request_data.parameters.redirect_uri, 1730 1797 &code.0, 1731 1798 request_data.parameters.state.as_deref(), ··· 2367 2434 .into_response(); 2368 2435 } 2369 2436 2370 - let redirect_url = build_success_redirect( 2437 + let redirect_url = build_intermediate_redirect_url( 2371 2438 &request_data.parameters.redirect_uri, 2372 2439 &code.0, 2373 2440 request_data.parameters.state.as_deref(),
+35 -1
src/util.rs
··· 154 154 } 155 155 156 156 pub fn build_full_url(path: &str) -> String { 157 - format!("{}{}", pds_public_url(), path) 157 + let normalized_path = if !path.starts_with("/xrpc/") 158 + && (path.starts_with("/com.atproto.") 159 + || path.starts_with("/app.bsky.") 160 + || path.starts_with("/_")) 161 + { 162 + format!("/xrpc{}", path) 163 + } else { 164 + path.to_string() 165 + }; 166 + format!("{}{}", pds_public_url(), normalized_path) 158 167 } 159 168 160 169 pub fn json_to_ipld(value: &JsonValue) -> Ipld { ··· 354 363 return; 355 364 } 356 365 panic!("Failed to find CID link in parsed CBOR"); 366 + } 367 + 368 + #[test] 369 + fn test_build_full_url_adds_xrpc_prefix_for_atproto_paths() { 370 + unsafe { std::env::set_var("PDS_HOSTNAME", "example.com") }; 371 + assert_eq!( 372 + build_full_url("/com.atproto.server.getSession"), 373 + "https://example.com/xrpc/com.atproto.server.getSession" 374 + ); 375 + assert_eq!( 376 + build_full_url("/app.bsky.feed.getTimeline"), 377 + "https://example.com/xrpc/app.bsky.feed.getTimeline" 378 + ); 379 + assert_eq!( 380 + build_full_url("/_health"), 381 + "https://example.com/xrpc/_health" 382 + ); 383 + assert_eq!( 384 + build_full_url("/xrpc/com.atproto.server.getSession"), 385 + "https://example.com/xrpc/com.atproto.server.getSession" 386 + ); 387 + assert_eq!( 388 + build_full_url("/oauth/token"), 389 + "https://example.com/oauth/token" 390 + ); 357 391 } 358 392 }