+100
-6
frontend/src/lib/migration/atproto-client.ts
+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
+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
+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
+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 (