I've been saying "PDSes seem easy enough, they're what, some CRUD to a db? I can do that in my sleep". well i'm sleeping rn so let's go

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

+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 }