A better Rust ATProto crate
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 26 kB View raw
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}