Tranquil PDS! Moved to https://tangled.org/tranquil.farm/tranquil-pds

Fix replying to other users not in the same PDS, add PAR for oauth and fix minor bugs #10

closed opened by scanash.com targeting main from [deleted fork]: main
Labels

None yet.

assignee

None yet.

Participants 3
AT URI
at://did:plc:3i6uzuatdyk7rwfkrybynf5j/sh.tangled.repo.pull/3mbekljkj5d22
+310 -30
Diff #3
+1
Dockerfile
··· 17 17 cp target/release/tranquil-pds /tmp/tranquil-pds 18 18 19 19 FROM alpine:3.23 20 + RUN apk add --no-cache msmtp ca-certificates && ln -sf /usr/bin/msmtp /usr/sbin/sendmail 20 21 COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds 21 22 COPY --from=builder /app/migrations /app/migrations 22 23 COPY --from=frontend-builder /frontend/dist /app/frontend/dist
+103 -9
frontend/src/lib/migration/atproto-client.ts
··· 101 101 let requestBody: BodyInit | undefined; 102 102 if (rawBody) { 103 103 headers["Content-Type"] = contentType ?? "application/octet-stream"; 104 - requestBody = rawBody; 104 + requestBody = rawBody as BodyInit; 105 105 } else if (body) { 106 106 headers["Content-Type"] = "application/json"; 107 107 requestBody = JSON.stringify(body); ··· 546 546 return null; 547 547 } 548 548 549 - const authServerUrl = `${ 550 - authServers[0] 551 - }/.well-known/oauth-authorization-server`; 549 + const authServerUrl = `${authServers[0] 550 + }/.well-known/oauth-authorization-server`; 552 551 const authServerRes = await fetch(authServerUrl); 553 552 if (!authServerRes.ok) { 554 553 return null; ··· 616 615 ...cred, 617 616 id: base64UrlDecode(cred.id as string), 618 617 }), 619 - ), 620 - } as PublicKeyCredentialCreationOptions; 618 + ) as unknown, 619 + } as unknown as PublicKeyCredentialCreationOptions; 621 620 } 622 621 623 622 async function computeAccessTokenHash(accessToken: string): Promise<string> { ··· 662 661 return url.toString(); 663 662 } 664 663 664 + export function buildParAuthorizationUrl( 665 + metadata: OAuthServerMetadata, 666 + clientId: string, 667 + requestUri: string, 668 + ): string { 669 + const url = new URL(metadata.authorization_endpoint); 670 + url.searchParams.set("client_id", clientId); 671 + url.searchParams.set("request_uri", requestUri); 672 + return url.toString(); 673 + } 674 + 675 + export async function pushAuthorizationRequest( 676 + metadata: OAuthServerMetadata, 677 + params: { 678 + clientId: string; 679 + redirectUri: string; 680 + codeChallenge: string; 681 + state: string; 682 + scope?: string; 683 + dpopJkt?: string; 684 + loginHint?: string; 685 + }, 686 + dpopKeyPair: DPoPKeyPair, 687 + ): Promise<{ request_uri: string; expires_in: number }> { 688 + if (!metadata.pushed_authorization_request_endpoint) { 689 + throw new Error("Server does not support PAR"); 690 + } 691 + 692 + const body = new URLSearchParams({ 693 + response_type: "code", 694 + client_id: params.clientId, 695 + redirect_uri: params.redirectUri, 696 + code_challenge: params.codeChallenge, 697 + code_challenge_method: "S256", 698 + state: params.state, 699 + scope: params.scope ?? "atproto", 700 + }); 701 + 702 + if (params.dpopJkt) { 703 + body.set("dpop_jkt", params.dpopJkt); 704 + } 705 + if (params.loginHint) { 706 + body.set("login_hint", params.loginHint); 707 + } 708 + 709 + const makeRequest = async (nonce?: string): Promise<Response> => { 710 + const dpopProof = await createDPoPProof( 711 + dpopKeyPair, 712 + "POST", 713 + metadata.pushed_authorization_request_endpoint!, 714 + nonce, 715 + ); 716 + 717 + return fetch(metadata.pushed_authorization_request_endpoint!, { 718 + method: "POST", 719 + headers: { 720 + "Content-Type": "application/x-www-form-urlencoded", 721 + DPoP: dpopProof, 722 + }, 723 + body: body.toString(), 724 + }); 725 + }; 726 + 727 + let res = await makeRequest(); 728 + 729 + if (!res.ok) { 730 + const err = await res.json().catch(() => ({ 731 + error: "par_error", 732 + error_description: res.statusText, 733 + })); 734 + 735 + if (err.error === "use_dpop_nonce") { 736 + const dpopNonce = res.headers.get("DPoP-Nonce"); 737 + if (dpopNonce) { 738 + res = await makeRequest(dpopNonce); 739 + if (!res.ok) { 740 + const retryErr = await res.json().catch(() => ({ 741 + error: "par_error", 742 + error_description: res.statusText, 743 + })); 744 + throw new Error( 745 + retryErr.error_description || retryErr.error || "PAR request failed", 746 + ); 747 + } 748 + return res.json(); 749 + } 750 + } 751 + 752 + throw new Error( 753 + err.error_description || err.error || "PAR request failed", 754 + ); 755 + } 756 + 757 + return res.json(); 758 + } 759 + 665 760 export async function exchangeOAuthCode( 666 761 metadata: OAuthServerMetadata, 667 762 params: { ··· 721 816 })); 722 817 throw new Error( 723 818 retryErr.error_description || retryErr.error || 724 - "Token exchange failed", 819 + "Token exchange failed", 725 820 ); 726 821 } 727 822 return res.json(); ··· 773 868 774 869 if (handle.endsWith(".bsky.social")) { 775 870 const res = await fetch( 776 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ 777 - encodeURIComponent(handle) 871 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle) 778 872 }`, 779 873 ); 780 874 if (!res.ok) {
+70 -11
frontend/src/lib/migration/flow.svelte.ts
··· 9 9 import { 10 10 AtprotoClient, 11 11 buildOAuthAuthorizationUrl, 12 + buildParAuthorizationUrl, 12 13 clearDPoPKey, 13 14 createLocalClient, 14 15 exchangeOAuthCode, ··· 19 20 getMigrationOAuthRedirectUri, 20 21 getOAuthServerMetadata, 21 22 loadDPoPKey, 23 + pushAuthorizationRequest, 22 24 resolvePdsUrl, 23 25 saveDPoPKey, 24 26 } from "./atproto-client"; ··· 56 58 } 57 59 58 60 export function createInboundMigrationFlow() { 61 + // @ts-ignore 59 62 let state = $state<InboundMigrationState>({ 60 63 direction: "inbound", 61 64 step: "welcome", ··· 96 99 } 97 100 } 98 101 99 - function setError(error: string) { 102 + function setError(error: string | null) { 100 103 state.error = error; 101 104 saveMigrationState(state); 102 105 } ··· 156 159 localStorage.setItem("migration_source_handle", state.sourceHandle); 157 160 localStorage.setItem("migration_oauth_issuer", metadata.issuer); 158 161 159 - const authUrl = buildOAuthAuthorizationUrl(metadata, { 160 - clientId: getMigrationOAuthClientId(), 161 - redirectUri: getMigrationOAuthRedirectUri(), 162 - codeChallenge, 163 - state: oauthState, 164 - scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 165 - dpopJkt: dpopKeyPair.thumbprint, 166 - loginHint: state.sourceHandle, 167 - }); 162 + let authUrl: string; 163 + 164 + if (metadata.pushed_authorization_request_endpoint) { 165 + migrationLog("initiateOAuthLogin: Using PAR flow"); 166 + const parResponse = await pushAuthorizationRequest( 167 + metadata, 168 + { 169 + clientId: getMigrationOAuthClientId(), 170 + redirectUri: getMigrationOAuthRedirectUri(), 171 + codeChallenge, 172 + state: oauthState, 173 + scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 174 + dpopJkt: dpopKeyPair.thumbprint, 175 + loginHint: state.sourceHandle, 176 + }, 177 + dpopKeyPair 178 + ); 179 + 180 + authUrl = buildParAuthorizationUrl( 181 + metadata, 182 + getMigrationOAuthClientId(), 183 + parResponse.request_uri 184 + ); 185 + } else { 186 + migrationLog("initiateOAuthLogin: Using standard OAuth flow"); 187 + authUrl = buildOAuthAuthorizationUrl(metadata, { 188 + clientId: getMigrationOAuthClientId(), 189 + redirectUri: getMigrationOAuthRedirectUri(), 190 + codeChallenge, 191 + state: oauthState, 192 + scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 193 + dpopJkt: dpopKeyPair.thumbprint, 194 + loginHint: state.sourceHandle, 195 + }); 196 + } 168 197 169 198 migrationLog("initiateOAuthLogin: Redirecting to authorization", { 170 199 sourcePdsUrl: state.sourcePdsUrl, ··· 461 490 async function migrateBlobs(): Promise<void> { 462 491 if (!sourceClient || !localClient) return; 463 492 493 + let cursor: string | undefined; 494 + let migrated = 0; 495 + 496 + do { 497 + const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 498 + cursor, 499 + 100, 500 + ); 501 + 502 + for (const blob of blobs) { 503 + try { 504 + setProgress({ 505 + currentOperation: `Migrating blob ${migrated + 1 506 + }/${state.progress.blobsTotal}...`, 507 + }); 508 + 509 + const blobData = await sourceClient.getBlob( 510 + state.sourceDid, 511 + blob.cid, 512 + ); 513 + await localClient.uploadBlob(blobData, "application/octet-stream"); 514 + migrated++; 515 + setProgress({ blobsMigrated: migrated }); 516 + } catch { 517 + state.progress.blobsFailed.push(blob.cid); 518 + } 519 + } 520 + 521 + cursor = nextCursor; 522 + } while (cursor); 464 523 const result = await migrateBlobsUtil( 465 524 localClient, 466 525 sourceClient, ··· 493 552 setError(null); 494 553 495 554 try { 496 - await localClient.verifyToken(token, state.targetEmail); 555 + await localClient.verifyToken(token, state.targetEmail || ""); 497 556 498 557 if (!sourceClient) { 499 558 setStep("source-handle");
+2
frontend/src/lib/migration/types.ts
··· 254 254 issuer: string; 255 255 authorization_endpoint: string; 256 256 token_endpoint: string; 257 + pushed_authorization_request_endpoint?: string; 258 + require_pushed_authorization_requests?: boolean; 257 259 scopes_supported?: string[]; 258 260 response_types_supported?: string[]; 259 261 grant_types_supported?: string[];
+134 -10
src/api/repo/record/read.rs
··· 133 133 .into_response(); 134 134 } 135 135 } 136 - return ( 137 - StatusCode::NOT_FOUND, 138 - Json(json!({"error": "RepoNotFound", "message": "Repo not found"})), 139 - ) 140 - .into_response(); 136 + let appview_endpoint = std::env::var("BSKY_APPVIEW_ENDPOINT") 137 + .unwrap_or_else(|_| "https://api.bsky.app".to_string()); 138 + let mut url = format!( 139 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 140 + appview_endpoint.trim_end_matches('/'), 141 + urlencoding::encode(&input.repo), 142 + urlencoding::encode(&input.collection), 143 + urlencoding::encode(&input.rkey) 144 + ); 145 + if let Some(cid) = &input.cid { 146 + url.push_str(&format!("&cid={}", urlencoding::encode(cid))); 147 + } 148 + info!( 149 + "Repo not found locally. Proxying getRecord for {} to AppView: {}", 150 + input.repo, url 151 + ); 152 + match proxy_client().get(&url).send().await { 153 + Ok(resp) => { 154 + let status = resp.status(); 155 + let body = match resp.bytes().await { 156 + Ok(b) => b, 157 + Err(e) => { 158 + error!("Error reading AppView proxy response: {:?}", e); 159 + return ( 160 + StatusCode::BAD_GATEWAY, 161 + Json(json!({ 162 + "error": "UpstreamFailure", 163 + "message": "Error reading upstream response from AppView" 164 + })), 165 + ) 166 + .into_response(); 167 + } 168 + }; 169 + return Response::builder() 170 + .status(status) 171 + .header("content-type", "application/json") 172 + .body(axum::body::Body::from(body)) 173 + .unwrap_or_else(|_| { 174 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() 175 + }); 176 + } 177 + Err(e) => { 178 + error!("Error proxying request to AppView: {:?}", e); 179 + return ( 180 + StatusCode::BAD_GATEWAY, 181 + Json(json!({ 182 + "error": "UpstreamFailure", 183 + "message": "Failed to reach AppView" 184 + })), 185 + ) 186 + .into_response(); 187 + } 188 + } 141 189 } 142 190 Err(_) => { 143 191 return ( ··· 158 206 let record_cid_str: String = match record_row { 159 207 Ok(Some(row)) => row.record_cid, 160 208 _ => { 161 - return ( 162 - StatusCode::NOT_FOUND, 163 - Json(json!({"error": "RecordNotFound", "message": "Record not found"})), 164 - ) 165 - .into_response(); 209 + let did = if input.repo.starts_with("did:") { 210 + input.repo.clone() 211 + } else { 212 + info!("Resolving handle {} to DID for P2P proxy", input.repo); 213 + match crate::handle::resolve_handle(&input.repo).await { 214 + Ok(d) => d, 215 + Err(e) => { 216 + error!("Failed to resolve handle {}: {}", input.repo, e); 217 + return ( 218 + StatusCode::NOT_FOUND, 219 + Json(json!({"error": "RepoNotFound", "message": "Could not resolve handle"})), 220 + ) 221 + .into_response(); 222 + } 223 + } 224 + }; 225 + 226 + let resolved_service = match state.did_resolver.resolve_did(&did).await { 227 + Some(s) => s, 228 + None => { 229 + error!("Failed to resolve PDS for DID {}", did); 230 + return ( 231 + StatusCode::NOT_FOUND, 232 + Json(json!({"error": "RepoNotFound", "message": "Could not resolve DID service endpoint"})), 233 + ) 234 + .into_response(); 235 + } 236 + }; 237 + 238 + let mut url = format!( 239 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 240 + resolved_service.url.trim_end_matches('/'), 241 + urlencoding::encode(&input.repo), 242 + urlencoding::encode(&input.collection), 243 + urlencoding::encode(&input.rkey) 244 + ); 245 + if let Some(cid) = &input.cid { 246 + url.push_str(&format!("&cid={}", urlencoding::encode(cid))); 247 + } 248 + info!( 249 + "Record not found locally. Proxying getRecord for {} (DID: {}) to PDS: {}", 250 + input.repo, did, url 251 + ); 252 + 253 + match proxy_client().get(&url).send().await { 254 + Ok(resp) => { 255 + let status = resp.status(); 256 + let body = match resp.bytes().await { 257 + Ok(b) => b, 258 + Err(e) => { 259 + error!("Error reading upstream PDS response: {:?}", e); 260 + return ( 261 + StatusCode::BAD_GATEWAY, 262 + Json(json!({ 263 + "error": "UpstreamFailure", 264 + "message": "Error reading upstream response" 265 + })), 266 + ) 267 + .into_response(); 268 + } 269 + }; 270 + return Response::builder() 271 + .status(status) 272 + .header("content-type", "application/json") 273 + .body(axum::body::Body::from(body)) 274 + .unwrap_or_else(|_| { 275 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() 276 + }); 277 + } 278 + Err(e) => { 279 + error!("Error proxying request to upstream PDS: {:?}", e); 280 + return ( 281 + StatusCode::BAD_GATEWAY, 282 + Json(json!({ 283 + "error": "UpstreamFailure", 284 + "message": "Failed to reach upstream PDS" 285 + })), 286 + ) 287 + .into_response(); 288 + } 289 + } 166 290 } 167 291 }; 168 292 if let Some(expected_cid) = &input.cid

History

4 rounds 12 comments
sign up or login to add to the discussion
5 commits
expand
40568e20
try to fix
149ed8e3
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
ced673cf
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
19e88430
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
1694091e
Backups, adversarial migrations
expand 0 comments
closed without merging
6 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
2f966608
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
085621dc
Backups, adversarial migrations
expand 1 comment
5 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
2f966608
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
expand 5 comments

there, i fixed it

Would you mind rebasing or something? :p i can't see nothin in there (I know it's my fault for pushing massive changes while your PR is open but whatchagonnadoabouttit

i cant resubmit

it wont let me..

yeah it sucks

4 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
expand 6 comments

tangled is so funny why did it include the commit already merged here

i dont know how to fix that merge conflict tangled is the best git platform oat

what is this mystical BSKY_APPVIEW_ENDPOINT about :p what if im running tranquil and I wanna support every bsky-esque appview equally?? zeppelin may return in spirit

it's so i can add a backdoor to the ccp

i would reset to the commit before the port/host hardoded thing fix i did and then merge this one as it includes it

like lewis mentioned i dont quite get why the dedicated proxying to a configured bsky appview is needed here? it makes tranquil less generic, less spec compliant and i dont see what functionality it offers over the spec compliant proxying thats already there? (and that modern social-app has luckily been updated to use as it should)