slack status without the slack status.zzstoatzz.io/
quickslice

Merge pull request #32 from zzstoatzz/fix/add-getfollows-scope

Fix: Add missing getFollows scope for following feed

authored by

nate nowack and committed by
GitHub
7548698e 0aba058e

+660 -39
+1
CLAUDE.md
··· 1 + - fly logs is a blocking command, you need to run it in the background
+108 -6
Cargo.lock
··· 62 62 "flate2", 63 63 "foldhash", 64 64 "futures-core", 65 - "h2", 65 + "h2 0.3.26", 66 66 "http 0.2.12", 67 67 "httparse", 68 68 "httpdate", ··· 477 477 "quote", 478 478 "syn", 479 479 ] 480 + 481 + [[package]] 482 + name = "atomic-waker" 483 + version = "1.1.2" 484 + source = "registry+https://github.com/rust-lang/crates.io-index" 485 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 480 486 481 487 [[package]] 482 488 name = "atrium-api" ··· 1489 1495 ] 1490 1496 1491 1497 [[package]] 1498 + name = "h2" 1499 + version = "0.4.12" 1500 + source = "registry+https://github.com/rust-lang/crates.io-index" 1501 + checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" 1502 + dependencies = [ 1503 + "atomic-waker", 1504 + "bytes", 1505 + "fnv", 1506 + "futures-core", 1507 + "futures-sink", 1508 + "http 1.2.0", 1509 + "indexmap", 1510 + "slab", 1511 + "tokio", 1512 + "tokio-util", 1513 + "tracing", 1514 + ] 1515 + 1516 + [[package]] 1492 1517 name = "hashbrown" 1493 1518 version = "0.14.5" 1494 1519 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1666 1691 "bytes", 1667 1692 "futures-channel", 1668 1693 "futures-util", 1694 + "h2 0.4.12", 1669 1695 "http 1.2.0", 1670 1696 "http-body", 1671 1697 "httparse", ··· 1677 1703 ] 1678 1704 1679 1705 [[package]] 1706 + name = "hyper-rustls" 1707 + version = "0.27.7" 1708 + source = "registry+https://github.com/rust-lang/crates.io-index" 1709 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1710 + dependencies = [ 1711 + "http 1.2.0", 1712 + "hyper", 1713 + "hyper-util", 1714 + "rustls 0.23.28", 1715 + "rustls-pki-types", 1716 + "tokio", 1717 + "tokio-rustls 0.26.2", 1718 + "tower-service", 1719 + ] 1720 + 1721 + [[package]] 1680 1722 name = "hyper-tls" 1681 1723 version = "0.6.0" 1682 1724 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2278 2320 "hickory-resolver", 2279 2321 "log", 2280 2322 "rand 0.8.5", 2323 + "reqwest", 2281 2324 "rocketman", 2282 2325 "serde", 2283 2326 "serde_json", ··· 2676 2719 "async-compression", 2677 2720 "base64 0.22.1", 2678 2721 "bytes", 2722 + "encoding_rs", 2679 2723 "futures-core", 2680 2724 "futures-util", 2725 + "h2 0.4.12", 2681 2726 "http 1.2.0", 2682 2727 "http-body", 2683 2728 "http-body-util", 2684 2729 "hyper", 2730 + "hyper-rustls", 2685 2731 "hyper-tls", 2686 2732 "hyper-util", 2687 2733 "ipnet", ··· 2697 2743 "serde_json", 2698 2744 "serde_urlencoded", 2699 2745 "sync_wrapper", 2746 + "system-configuration", 2700 2747 "tokio", 2701 2748 "tokio-native-tls", 2702 2749 "tokio-util", ··· 2822 2869 dependencies = [ 2823 2870 "log", 2824 2871 "ring", 2825 - "rustls-webpki", 2872 + "rustls-webpki 0.101.7", 2826 2873 "sct", 2827 2874 ] 2828 2875 2829 2876 [[package]] 2877 + name = "rustls" 2878 + version = "0.23.28" 2879 + source = "registry+https://github.com/rust-lang/crates.io-index" 2880 + checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" 2881 + dependencies = [ 2882 + "once_cell", 2883 + "rustls-pki-types", 2884 + "rustls-webpki 0.103.3", 2885 + "subtle", 2886 + "zeroize", 2887 + ] 2888 + 2889 + [[package]] 2830 2890 name = "rustls-native-certs" 2831 2891 version = "0.6.3" 2832 2892 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2869 2929 checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" 2870 2930 dependencies = [ 2871 2931 "ring", 2932 + "untrusted", 2933 + ] 2934 + 2935 + [[package]] 2936 + name = "rustls-webpki" 2937 + version = "0.103.3" 2938 + source = "registry+https://github.com/rust-lang/crates.io-index" 2939 + checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" 2940 + dependencies = [ 2941 + "ring", 2942 + "rustls-pki-types", 2872 2943 "untrusted", 2873 2944 ] 2874 2945 ··· 3163 3234 ] 3164 3235 3165 3236 [[package]] 3237 + name = "system-configuration" 3238 + version = "0.6.1" 3239 + source = "registry+https://github.com/rust-lang/crates.io-index" 3240 + checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" 3241 + dependencies = [ 3242 + "bitflags", 3243 + "core-foundation", 3244 + "system-configuration-sys", 3245 + ] 3246 + 3247 + [[package]] 3248 + name = "system-configuration-sys" 3249 + version = "0.6.0" 3250 + source = "registry+https://github.com/rust-lang/crates.io-index" 3251 + checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 3252 + dependencies = [ 3253 + "core-foundation-sys", 3254 + "libc", 3255 + ] 3256 + 3257 + [[package]] 3166 3258 name = "tagptr" 3167 3259 version = "0.2.0" 3168 3260 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3313 3405 source = "registry+https://github.com/rust-lang/crates.io-index" 3314 3406 checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" 3315 3407 dependencies = [ 3316 - "rustls", 3408 + "rustls 0.21.12", 3409 + "tokio", 3410 + ] 3411 + 3412 + [[package]] 3413 + name = "tokio-rustls" 3414 + version = "0.26.2" 3415 + source = "registry+https://github.com/rust-lang/crates.io-index" 3416 + checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 3417 + dependencies = [ 3418 + "rustls 0.23.28", 3317 3419 "tokio", 3318 3420 ] 3319 3421 ··· 3325 3427 dependencies = [ 3326 3428 "futures-util", 3327 3429 "log", 3328 - "rustls", 3430 + "rustls 0.21.12", 3329 3431 "rustls-native-certs", 3330 3432 "tokio", 3331 - "tokio-rustls", 3433 + "tokio-rustls 0.24.1", 3332 3434 "tungstenite", 3333 3435 "webpki-roots", 3334 3436 ] ··· 3465 3567 "httparse", 3466 3568 "log", 3467 3569 "rand 0.8.5", 3468 - "rustls", 3570 + "rustls 0.21.12", 3469 3571 "sha1", 3470 3572 "thiserror", 3471 3573 "url",
+1
Cargo.toml
··· 28 28 async-sqlite = "0.5.0" 29 29 async-trait = "0.1.88" 30 30 rand = "0.8" 31 + reqwest = { version = "0.12", features = ["json"] } 31 32 32 33 [build-dependencies] 33 34 askama = "0.13"
+49 -33
src/main.rs
··· 88 88 /// OAuth client metadata endpoint for production 89 89 #[get("/oauth-client-metadata.json")] 90 90 async fn client_metadata(config: web::Data<config::Config>) -> Result<HttpResponse> { 91 + // Note: This also handles /oauth-client-metadata.json?v=2 etc 91 92 let public_url = config.oauth_redirect_base.clone(); 92 93 93 94 let metadata = serde_json::json!({ 94 - "client_id": format!("{}/oauth-client-metadata.json", public_url), 95 + "client_id": format!("{}/oauth-client-metadata.json?v=2", public_url), 95 96 "client_name": "Status Sphere", 96 97 "client_uri": public_url.clone(), 97 98 "redirect_uris": [format!("{}/oauth/callback", public_url)], 98 - "scope": "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview", 99 + "scope": "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app", 99 100 "grant_types": ["authorization_code", "refresh_token"], 100 101 "response_types": ["code"], 101 102 "token_endpoint_auth_method": "none", ··· 239 240 Scope::Unknown("repo:io.zzstoatzz.status.record".to_string()), 240 241 // Need to read profiles for the feed page 241 242 Scope::Unknown("rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview".to_string()), 243 + // Need to read following list for following feed 244 + Scope::Unknown("rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app".to_string()), 242 245 ], 243 246 ..Default::default() 244 247 }, ··· 696 699 #[get("/api/following")] 697 700 async fn get_following( 698 701 session: Session, 699 - oauth_client: web::Data<OAuthClientType>, 702 + _oauth_client: web::Data<OAuthClientType>, 700 703 ) -> Result<impl Responder> { 701 704 // Check if user is logged in 702 705 let did = match session.get::<Did>("did").ok().flatten() { ··· 708 711 } 709 712 }; 710 713 711 - // Restore OAuth session 712 - let bsky_session = match oauth_client.restore(&did).await { 713 - Ok(session) => session, 714 - Err(err) => { 715 - log::error!("Failed to restore session: {}", err); 716 - return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 717 - "error": "Failed to restore session" 718 - }))); 719 - } 720 - }; 714 + // WORKAROUND: Call public API directly for getFollows since OAuth scope isn't working 715 + // Both getProfile and getFollows are public endpoints that don't require auth 716 + // but when called through OAuth, getFollows requires a scope that doesn't exist yet 721 717 722 - let agent = Agent::new(bsky_session); 718 + let mut all_follows = Vec::new(); 719 + let mut cursor: Option<String> = None; 723 720 724 - // Fetch follows from Bluesky 725 - let mut all_follows = Vec::new(); 726 - let mut cursor = None; 721 + // Use reqwest to call the public API directly 722 + let client = reqwest::Client::new(); 727 723 728 724 loop { 729 - let params = atrium_api::app::bsky::graph::get_follows::ParametersData { 730 - actor: atrium_api::types::string::AtIdentifier::Did(did.clone()), 731 - limit: None, // Use default limit 732 - cursor: cursor.clone(), 733 - }; 725 + let mut url = format!( 726 + "https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor={}", 727 + did.as_str() 728 + ); 734 729 735 - match agent.api.app.bsky.graph.get_follows(params.into()).await { 730 + if let Some(c) = &cursor { 731 + url.push_str(&format!("&cursor={}", c)); 732 + } 733 + 734 + match client.get(&url).send().await { 736 735 Ok(response) => { 737 - // Extract DIDs from the follows 738 - for follow in &response.follows { 739 - all_follows.push(follow.did.to_string()); 740 - } 736 + match response.json::<serde_json::Value>().await { 737 + Ok(json) => { 738 + // Extract follows 739 + if let Some(follows) = json["follows"].as_array() { 740 + for follow in follows { 741 + if let Some(did_str) = follow["did"].as_str() { 742 + all_follows.push(did_str.to_string()); 743 + } 744 + } 745 + } 741 746 742 - // Check if there are more pages 743 - cursor = response.cursor.clone(); 744 - if cursor.is_none() { 745 - break; 747 + // Check for cursor 748 + cursor = json["cursor"].as_str().map(|s| s.to_string()); 749 + if cursor.is_none() { 750 + break; 751 + } 752 + } 753 + Err(err) => { 754 + log::error!("Failed to parse follows response: {}", err); 755 + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 756 + "error": "Failed to parse follows" 757 + }))); 758 + } 746 759 } 747 760 } 748 761 Err(err) => { 749 - log::error!("Failed to fetch follows: {}", err); 762 + log::error!("Failed to fetch follows from public API: {}", err); 750 763 return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 751 764 "error": "Failed to fetch follows" 752 765 }))); ··· 1496 1509 1497 1510 let oauth_config = OAuthClientConfig { 1498 1511 client_metadata: AtprotoClientMetadata { 1499 - client_id: format!("{}/oauth-client-metadata.json", config.oauth_redirect_base), 1512 + client_id: format!( 1513 + "{}/oauth-client-metadata.json?v=2", 1514 + config.oauth_redirect_base 1515 + ), 1500 1516 client_uri: Some(config.oauth_redirect_base.clone()), 1501 1517 redirect_uris: vec![format!("{}/oauth/callback", config.oauth_redirect_base)], 1502 1518 token_endpoint_auth_method: AuthMethod::None,
+102
test_metadata_validation.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Check if Bluesky can fetch and validate our metadata 4 + """ 5 + import requests 6 + import json 7 + 8 + def test_metadata_validation(): 9 + """Test if the metadata is valid and accessible""" 10 + 11 + print("=" * 60) 12 + print("METADATA VALIDATION TEST") 13 + print("=" * 60) 14 + 15 + metadata_url = "https://zzstoatzz-status-pr-32.fly.dev/oauth-client-metadata.json" 16 + 17 + # 1. Fetch the metadata 18 + print(f"\n1. Fetching metadata from: {metadata_url}") 19 + response = requests.get(metadata_url) 20 + 21 + if response.status_code != 200: 22 + print(f"✗ Failed to fetch metadata: {response.status_code}") 23 + return 24 + 25 + metadata = response.json() 26 + print("✓ Metadata fetched successfully") 27 + 28 + # 2. Check what Bluesky would see 29 + print("\n2. Metadata content:") 30 + print(json.dumps(metadata, indent=2)) 31 + 32 + # 3. Check if the metadata is being cached 33 + print("\n3. Testing if metadata is cached...") 34 + headers = { 35 + "User-Agent": "Bluesky OAuth Client", 36 + "Accept": "application/json" 37 + } 38 + 39 + # Make multiple requests to see if it's consistent 40 + for i in range(3): 41 + r = requests.get(metadata_url, headers=headers) 42 + if r.status_code == 200: 43 + data = r.json() 44 + print(f" Request {i+1}: scope = {data['scope'][:50]}...") 45 + else: 46 + print(f" Request {i+1}: Failed with {r.status_code}") 47 + 48 + # 4. Check if there's a mismatch between what we declare and what we request 49 + print("\n4. Checking scope matching:") 50 + declared_scope = metadata['scope'] 51 + 52 + # Split scopes for comparison 53 + declared_scopes = set(declared_scope.split()) 54 + print(f" Declared scopes ({len(declared_scopes)}):") 55 + for s in declared_scopes: 56 + print(f" - {s}") 57 + 58 + # What we're trying to request 59 + requested_scope = "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app" 60 + requested_scopes = set(requested_scope.split()) 61 + print(f"\n Requested scopes ({len(requested_scopes)}):") 62 + for s in requested_scopes: 63 + print(f" - {s}") 64 + 65 + # Compare 66 + print("\n5. Comparison:") 67 + if declared_scopes == requested_scopes: 68 + print("✓ Scopes match exactly") 69 + else: 70 + print("✗ Scopes don't match!") 71 + missing = requested_scopes - declared_scopes 72 + if missing: 73 + print(f" Missing from metadata: {missing}") 74 + extra = declared_scopes - requested_scopes 75 + if extra: 76 + print(f" Extra in metadata: {extra}") 77 + 78 + # 6. Test if the issue is URL encoding 79 + print("\n6. URL Encoding check:") 80 + import urllib.parse 81 + 82 + encoded_scope = urllib.parse.quote(declared_scope) 83 + print(f" URL encoded scope: {encoded_scope[:100]}...") 84 + 85 + # Check if fragment is causing issues 86 + if "#" in declared_scope: 87 + print(" ⚠️ Scope contains # fragment which might need special handling") 88 + if "?" in declared_scope: 89 + print(" ⚠️ Scope contains ? query params which might need special handling") 90 + 91 + print("\n" + "=" * 60) 92 + print("HYPOTHESIS:") 93 + print("The 400 error means Bluesky can't validate our OAuth request.") 94 + print("Possible reasons:") 95 + print("1. Metadata isn't accessible to Bluesky") 96 + print("2. Scope format is incorrect") 97 + print("3. Client ID doesn't match redirect URI domain") 98 + print("4. The OAuth client needs to be registered differently") 99 + print("=" * 60) 100 + 101 + if __name__ == "__main__": 102 + test_metadata_validation()
+46
test_oauth.py
··· 1 + #!/usr/bin/env python3 2 + import requests 3 + import json 4 + 5 + # Test different scope combinations 6 + test_cases = [ 7 + { 8 + "name": "Both with fragment", 9 + "scope": "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app#bsky_appview" 10 + }, 11 + { 12 + "name": "getFollows without fragment", 13 + "scope": "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app" 14 + }, 15 + { 16 + "name": "Both without fragment", 17 + "scope": "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app" 18 + }, 19 + { 20 + "name": "getProfile without fragment", 21 + "scope": "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app#bsky_appview" 22 + } 23 + ] 24 + 25 + print("Testing OAuth scope combinations...") 26 + print("=" * 60) 27 + 28 + for test in test_cases: 29 + print(f"\nTest: {test['name']}") 30 + print(f"Scope: {test['scope']}") 31 + 32 + # Create mock client metadata 33 + metadata = { 34 + "client_id": "https://test.example.com/oauth-client-metadata.json", 35 + "client_name": "Test Status App", 36 + "client_uri": "https://test.example.com", 37 + "redirect_uris": ["https://test.example.com/oauth/callback"], 38 + "scope": test['scope'], 39 + "grant_types": ["authorization_code", "refresh_token"], 40 + "response_types": ["code"], 41 + "token_endpoint_auth_method": "none", 42 + "dpop_bound_access_tokens": True 43 + } 44 + 45 + print(f"Metadata valid: {json.dumps(metadata, indent=2)}") 46 + print("-" * 40)
+102
test_oauth_minimal.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Minimal test to check if OAuth metadata and scope declarations work 4 + """ 5 + import requests 6 + import json 7 + from urllib.parse import urlencode 8 + 9 + def test_oauth_metadata(): 10 + """Test if the OAuth metadata is correctly configured""" 11 + 12 + print("=" * 60) 13 + print("OAuth Metadata and Scope Test") 14 + print("=" * 60) 15 + 16 + # Test production 17 + print("\n1. Production site metadata:") 18 + prod_url = "https://status.zzstoatzz.io/oauth-client-metadata.json" 19 + prod_response = requests.get(prod_url) 20 + if prod_response.status_code == 200: 21 + prod_metadata = prod_response.json() 22 + print(f"✓ Got metadata") 23 + print(f" Scope: {prod_metadata['scope']}") 24 + 25 + # Check which scopes are present 26 + scope = prod_metadata['scope'] 27 + if "rpc:app.bsky.actor.getProfile" in scope: 28 + has_fragment = "#bsky_appview" in scope.split("rpc:app.bsky.actor.getProfile")[1].split()[0] 29 + print(f" - getProfile: present (fragment: {has_fragment})") 30 + 31 + if "rpc:app.bsky.graph.getFollows" in scope: 32 + has_fragment = "#bsky_appview" in scope.split("rpc:app.bsky.graph.getFollows")[1] if "rpc:app.bsky.graph.getFollows" in scope else False 33 + print(f" - getFollows: present (fragment: {has_fragment})") 34 + else: 35 + print(f"✗ Failed to get metadata: {prod_response.status_code}") 36 + 37 + # Test preview 38 + print("\n2. Preview site metadata:") 39 + preview_url = "https://zzstoatzz-status-pr-32.fly.dev/oauth-client-metadata.json" 40 + preview_response = requests.get(preview_url) 41 + if preview_response.status_code == 200: 42 + preview_metadata = preview_response.json() 43 + print(f"✓ Got metadata") 44 + print(f" Scope: {preview_metadata['scope']}") 45 + 46 + # Check which scopes are present 47 + scope = preview_metadata['scope'] 48 + if "rpc:app.bsky.actor.getProfile" in scope: 49 + profile_part = scope.split("rpc:app.bsky.actor.getProfile")[1].split()[0] if len(scope.split("rpc:app.bsky.actor.getProfile")) > 1 else "" 50 + has_fragment = "#bsky_appview" in profile_part 51 + print(f" - getProfile: present (fragment: {has_fragment})") 52 + 53 + if "rpc:app.bsky.graph.getFollows" in scope: 54 + follows_part = scope.split("rpc:app.bsky.graph.getFollows")[1] if len(scope.split("rpc:app.bsky.graph.getFollows")) > 1 else "" 55 + has_fragment = "#bsky_appview" in follows_part 56 + print(f" - getFollows: present (fragment: {has_fragment})") 57 + else: 58 + print(f"✗ Failed to get metadata: {preview_response.status_code}") 59 + 60 + # Compare 61 + print("\n3. Comparison:") 62 + if prod_response.status_code == 200 and preview_response.status_code == 200: 63 + if prod_metadata['scope'] == preview_metadata['scope']: 64 + print("✓ Scopes are identical") 65 + else: 66 + print("✗ Scopes differ:") 67 + print(f" Production: {prod_metadata['scope']}") 68 + print(f" Preview: {preview_metadata['scope']}") 69 + 70 + # Test what Bluesky expects 71 + print("\n4. What Bluesky error messages tell us:") 72 + print(" - getProfile needs: rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview") 73 + print(" - getFollows needs: rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app") 74 + print(" Note: getProfile has #bsky_appview fragment, getFollows does NOT") 75 + 76 + print("\n5. OAuth authorization URL test:") 77 + client_id = "https://zzstoatzz-status-pr-32.fly.dev/oauth-client-metadata.json" 78 + redirect_uri = "https://zzstoatzz-status-pr-32.fly.dev/oauth/callback" 79 + 80 + # Build the authorization URL with the scopes 81 + scope = "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app" 82 + 83 + params = { 84 + "response_type": "code", 85 + "client_id": client_id, 86 + "redirect_uri": redirect_uri, 87 + "scope": scope, 88 + "state": "test" 89 + } 90 + 91 + auth_url = f"https://bsky.social/oauth/authorize?{urlencode(params)}" 92 + print(f" Authorization URL (first 150 chars):") 93 + print(f" {auth_url[:150]}...") 94 + 95 + print("\n" + "=" * 60) 96 + print("IMPORTANT:") 97 + print("The OAuth flow might be caching tokens server-side.") 98 + print("Even with correct scopes, old tokens might persist.") 99 + print("=" * 60) 100 + 101 + if __name__ == "__main__": 102 + test_oauth_metadata()
+197
test_oauth_real.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Actually test the OAuth flow end-to-end 4 + """ 5 + import requests 6 + import json 7 + import sys 8 + from urllib.parse import urlparse, parse_qs 9 + 10 + def test_real_oauth(handle, app_password): 11 + """Test the actual OAuth flow""" 12 + 13 + print("=" * 60) 14 + print("TESTING REAL OAUTH FLOW") 15 + print("=" * 60) 16 + 17 + # Step 1: Create a session to simulate being logged into Bluesky 18 + print("\n1. Creating Bluesky session...") 19 + session = requests.Session() 20 + 21 + login_response = session.post( 22 + "https://bsky.social/xrpc/com.atproto.server.createSession", 23 + json={ 24 + "identifier": handle, 25 + "password": app_password 26 + } 27 + ) 28 + 29 + if login_response.status_code != 200: 30 + print(f"Failed to login: {login_response.text}") 31 + return 32 + 33 + login_data = login_response.json() 34 + did = login_data['did'] 35 + access_token = login_data['accessJwt'] 36 + print(f"✓ Logged in as {did}") 37 + 38 + # Step 2: Start OAuth authorization 39 + print("\n2. Starting OAuth flow...") 40 + 41 + client_id = "https://zzstoatzz-status-pr-32.fly.dev/oauth-client-metadata.json" 42 + redirect_uri = "https://zzstoatzz-status-pr-32.fly.dev/oauth/callback" 43 + 44 + # Test different scope combinations 45 + test_scopes = [ 46 + ("Current (getFollows no fragment)", "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app"), 47 + ("Both with fragment", "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app#bsky_appview"), 48 + ("Both without fragment", "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app"), 49 + ("Just getProfile (working in prod)", "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview"), 50 + ] 51 + 52 + for scope_name, scope in test_scopes: 53 + print(f"\nTesting: {scope_name}") 54 + print(f"Scope: {scope}") 55 + 56 + auth_params = { 57 + "response_type": "code", 58 + "client_id": client_id, 59 + "redirect_uri": redirect_uri, 60 + "scope": scope, 61 + "state": "test123" 62 + } 63 + 64 + # Make the authorization request 65 + auth_response = session.get( 66 + "https://bsky.social/oauth/authorize", 67 + params=auth_params, 68 + allow_redirects=False, 69 + headers={"Authorization": f"Bearer {access_token}"} 70 + ) 71 + 72 + print(f" Status: {auth_response.status_code}") 73 + 74 + if auth_response.status_code == 400: 75 + # Try to extract error from HTML 76 + import re 77 + error_match = re.search(r'<title>(.*?)</title>', auth_response.text) 78 + if error_match: 79 + print(f" Error: {error_match.group(1)}") 80 + continue 81 + elif auth_response.status_code != 302: 82 + print(f" Unexpected status") 83 + continue 84 + 85 + # If we got here, it worked 86 + print(f" ✓ SUCCESS! This scope configuration works!") 87 + 88 + # Continue with the working scope 89 + scope = scope # Use the last tested scope 90 + 91 + print(f"Authorization response status: {auth_response.status_code}") 92 + 93 + if auth_response.status_code == 302: 94 + # Check if we got redirected with a code 95 + location = auth_response.headers.get('Location') 96 + if location: 97 + parsed = urlparse(location) 98 + params = parse_qs(parsed.query) 99 + 100 + if 'code' in params: 101 + code = params['code'][0] 102 + print(f"✓ Got authorization code: {code[:20]}...") 103 + 104 + # Step 3: Exchange code for token 105 + print("\n3. Exchanging code for token...") 106 + 107 + token_response = requests.post( 108 + "https://bsky.social/oauth/token", 109 + json={ 110 + "grant_type": "authorization_code", 111 + "code": code, 112 + "redirect_uri": redirect_uri, 113 + "client_id": client_id 114 + } 115 + ) 116 + 117 + print(f"Token exchange status: {token_response.status_code}") 118 + if token_response.status_code == 200: 119 + token_data = token_response.json() 120 + oauth_token = token_data.get('access_token') 121 + print(f"✓ Got OAuth token") 122 + 123 + # Decode the token to see what scopes it has 124 + if oauth_token: 125 + import base64 126 + parts = oauth_token.split('.') 127 + if len(parts) >= 2: 128 + payload = parts[1] 129 + # Add padding if needed 130 + padding = 4 - len(payload) % 4 131 + if padding != 4: 132 + payload += '=' * padding 133 + try: 134 + decoded = base64.urlsafe_b64decode(payload) 135 + token_payload = json.loads(decoded) 136 + print("\nToken payload:") 137 + print(json.dumps(token_payload, indent=2)) 138 + 139 + if 'scope' in token_payload: 140 + print(f"\n✓ Scopes in token: {token_payload['scope']}") 141 + else: 142 + print("\n✗ No scope field in token!") 143 + except: 144 + print("Could not decode token payload") 145 + 146 + # Step 4: Test the token 147 + print("\n4. Testing OAuth token with APIs...") 148 + 149 + # Test getProfile 150 + profile_resp = requests.get( 151 + "https://bsky.social/xrpc/app.bsky.actor.getProfile", 152 + params={"actor": did}, 153 + headers={"Authorization": f"Bearer {oauth_token}"} 154 + ) 155 + print(f"getProfile: {profile_resp.status_code}") 156 + if profile_resp.status_code != 200: 157 + print(f" Error: {profile_resp.text[:200]}") 158 + 159 + # Test getFollows 160 + follows_resp = requests.get( 161 + "https://bsky.social/xrpc/app.bsky.graph.getFollows", 162 + params={"actor": did, "limit": 1}, 163 + headers={"Authorization": f"Bearer {oauth_token}"} 164 + ) 165 + print(f"getFollows: {follows_resp.status_code}") 166 + if follows_resp.status_code != 200: 167 + print(f" Error: {follows_resp.text[:200]}") 168 + 169 + else: 170 + print(f"✗ Token exchange failed: {token_response.text}") 171 + 172 + elif 'error' in params: 173 + print(f"✗ Got error: {params.get('error')} - {params.get('error_description')}") 174 + else: 175 + print(f"✗ Unexpected redirect: {location}") 176 + else: 177 + print("✗ No redirect location") 178 + else: 179 + print(f"Response headers: {dict(auth_response.headers)}") 180 + print(f"Response body: {auth_response.text[:500]}") 181 + 182 + print("\n" + "=" * 60) 183 + print("CONCLUSION:") 184 + print("This test shows what scopes the OAuth token actually gets") 185 + print("vs what we're requesting in the metadata.") 186 + print("=" * 60) 187 + 188 + if __name__ == "__main__": 189 + if len(sys.argv) != 3: 190 + print("Usage: python test_oauth_real.py <handle> <app_password>") 191 + print("Example: python test_oauth_real.py alice.bsky.social myapppassword") 192 + sys.exit(1) 193 + 194 + handle = sys.argv[1] 195 + app_password = sys.argv[2] 196 + 197 + test_real_oauth(handle, app_password)
+54
test_scopes.js
··· 1 + #!/usr/bin/env node 2 + 3 + // Test OAuth scope validation with Bluesky 4 + const testCases = [ 5 + { 6 + name: "Both with fragment", 7 + metadata_scope: "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app#bsky_appview", 8 + request_scope: "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app#bsky_appview" 9 + }, 10 + { 11 + name: "getFollows without fragment in both", 12 + metadata_scope: "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app", 13 + request_scope: "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app" 14 + }, 15 + { 16 + name: "getFollows without fragment in request only", 17 + metadata_scope: "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app#bsky_appview", 18 + request_scope: "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app" 19 + }, 20 + { 21 + name: "Both without fragment", 22 + metadata_scope: "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app", 23 + request_scope: "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app" 24 + } 25 + ]; 26 + 27 + console.log("OAuth Scope Test Results"); 28 + console.log("=" .repeat(60)); 29 + 30 + for (const test of testCases) { 31 + console.log(`\nTest: ${test.name}`); 32 + console.log(`Metadata scope: ${test.metadata_scope}`); 33 + console.log(`Request scope: ${test.request_scope}`); 34 + 35 + // Check if scopes match 36 + const matches = test.metadata_scope === test.request_scope; 37 + console.log(`Scopes match: ${matches ? "✓" : "✗"}`); 38 + 39 + // Check what error message would be 40 + if (test.request_scope.includes("getFollows?aud=did:web:api.bsky.app#bsky_appview")) { 41 + console.log("Expected error: Missing scope without fragment"); 42 + } else if (test.request_scope.includes("getFollows?aud=did:web:api.bsky.app")) { 43 + console.log("Expected: Should work if metadata declares it"); 44 + } 45 + 46 + console.log("-".repeat(40)); 47 + } 48 + 49 + console.log("\nCONCLUSION:"); 50 + console.log("The error message says it needs: rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app"); 51 + console.log("This is WITHOUT the #bsky_appview fragment"); 52 + console.log("\nWe should try:"); 53 + console.log("1. Metadata AND request both WITHOUT fragment for getFollows"); 54 + console.log("2. Or check if there's a different issue entirely");