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
+258 -36
Diff #0
+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); ··· 572 572 return null; 573 573 } 574 574 575 - const authServerUrl = `${ 576 - authServers[0] 577 - }/.well-known/oauth-authorization-server`; 575 + const authServerUrl = `${authServers[0] 576 + }/.well-known/oauth-authorization-server`; 578 577 const authServerRes = await fetch(authServerUrl); 579 578 if (!authServerRes.ok) { 580 579 return null; ··· 642 641 ...cred, 643 642 id: base64UrlDecode(cred.id as string), 644 643 }), 645 - ), 646 - } as PublicKeyCredentialCreationOptions; 644 + ) as unknown, 645 + } as unknown as PublicKeyCredentialCreationOptions; 647 646 } 648 647 649 648 async function computeAccessTokenHash(accessToken: string): Promise<string> { ··· 688 687 return url.toString(); 689 688 } 690 689 690 + export function buildParAuthorizationUrl( 691 + metadata: OAuthServerMetadata, 692 + clientId: string, 693 + requestUri: string, 694 + ): string { 695 + const url = new URL(metadata.authorization_endpoint); 696 + url.searchParams.set("client_id", clientId); 697 + url.searchParams.set("request_uri", requestUri); 698 + return url.toString(); 699 + } 700 + 701 + export async function pushAuthorizationRequest( 702 + metadata: OAuthServerMetadata, 703 + params: { 704 + clientId: string; 705 + redirectUri: string; 706 + codeChallenge: string; 707 + state: string; 708 + scope?: string; 709 + dpopJkt?: string; 710 + loginHint?: string; 711 + }, 712 + dpopKeyPair: DPoPKeyPair, 713 + ): Promise<{ request_uri: string; expires_in: number }> { 714 + if (!metadata.pushed_authorization_request_endpoint) { 715 + throw new Error("Server does not support PAR"); 716 + } 717 + 718 + const body = new URLSearchParams({ 719 + response_type: "code", 720 + client_id: params.clientId, 721 + redirect_uri: params.redirectUri, 722 + code_challenge: params.codeChallenge, 723 + code_challenge_method: "S256", 724 + state: params.state, 725 + scope: params.scope ?? "atproto", 726 + }); 727 + 728 + if (params.dpopJkt) { 729 + body.set("dpop_jkt", params.dpopJkt); 730 + } 731 + if (params.loginHint) { 732 + body.set("login_hint", params.loginHint); 733 + } 734 + 735 + const makeRequest = async (nonce?: string): Promise<Response> => { 736 + const dpopProof = await createDPoPProof( 737 + dpopKeyPair, 738 + "POST", 739 + metadata.pushed_authorization_request_endpoint!, 740 + nonce, 741 + ); 742 + 743 + return fetch(metadata.pushed_authorization_request_endpoint!, { 744 + method: "POST", 745 + headers: { 746 + "Content-Type": "application/x-www-form-urlencoded", 747 + DPoP: dpopProof, 748 + }, 749 + body: body.toString(), 750 + }); 751 + }; 752 + 753 + let res = await makeRequest(); 754 + 755 + if (!res.ok) { 756 + const err = await res.json().catch(() => ({ 757 + error: "par_error", 758 + error_description: res.statusText, 759 + })); 760 + 761 + if (err.error === "use_dpop_nonce") { 762 + const dpopNonce = res.headers.get("DPoP-Nonce"); 763 + if (dpopNonce) { 764 + res = await makeRequest(dpopNonce); 765 + if (!res.ok) { 766 + const retryErr = await res.json().catch(() => ({ 767 + error: "par_error", 768 + error_description: res.statusText, 769 + })); 770 + throw new Error( 771 + retryErr.error_description || retryErr.error || "PAR request failed", 772 + ); 773 + } 774 + return res.json(); 775 + } 776 + } 777 + 778 + throw new Error( 779 + err.error_description || err.error || "PAR request failed", 780 + ); 781 + } 782 + 783 + return res.json(); 784 + } 785 + 691 786 export async function exchangeOAuthCode( 692 787 metadata: OAuthServerMetadata, 693 788 params: { ··· 747 842 })); 748 843 throw new Error( 749 844 retryErr.error_description || retryErr.error || 750 - "Token exchange failed", 845 + "Token exchange failed", 751 846 ); 752 847 } 753 848 return res.json(); ··· 799 894 800 895 if (handle.endsWith(".bsky.social")) { 801 896 const res = await fetch( 802 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ 803 - encodeURIComponent(handle) 897 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle) 804 898 }`, 805 899 ); 806 900 if (!res.ok) {
+46 -17
frontend/src/lib/migration/flow.svelte.ts
··· 11 11 import { 12 12 AtprotoClient, 13 13 buildOAuthAuthorizationUrl, 14 + buildParAuthorizationUrl, 14 15 clearDPoPKey, 15 16 createLocalClient, 16 17 exchangeOAuthCode, ··· 21 22 getMigrationOAuthRedirectUri, 22 23 getOAuthServerMetadata, 23 24 loadDPoPKey, 25 + pushAuthorizationRequest, 24 26 resolvePdsUrl, 25 27 saveDPoPKey, 26 28 } from "./atproto-client"; ··· 57 59 } 58 60 59 61 export function createInboundMigrationFlow() { 62 + // @ts-ignore 60 63 let state = $state<InboundMigrationState>({ 61 64 direction: "inbound", 62 65 step: "welcome", ··· 85 88 let sourceClient: AtprotoClient | null = null; 86 89 let localClient: AtprotoClient | null = null; 87 90 let localServerInfo: ServerDescription | null = null; 91 + let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = null; 88 92 89 93 function setStep(step: InboundStep) { 90 94 state.step = step; ··· 93 97 updateStep(step); 94 98 } 95 99 96 - function setError(error: string) { 100 + function setError(error: string | null) { 97 101 state.error = error; 98 102 saveMigrationState(state); 99 103 } ··· 153 157 localStorage.setItem("migration_source_handle", state.sourceHandle); 154 158 localStorage.setItem("migration_oauth_issuer", metadata.issuer); 155 159 156 - const authUrl = buildOAuthAuthorizationUrl(metadata, { 157 - clientId: getMigrationOAuthClientId(), 158 - redirectUri: getMigrationOAuthRedirectUri(), 159 - codeChallenge, 160 - state: oauthState, 161 - scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 162 - dpopJkt: dpopKeyPair.thumbprint, 163 - loginHint: state.sourceHandle, 164 - }); 160 + let authUrl: string; 161 + 162 + if (metadata.pushed_authorization_request_endpoint) { 163 + migrationLog("initiateOAuthLogin: Using PAR flow"); 164 + const parResponse = await pushAuthorizationRequest( 165 + metadata, 166 + { 167 + clientId: getMigrationOAuthClientId(), 168 + redirectUri: getMigrationOAuthRedirectUri(), 169 + codeChallenge, 170 + state: oauthState, 171 + scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 172 + dpopJkt: dpopKeyPair.thumbprint, 173 + loginHint: state.sourceHandle, 174 + }, 175 + dpopKeyPair 176 + ); 177 + 178 + authUrl = buildParAuthorizationUrl( 179 + metadata, 180 + getMigrationOAuthClientId(), 181 + parResponse.request_uri 182 + ); 183 + } else { 184 + migrationLog("initiateOAuthLogin: Using standard OAuth flow"); 185 + authUrl = buildOAuthAuthorizationUrl(metadata, { 186 + clientId: getMigrationOAuthClientId(), 187 + redirectUri: getMigrationOAuthRedirectUri(), 188 + codeChallenge, 189 + state: oauthState, 190 + scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 191 + dpopJkt: dpopKeyPair.thumbprint, 192 + loginHint: state.sourceHandle, 193 + }); 194 + } 165 195 166 196 migrationLog("initiateOAuthLogin: Redirecting to authorization", { 167 197 sourcePdsUrl: state.sourcePdsUrl, ··· 470 500 for (const blob of blobs) { 471 501 try { 472 502 setProgress({ 473 - currentOperation: `Migrating blob ${ 474 - migrated + 1 475 - }/${state.progress.blobsTotal}...`, 503 + currentOperation: `Migrating blob ${migrated + 1 504 + }/${state.progress.blobsTotal}...`, 476 505 }); 477 506 478 507 const blobData = await sourceClient.getBlob( ··· 513 542 setError(null); 514 543 515 544 try { 516 - await localClient.verifyToken(token, state.targetEmail); 545 + await localClient.verifyToken(token, state.targetEmail || ""); 517 546 518 547 if (!sourceClient) { 519 548 setStep("source-handle"); ··· 979 1008 } 980 1009 981 1010 export function createOutboundMigrationFlow() { 1011 + // @ts-ignore 982 1012 let state = $state<OutboundMigrationState>({ 983 1013 direction: "outbound", 984 1014 step: "welcome", ··· 1132 1162 for (const blob of blobs) { 1133 1163 try { 1134 1164 setProgress({ 1135 - currentOperation: `Migrating blob ${ 1136 - migrated + 1 1137 - }/${state.progress.blobsTotal}...`, 1165 + currentOperation: `Migrating blob ${migrated + 1 1166 + }/${state.progress.blobsTotal}...`, 1138 1167 }); 1139 1168 1140 1169 const blobData = await localClient.getBlob(did, blob.cid);
+2
frontend/src/lib/migration/types.ts
··· 240 240 issuer: string; 241 241 authorization_endpoint: string; 242 242 token_endpoint: string; 243 + pushed_authorization_request_endpoint?: string; 244 + require_pushed_authorization_requests?: boolean; 243 245 scopes_supported?: string[]; 244 246 response_types_supported?: string[]; 245 247 grant_types_supported?: string[];
+106 -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 appview_endpoint = std::env::var("BSKY_APPVIEW_ENDPOINT") 210 + .unwrap_or_else(|_| "https://api.bsky.app".to_string()); 211 + let mut url = format!( 212 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 213 + appview_endpoint.trim_end_matches('/'), 214 + urlencoding::encode(&input.repo), 215 + urlencoding::encode(&input.collection), 216 + urlencoding::encode(&input.rkey) 217 + ); 218 + if let Some(cid) = &input.cid { 219 + url.push_str(&format!("&cid={}", urlencoding::encode(cid))); 220 + } 221 + info!( 222 + "Record not found locally (user exists). Proxying getRecord for {} to AppView: {}", 223 + input.repo, url 224 + ); 225 + match proxy_client().get(&url).send().await { 226 + Ok(resp) => { 227 + let status = resp.status(); 228 + let body = match resp.bytes().await { 229 + Ok(b) => b, 230 + Err(e) => { 231 + error!("Error reading AppView proxy response: {:?}", e); 232 + return ( 233 + StatusCode::BAD_GATEWAY, 234 + Json(json!({ 235 + "error": "UpstreamFailure", 236 + "message": "Error reading upstream response from AppView" 237 + })), 238 + ) 239 + .into_response(); 240 + } 241 + }; 242 + return Response::builder() 243 + .status(status) 244 + .header("content-type", "application/json") 245 + .body(axum::body::Body::from(body)) 246 + .unwrap_or_else(|_| { 247 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() 248 + }); 249 + } 250 + Err(e) => { 251 + error!("Error proxying request to AppView: {:?}", e); 252 + return ( 253 + StatusCode::BAD_GATEWAY, 254 + Json(json!({ 255 + "error": "UpstreamFailure", 256 + "message": "Failed to reach AppView" 257 + })), 258 + ) 259 + .into_response(); 260 + } 261 + } 166 262 } 167 263 }; 168 264 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

scanash.com submitted #0
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)