PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.

feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.

Changed files
+192 -20
frontend
src
api
repo
record
+100 -6
frontend/src/lib/migration/atproto-client.ts
··· 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; ··· 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) {
+37 -9
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"; ··· 155 157 localStorage.setItem("migration_source_handle", state.sourceHandle); 156 158 localStorage.setItem("migration_oauth_issuer", metadata.issuer); 157 159 158 - const authUrl = buildOAuthAuthorizationUrl(metadata, { 159 - clientId: getMigrationOAuthClientId(), 160 - redirectUri: getMigrationOAuthRedirectUri(), 161 - codeChallenge, 162 - state: oauthState, 163 - scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 164 - dpopJkt: dpopKeyPair.thumbprint, 165 - loginHint: state.sourceHandle, 166 - }); 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 + } 167 195 168 196 migrationLog("initiateOAuthLogin: Redirecting to authorization", { 169 197 sourcePdsUrl: state.sourcePdsUrl,
+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[];
+53 -5
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 (