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

fix: add version param to client_id to bypass Bluesky's metadata cache

+407 -2
+6 -2
src/main.rs
··· 88 /// OAuth client metadata endpoint for production 89 #[get("/oauth-client-metadata.json")] 90 async fn client_metadata(config: web::Data<config::Config>) -> Result<HttpResponse> { 91 let public_url = config.oauth_redirect_base.clone(); 92 93 let metadata = serde_json::json!({ 94 - "client_id": format!("{}/oauth-client-metadata.json", public_url), 95 "client_name": "Status Sphere", 96 "client_uri": public_url.clone(), 97 "redirect_uris": [format!("{}/oauth/callback", public_url)], ··· 1498 1499 let oauth_config = OAuthClientConfig { 1500 client_metadata: AtprotoClientMetadata { 1501 - client_id: format!("{}/oauth-client-metadata.json", config.oauth_redirect_base), 1502 client_uri: Some(config.oauth_redirect_base.clone()), 1503 redirect_uris: vec![format!("{}/oauth/callback", config.oauth_redirect_base)], 1504 token_endpoint_auth_method: AuthMethod::None,
··· 88 /// OAuth client metadata endpoint for production 89 #[get("/oauth-client-metadata.json")] 90 async fn client_metadata(config: web::Data<config::Config>) -> Result<HttpResponse> { 91 + // Note: This also handles /oauth-client-metadata.json?v=2 etc 92 let public_url = config.oauth_redirect_base.clone(); 93 94 let metadata = serde_json::json!({ 95 + "client_id": format!("{}/oauth-client-metadata.json?v=2", public_url), 96 "client_name": "Status Sphere", 97 "client_uri": public_url.clone(), 98 "redirect_uris": [format!("{}/oauth/callback", public_url)], ··· 1499 1500 let oauth_config = OAuthClientConfig { 1501 client_metadata: AtprotoClientMetadata { 1502 + client_id: format!( 1503 + "{}/oauth-client-metadata.json?v=2", 1504 + config.oauth_redirect_base 1505 + ), 1506 client_uri: Some(config.oauth_redirect_base.clone()), 1507 redirect_uris: vec![format!("{}/oauth/callback", config.oauth_redirect_base)], 1508 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()
+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)