A better Rust ATProto crate
1use std::{collections::VecDeque, future::Future, sync::Arc};
2
3use axum::{
4 Json, Router,
5 extract::FromRef,
6 http::{self, Response as HttpResponse, StatusCode},
7 response::IntoResponse,
8 routing::{get, post},
9};
10use axum_extra::extract::cookie::Key;
11use axum_test::TestServer;
12use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
13use bytes::Bytes;
14use jacquard::{
15 BosStr,
16 common::{
17 deps::{fluent_uri::Uri, smol_str::SmolStr},
18 http_client::HttpClient,
19 session::SessionKey,
20 types::string::{Datetime, Did},
21 },
22 oauth::{
23 atproto::{AtprotoClientMetadata, atproto_client_metadata},
24 authstore::{ClientAuthStore, MemoryAuthStore},
25 client::OAuthClient,
26 resolver::OAuthResolver,
27 scopes::Scopes,
28 session::{ClientData, ClientSessionData, DpopClientData},
29 types::{OAuthAuthorizationServerMetadata, OAuthTokenType, TokenSet},
30 },
31};
32use jacquard_axum::oauth::{
33 BrowserOAuthSession, ExtractOAuthSession, OAuthWebConfig, OAuthWebState, encode_session_key,
34 routes, set_session_cookie,
35};
36
37#[derive(Clone, Default)]
38struct MockClient {
39 queue: Arc<tokio::sync::Mutex<VecDeque<http::Response<Vec<u8>>>>>,
40}
41
42impl MockClient {
43 async fn push(&self, resp: http::Response<Vec<u8>>) {
44 self.queue.lock().await.push_back(resp);
45 }
46}
47
48impl HttpClient for MockClient {
49 type Error = std::convert::Infallible;
50
51 fn send_http(
52 &self,
53 _request: http::Request<Vec<u8>>,
54 ) -> impl core::future::Future<Output = Result<http::Response<Vec<u8>>, Self::Error>> + Send
55 {
56 let queue = self.queue.clone();
57 async move { Ok(queue.lock().await.pop_front().expect("no queued response")) }
58 }
59}
60
61impl jacquard::identity::resolver::IdentityResolver for MockClient {
62 fn options(&self) -> &jacquard::identity::resolver::ResolverOptions {
63 use std::sync::LazyLock;
64 static OPTS: LazyLock<jacquard::identity::resolver::ResolverOptions> =
65 LazyLock::new(jacquard::identity::resolver::ResolverOptions::default);
66 &OPTS
67 }
68
69 async fn resolve_handle<S: BosStr + Sync>(
70 &self,
71 _handle: &jacquard::types::string::Handle<S>,
72 ) -> Result<Did, jacquard::identity::resolver::IdentityError> {
73 Ok(Did::new_static("did:plc:alice").unwrap())
74 }
75
76 async fn resolve_did_doc<S: BosStr + Sync>(
77 &self,
78 _did: &jacquard::types::did::Did<S>,
79 ) -> Result<
80 jacquard::identity::resolver::DidDocResponse,
81 jacquard::identity::resolver::IdentityError,
82 > {
83 let doc = alice_did_document_json();
84 Ok(jacquard::identity::resolver::DidDocResponse {
85 buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()),
86 status: StatusCode::OK,
87 requested: None,
88 })
89 }
90}
91
92impl OAuthResolver for MockClient {
93 async fn resolve_oauth(
94 &self,
95 _input: &str,
96 ) -> Result<
97 (
98 OAuthAuthorizationServerMetadata,
99 Option<jacquard::common::types::did_doc::DidDocument>,
100 ),
101 jacquard::oauth::resolver::ResolverError,
102 > {
103 let md = server_metadata("https://issuer");
104 let did_doc = serde_json::from_value(alice_did_document_json()).unwrap();
105 Ok((md, Some(did_doc)))
106 }
107
108 async fn get_authorization_server_metadata(
109 &self,
110 issuer: &str,
111 ) -> Result<OAuthAuthorizationServerMetadata, jacquard::oauth::resolver::ResolverError> {
112 Ok(server_metadata(issuer))
113 }
114
115 async fn get_resource_server_metadata(
116 &self,
117 _pds: &str,
118 ) -> Result<OAuthAuthorizationServerMetadata, jacquard::oauth::resolver::ResolverError> {
119 Ok(server_metadata("https://issuer"))
120 }
121}
122
123impl jacquard::oauth::dpop::DpopExt for MockClient {}
124
125fn alice_did_document_json() -> serde_json::Value {
126 serde_json::json!({
127 "@context": ["https://www.w3.org/ns/did/v1"],
128 "id": "did:plc:alice",
129 "alsoKnownAs": ["at://alice.bsky.social"],
130 "service": [{
131 "id": "#atproto_pds",
132 "type": "AtprotoPersonalDataServer",
133 "serviceEndpoint": "https://pds.example.com"
134 }]
135 })
136}
137
138impl jacquard::identity::lexicon_resolver::LexiconSchemaResolver for MockClient {
139 async fn resolve_lexicon_schema<S: BosStr + Sync>(
140 &self,
141 nsid: &jacquard::types::nsid::Nsid<S>,
142 ) -> Result<
143 jacquard::identity::lexicon_resolver::ResolvedLexiconSchema<'static>,
144 jacquard::identity::lexicon_resolver::LexiconResolutionError,
145 > {
146 use jacquard::IntoStatic;
147 Err(
148 jacquard::identity::lexicon_resolver::LexiconResolutionError::new(
149 jacquard::identity::lexicon_resolver::LexiconResolutionErrorKind::FetchFailed {
150 nsid: nsid.into_static().as_str().into(),
151 },
152 None,
153 ),
154 )
155 }
156}
157
158#[derive(Clone)]
159struct AppState {
160 oauth: Arc<OAuthClient<MockClient, MemoryAuthStore>>,
161 config: OAuthWebConfig,
162 key: Key,
163}
164
165impl OAuthWebState<MockClient, MemoryAuthStore> for AppState {
166 fn oauth_client(&self) -> &OAuthClient<MockClient, MemoryAuthStore> {
167 self.oauth.as_ref()
168 }
169}
170
171impl FromRef<AppState> for OAuthWebConfig {
172 fn from_ref(input: &AppState) -> Self {
173 input.config.clone()
174 }
175}
176
177impl FromRef<AppState> for Key {
178 fn from_ref(input: &AppState) -> Self {
179 input.key.clone()
180 }
181}
182
183fn server_metadata(issuer: &str) -> OAuthAuthorizationServerMetadata {
184 let mut md = OAuthAuthorizationServerMetadata::default();
185 md.issuer = SmolStr::from(issuer);
186 md.authorization_endpoint = SmolStr::from(format!("{issuer}/authorize"));
187 md.token_endpoint = SmolStr::from(format!("{issuer}/token"));
188 md.require_pushed_authorization_requests = Some(true);
189 md.pushed_authorization_request_endpoint = Some(SmolStr::from(format!("{issuer}/par")));
190 md.token_endpoint_auth_methods_supported = Some(vec![SmolStr::new_static("none")]);
191 md.dpop_signing_alg_values_supported = Some(vec![SmolStr::new_static("ES256")]);
192 md
193}
194
195fn client_data() -> ClientData<SmolStr> {
196 ClientData {
197 keyset: None,
198 config: AtprotoClientMetadata::new_localhost(
199 None,
200 Some(Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap()),
201 ),
202 }
203}
204
205fn app_state() -> (AppState, MockClient) {
206 let client = MockClient::default();
207 let oauth =
208 OAuthClient::new_from_resolver(MemoryAuthStore::new(), client.clone(), client_data());
209 (
210 AppState {
211 oauth: Arc::new(oauth),
212 config: OAuthWebConfig::default(),
213 key: Key::generate(),
214 },
215 client,
216 )
217}
218
219fn session_data(session_id: &str) -> ClientSessionData {
220 let did = Did::new_static("did:plc:alice").unwrap();
221 ClientSessionData {
222 account_did: did.clone(),
223 session_id: SmolStr::from(session_id),
224 host_url: Uri::parse("https://pds.example.com").unwrap().to_owned(),
225 authserver_url: SmolStr::new_static("https://issuer"),
226 authserver_token_endpoint: SmolStr::new_static("https://issuer/token"),
227 authserver_revocation_endpoint: None,
228 scopes: Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(),
229 dpop_data: DpopClientData {
230 dpop_key: jacquard::oauth::utils::generate_key(&[SmolStr::new_static("ES256")])
231 .unwrap(),
232 dpop_authserver_nonce: SmolStr::default(),
233 dpop_host_nonce: SmolStr::default(),
234 },
235 token_set: TokenSet {
236 iss: SmolStr::new_static("https://issuer"),
237 sub: did,
238 aud: SmolStr::new_static("https://pds.example.com"),
239 scope: Some(SmolStr::new_static("atproto rpc:*")),
240 refresh_token: Some(SmolStr::new_static("rt")),
241 access_token: SmolStr::new_static("atk"),
242 token_type: OAuthTokenType::DPoP,
243 expires_at: Some(Datetime::raw_str("2099-01-01T00:00:00.000000Z")),
244 },
245 resolved_scopes: None,
246 }
247}
248
249async fn strict_handler(
250 ExtractOAuthSession(session): ExtractOAuthSession<MockClient, MemoryAuthStore>,
251) -> impl IntoResponse {
252 let (did, session_id) = session.session_info().await;
253 Json(serde_json::json!({ "did": did, "session_id": session_id }))
254}
255
256async fn browser_handler(
257 BrowserOAuthSession(session): BrowserOAuthSession<MockClient, MemoryAuthStore>,
258) -> impl IntoResponse {
259 let (did, _) = session.session_info().await;
260 Json(serde_json::json!({ "did": did }))
261}
262
263#[tokio::test]
264async fn metadata_route_serves_client_metadata_from_state_oauth_client() {
265 let (state, _) = app_state();
266 let expected = atproto_client_metadata(
267 &state.oauth.registry.client_data.config,
268 &state.oauth.registry.client_data.keyset,
269 )
270 .unwrap();
271 let expected = serde_json::to_value(expected).unwrap();
272 let app = routes::<MockClient, MemoryAuthStore, AppState>(&OAuthWebConfig::default())
273 .with_state(state);
274 let server = TestServer::new(app).unwrap();
275
276 let response = server.get("/oauth-client-metadata.json").await;
277 response.assert_status_ok();
278 let body: serde_json::Value = serde_json::from_str(&response.text()).unwrap();
279 assert_eq!(body, expected);
280}
281
282#[tokio::test]
283async fn strict_extractor_loads_cookie_bound_session() {
284 let (state, _) = app_state();
285 let data = session_data("cookie-session");
286 let key = SessionKey::new(data.account_did.clone(), data.session_id.clone());
287 state
288 .oauth
289 .registry
290 .store
291 .upsert_session(data)
292 .await
293 .unwrap();
294 let app = Router::new()
295 .route("/protected", get(strict_handler))
296 .route(
297 "/issue",
298 get({
299 let config = state.config.clone();
300 let key = key.clone();
301 move |jar| {
302 let config = config.clone();
303 let key = key.clone();
304 async move { set_session_cookie(jar, &config, &key).unwrap() }
305 }
306 }),
307 )
308 .with_state(state);
309 let server = TestServer::builder().save_cookies().build(app).unwrap();
310
311 server.get("/issue").await.assert_status_ok();
312 let response = server.get("/protected").await;
313 response.assert_status_ok();
314 assert!(response.text().contains("did:plc:alice"));
315}
316
317#[tokio::test]
318async fn strict_extractor_loads_header_bound_session() {
319 let (state, _) = app_state();
320 let data = session_data("header-session");
321 let key = SessionKey::new(data.account_did.clone(), data.session_id.clone());
322 state
323 .oauth
324 .registry
325 .store
326 .upsert_session(data)
327 .await
328 .unwrap();
329 let encoded = encode_session_key(&key).unwrap();
330 let app = Router::new()
331 .route("/protected", get(strict_handler))
332 .with_state(state);
333 let server = TestServer::new(app).unwrap();
334
335 let response = server
336 .get("/protected")
337 .add_header("x-jacquard-session", encoded)
338 .await;
339 response.assert_status_ok();
340}
341
342#[tokio::test]
343async fn strict_extractor_rejects_missing_session() {
344 let (state, _) = app_state();
345 let app = Router::new()
346 .route("/protected", get(strict_handler))
347 .with_state(state);
348 let server = TestServer::new(app).unwrap();
349
350 server.get("/protected").await.assert_status_unauthorized();
351}
352
353#[tokio::test]
354async fn browser_extractor_redirects_missing_session_to_login_with_return_to() {
355 let (state, _) = app_state();
356 let app = Router::new()
357 .route("/protected", get(browser_handler))
358 .with_state(state);
359 let server = TestServer::new(app).unwrap();
360
361 let response = server.get("/protected?x=1").await;
362 response.assert_status(StatusCode::TEMPORARY_REDIRECT);
363 let location = response.header("location");
364 let location = location.to_str().unwrap();
365 assert!(location.contains("/oauth/login?"));
366 assert!(location.contains("return_to=%2Fprotected%3Fx%3D1"));
367}
368
369#[tokio::test]
370async fn browser_extractor_redirects_deleted_session_to_start_with_did() {
371 let (state, _) = app_state();
372 let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "deleted-session");
373 let app = Router::new()
374 .route("/protected", get(browser_handler))
375 .route(
376 "/issue",
377 get({
378 let config = state.config.clone();
379 let key = key.clone();
380 move |jar| {
381 let config = config.clone();
382 let key = key.clone();
383 async move { set_session_cookie(jar, &config, &key).unwrap() }
384 }
385 }),
386 )
387 .with_state(state);
388 let server = TestServer::builder().save_cookies().build(app).unwrap();
389
390 server.get("/issue").await.assert_status_ok();
391 let response = server.get("/protected").await;
392 response.assert_status(StatusCode::TEMPORARY_REDIRECT);
393 let location = response.header("location");
394 let location = location.to_str().unwrap();
395 assert!(location.contains("/oauth/start?"));
396 assert!(location.contains("identifier=did%3Aplc%3Aalice"));
397}
398
399#[tokio::test]
400async fn start_auth_query_redirects_to_authorization_endpoint() {
401 let (state, client) = app_state();
402 client
403 .push(
404 HttpResponse::builder()
405 .status(StatusCode::CREATED)
406 .header(http::header::CONTENT_TYPE, "application/json")
407 .body(
408 serde_json::to_vec(&serde_json::json!({
409 "request_uri": "urn:par:abc",
410 "expires_in": 60
411 }))
412 .unwrap(),
413 )
414 .unwrap(),
415 )
416 .await;
417 let app = routes::<MockClient, MemoryAuthStore, AppState>(&state.config).with_state(state);
418 let server = TestServer::new(app).unwrap();
419
420 let response = server
421 .get("/oauth/start?identifier=alice.bsky.social&return_to=/protected")
422 .await;
423 response.assert_status(StatusCode::TEMPORARY_REDIRECT);
424 let location = response.header("location");
425 let location = location.to_str().unwrap();
426 assert!(location.starts_with("https://issuer/authorize?"));
427 assert!(location.contains("request_uri=urn%3Apar%3Aabc"));
428}
429
430#[tokio::test]
431async fn callback_rejects_unknown_state() {
432 let (state, _) = app_state();
433 let app = routes::<MockClient, MemoryAuthStore, AppState>(&state.config).with_state(state);
434 let server = TestServer::new(app).unwrap();
435
436 server
437 .get("/oauth/callback?code=abc&state=missing&iss=https%3A%2F%2Fissuer")
438 .await
439 .assert_status_bad_request();
440}
441
442#[tokio::test]
443async fn callback_success_sets_session_cookie() {
444 let (state, client) = app_state();
445 client
446 .push(
447 HttpResponse::builder()
448 .status(StatusCode::CREATED)
449 .header(http::header::CONTENT_TYPE, "application/json")
450 .body(
451 serde_json::to_vec(&serde_json::json!({
452 "request_uri": "urn:par:abc",
453 "expires_in": 60
454 }))
455 .unwrap(),
456 )
457 .unwrap(),
458 )
459 .await;
460 client
461 .push(
462 HttpResponse::builder()
463 .status(StatusCode::OK)
464 .header(http::header::CONTENT_TYPE, "application/json")
465 .header("DPoP-Nonce", http::HeaderValue::from_static("n1"))
466 .body(
467 serde_json::to_vec(&serde_json::json!({
468 "access_token": "atk1",
469 "token_type": "DPoP",
470 "refresh_token": "rt1",
471 "sub": "did:plc:alice",
472 "iss": "https://issuer",
473 "aud": "https://pds.example.com",
474 "scope": "atproto rpc:*",
475 "expires_in": 3600
476 }))
477 .unwrap(),
478 )
479 .unwrap(),
480 )
481 .await;
482 // Explicit state flow for deterministic callback assertion.
483 client
484 .push(
485 HttpResponse::builder()
486 .status(StatusCode::CREATED)
487 .header(http::header::CONTENT_TYPE, "application/json")
488 .body(
489 serde_json::to_vec(&serde_json::json!({
490 "request_uri": "urn:par:def",
491 "expires_in": 60
492 }))
493 .unwrap(),
494 )
495 .unwrap(),
496 )
497 .await;
498 client
499 .push(
500 HttpResponse::builder()
501 .status(StatusCode::OK)
502 .header(http::header::CONTENT_TYPE, "application/json")
503 .header("DPoP-Nonce", http::HeaderValue::from_static("n2"))
504 .body(
505 serde_json::to_vec(&serde_json::json!({
506 "access_token": "atk2",
507 "token_type": "DPoP",
508 "refresh_token": "rt2",
509 "sub": "did:plc:alice",
510 "iss": "https://issuer",
511 "aud": "https://pds.example.com",
512 "scope": "atproto rpc:*",
513 "expires_in": 3600
514 }))
515 .unwrap(),
516 )
517 .unwrap(),
518 )
519 .await;
520 state
521 .oauth
522 .start_auth(
523 "alice.bsky.social",
524 jacquard::oauth::types::AuthorizeOptions::default()
525 .with_state(SmolStr::new_static("known-state")),
526 )
527 .await
528 .unwrap();
529 let app = routes::<MockClient, MemoryAuthStore, AppState>(&state.config)
530 .route("/protected", get(strict_handler))
531 .with_state(state);
532 let server = TestServer::builder().save_cookies().build(app).unwrap();
533 let response = server
534 .get("/oauth/callback?code=abc&state=known-state&iss=https%3A%2F%2Fissuer")
535 .await;
536 response.assert_status(StatusCode::SEE_OTHER);
537 let protected = server.get("/protected").await;
538 assert_eq!(
539 protected.status_code(),
540 StatusCode::OK,
541 "protected route body: {}",
542 protected.text()
543 );
544}
545
546fn queue_par_response<'a>(
547 client: &'a MockClient,
548 request_uri: &'static str,
549) -> impl Future<Output = ()> + 'a {
550 async move {
551 client
552 .push(
553 HttpResponse::builder()
554 .status(StatusCode::CREATED)
555 .header(http::header::CONTENT_TYPE, "application/json")
556 .body(
557 serde_json::to_vec(&serde_json::json!({
558 "request_uri": request_uri,
559 "expires_in": 60
560 }))
561 .unwrap(),
562 )
563 .unwrap(),
564 )
565 .await;
566 }
567}
568
569fn queue_token_response<'a>(
570 client: &'a MockClient,
571 access_token: &'static str,
572) -> impl Future<Output = ()> + 'a {
573 async move {
574 client
575 .push(
576 HttpResponse::builder()
577 .status(StatusCode::OK)
578 .header(http::header::CONTENT_TYPE, "application/json")
579 .header("DPoP-Nonce", http::HeaderValue::from_static("n-callback"))
580 .body(
581 serde_json::to_vec(&serde_json::json!({
582 "access_token": access_token,
583 "token_type": "DPoP",
584 "refresh_token": "rt-callback",
585 "sub": "did:plc:alice",
586 "iss": "https://issuer",
587 "aud": "https://pds.example.com",
588 "scope": "atproto rpc:*",
589 "expires_in": 3600
590 }))
591 .unwrap(),
592 )
593 .unwrap(),
594 )
595 .await;
596 }
597}
598
599fn state_from_return_cookie(response: &axum_test::TestResponse, prefix: &str) -> SmolStr {
600 response
601 .headers()
602 .get_all(http::header::SET_COOKIE)
603 .iter()
604 .filter_map(|value| value.to_str().ok())
605 .find_map(|cookie| {
606 let name = cookie.split_once('=')?.0;
607 let encoded = name.strip_prefix(prefix)?;
608 let bytes = URL_SAFE_NO_PAD.decode(encoded).ok()?;
609 String::from_utf8(bytes).ok().map(SmolStr::from)
610 })
611 .expect("state-keyed return cookie")
612}
613
614#[tokio::test]
615async fn start_auth_return_to_callback_redirects_back_and_cookie_states_do_not_conflict() {
616 let (state, client) = app_state();
617 let app =
618 routes::<MockClient, MemoryAuthStore, AppState>(&state.config).with_state(state.clone());
619 let server = TestServer::builder().save_cookies().build(app).unwrap();
620
621 queue_par_response(&client, "urn:par:first").await;
622 let first = server
623 .get("/oauth/start?identifier=alice.bsky.social&return_to=/first")
624 .await;
625 first.assert_status(StatusCode::TEMPORARY_REDIRECT);
626 let first_state = state_from_return_cookie(&first, state.config.return_cookie_prefix.as_str());
627
628 queue_par_response(&client, "urn:par:second").await;
629 let second = server
630 .get("/oauth/start?identifier=alice.bsky.social&return_to=/second")
631 .await;
632 second.assert_status(StatusCode::TEMPORARY_REDIRECT);
633 let second_state =
634 state_from_return_cookie(&second, state.config.return_cookie_prefix.as_str());
635 assert_ne!(first_state, second_state);
636
637 queue_token_response(&client, "atk-second").await;
638 let callback = server
639 .get(&format!(
640 "/oauth/callback?code=abc&state={}&iss=https%3A%2F%2Fissuer",
641 second_state
642 ))
643 .await;
644 callback.assert_status(StatusCode::SEE_OTHER);
645 assert_eq!(callback.header("location").to_str().unwrap(), "/second");
646}
647
648#[tokio::test]
649async fn logout_deletes_session_and_clears_cookie() {
650 let (state, _) = app_state();
651 let data = session_data("logout-session");
652 let key = SessionKey::new(data.account_did.clone(), data.session_id.clone());
653 state
654 .oauth
655 .registry
656 .store
657 .upsert_session(data)
658 .await
659 .unwrap();
660 let app = Router::new()
661 .route("/protected", get(strict_handler))
662 .route(
663 "/oauth/logout",
664 post(jacquard_axum::oauth::logout_handler::<MockClient, MemoryAuthStore, AppState>),
665 )
666 .route(
667 "/issue",
668 get({
669 let config = state.config.clone();
670 let key = key.clone();
671 move |jar| {
672 let config = config.clone();
673 let key = key.clone();
674 async move { set_session_cookie(jar, &config, &key).unwrap() }
675 }
676 }),
677 )
678 .with_state(state.clone());
679 let server = TestServer::builder().save_cookies().build(app).unwrap();
680
681 server.get("/issue").await.assert_status_ok();
682 server.get("/protected").await.assert_status_ok();
683 server
684 .post("/oauth/logout")
685 .await
686 .assert_status(StatusCode::SEE_OTHER);
687 assert!(
688 state
689 .oauth
690 .registry
691 .store
692 .get_session(&key.did, key.session_id.as_str())
693 .await
694 .unwrap()
695 .is_none()
696 );
697 server.get("/protected").await.assert_status_unauthorized();
698}
699
700#[tokio::test]
701async fn custom_config_paths_are_honored_by_routes() {
702 // Custom paths must differ from the defaults so the assertions are
703 // meaningful: the default paths should 404 while the custom ones work.
704 let mut config = OAuthWebConfig::default();
705 config.start_auth_path = SmolStr::new_static("/auth/begin");
706 config.callback_path = SmolStr::new_static("/auth/done");
707 config.logout_path = SmolStr::new_static("/auth/exit");
708
709 let (mut state, client) = app_state();
710 state.config = config.clone();
711
712 // The start route needs a PAR response before it can redirect.
713 queue_par_response(&client, "urn:par:custom").await;
714 let app = routes::<MockClient, MemoryAuthStore, AppState>(&state.config).with_state(state);
715 let server = TestServer::new(app).unwrap();
716
717 // The custom start path issues a redirect to the authorization endpoint.
718 let response = server.get("/auth/begin?identifier=alice.bsky.social").await;
719 response.assert_status(StatusCode::TEMPORARY_REDIRECT);
720 let location = response.header("location");
721 assert!(
722 location
723 .to_str()
724 .unwrap()
725 .starts_with("https://issuer/authorize?")
726 );
727
728 // The callback route exists at its custom path (unknown state still 400s,
729 // which proves the route is mounted and handled rather than 404).
730 server
731 .get("/auth/done?code=abc&state=missing&iss=https%3A%2F%2Fissuer")
732 .await
733 .assert_status_bad_request();
734
735 // The default start path is no longer mounted.
736 server
737 .get("/oauth/start?identifier=alice.bsky.social")
738 .await
739 .assert_status_not_found();
740}
741
742#[tokio::test]
743async fn default_config_metadata_route_remains_fixed() {
744 // The client-metadata route is intentionally not configurable.
745 let (state, _) = app_state();
746 let app = routes::<MockClient, MemoryAuthStore, AppState>(&state.config).with_state(state);
747 let server = TestServer::new(app).unwrap();
748
749 server
750 .get("/oauth-client-metadata.json")
751 .await
752 .assert_status_ok();
753}