use super::*; use axum::{body::Body, http::Request}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use k256::ecdsa::{Signature, signature::Signer}; use tower::ServiceExt; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{body_json, header, method, path, query_param}, }; const ACTOR: &str = "did:plc:abcdefghijklmnopqrstuvwx"; const GATE: &str = "did:web:gate.example"; const CREATE: &str = "farm.tranquil.delegation.createAccount"; const LIST: &str = "farm.tranquil.delegation.listControlledAccounts"; const RESOLVE: &str = "sh.tangled.identity.resolveCommitters"; async fn setup() -> (App, MockServer) { setup_with_peers(&[]).await } async fn setup_with_peers(peers: &[&MockServer]) -> (App, MockServer) { let server = MockServer::start().await; let http = reqwest::Client::new(); let directory = Arc::new( JacquardResolver::new(ReqwestHttp::new(http.clone()), Default::default()).with_plc_source( PlcSource::PlcDirectory { base: Uri::parse(format!("{}/", server.uri()).as_str()) .unwrap() .to_owned(), }, ), ); let signing_key = SigningKey::from_slice(&[1; 32]).unwrap(); let gate = gate::Gate::builder( http.clone(), GATE, signing_key.clone(), server.uri(), "did:web:deliberi.example", ) .build(); let app = App { config: Arc::new(RuntimeConfig { did: GATE.into(), tranquil_url: server.uri(), signing_key, handle_domain: "example".into(), handle_hosts: std::iter::once(server.uri()) .chain(peers.iter().map(|peer| peer.uri())) .collect(), reserved_handles: vec!["gate.example".into()], }), http, auth: ServiceAuthConfig::new(Did::new_owned(GATE).unwrap(), directory), gate: Arc::new(gate), }; let mut key = vec![0xe7, 1]; key.extend_from_slice( app.config .signing_key .verifying_key() .to_encoded_point(true) .as_bytes(), ); Mock::given(method("GET")).and(path(format!("/{ACTOR}"))).respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": ACTOR, "verificationMethod": [{"id": format!("{ACTOR}#atproto"), "type":"Multikey", "controller": ACTOR, "publicKeyMultibase": format!("z{}", bs58::encode(key).into_string())}] }))).mount(&server).await; (app, server) } fn token(app: &App, aud: &str, lxm: &str, exp: u64, key: Option<&SigningKey>) -> String { let payload = json!({"iss": ACTOR, "aud": aud, "lxm": lxm, "iat": now(), "exp": exp, "jti": format!("test-{lxm}")}); let input = format!( "{}.{}", URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#), URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()) ); let signature: Signature = key .unwrap_or(&app.config.signing_key) .sign(input.as_bytes()); format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())) } async fn request(app: App, token: Option<&str>) -> Response { let mut request = Request::post("/xrpc/sh.tangled.delegation.createAccount") .header("content-type", "application/json"); if let Some(token) = token { request = request.header("authorization", format!("Bearer {token}")); } router(app) .oneshot( request .body(Body::from(json!({"handle":"org.example"}).to_string())) .unwrap(), ) .await .unwrap() } async fn describe_repo(server: &MockServer, status: u16, body: Value) { Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.describeRepo")) .and(query_param("repo", "org.example")) .respond_with(ResponseTemplate::new(status).set_body_json(body)) .mount(server) .await; } async fn handle_free(server: &MockServer) { describe_repo(server, 400, json!({"error": "RepoNotFound"})).await; } async fn email(server: &MockServer, values: Vec<&str>) { Mock::given(method("POST")) .and(path(format!("/xrpc/{RESOLVE}"))) .and(body_json(json!({"actor": ACTOR}))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"committers": values}))) .mount(server) .await; } #[tokio::test] async fn rejects_missing_wrong_audience_expired_wrong_method_and_bad_signature() { let (app, server) = setup().await; let bad_key = SigningKey::from_slice(&[2; 32]).unwrap(); let tokens = [ None, Some(token( &app, "did:web:other.example", CREATE, now() + 60, None, )), Some(token(&app, GATE, CREATE, now() - 1, None)), Some(token(&app, GATE, RESOLVE, now() + 60, None)), Some(token(&app, GATE, CREATE, now() + 60, Some(&bad_key))), ]; for token in tokens { assert!( request(app.clone(), token.as_deref()) .await .status() .is_client_error() ); } assert!( server .received_requests() .await .unwrap() .iter() .all(|r| r.method == "GET") ); } #[tokio::test] async fn requires_an_email_not_the_did_fallback_and_rejects_replay() { let (app, server) = setup().await; email(&server, vec![ACTOR]).await; let jwt = token(&app, GATE, CREATE, now() + 60, None); let response = request(app.clone(), Some(&jwt)).await; assert_eq!(response.status(), StatusCode::FORBIDDEN); let body = axum::body::to_bytes(response.into_body(), 4096) .await .unwrap(); assert!( std::str::from_utf8(&body) .unwrap() .contains("VerifiedEmailRequired") ); assert!(request(app, Some(&jwt)).await.status().is_client_error()); assert_eq!( server .received_requests() .await .unwrap() .iter() .filter(|r| r.method == "POST") .count(), 1 ); } #[tokio::test] async fn upstream_client_errors_pass_through_and_server_errors_fail_closed() { let (app, server) = setup().await; let input = || CreateInput { handle: "org.example".into(), }; // No email mock yet, so the resolver 404s and the gate relays that status. let error = app .create(ACTOR, "create-token", input()) .await .err() .unwrap(); assert_eq!(error.status, StatusCode::NOT_FOUND); assert_eq!(error.error, "UpstreamRejected"); email(&server, vec!["person@example.com"]).await; handle_free(&server).await; // Tranquil rejecting the creation is relayed with Tranquil's own status, // error code, and message, so a taken handle is distinguishable from the // per-controller cap. Mock::given(path("/xrpc/_delegation.createDelegatedAccount")) .respond_with(ResponseTemplate::new(400).set_body_json( json!({"error": "InvalidDelegation", "message": "delegate cap reached"}), )) .up_to_n_times(1) .mount(&server) .await; let error = app .create(ACTOR, "create-token", input()) .await .err() .unwrap(); assert_eq!(error.status, StatusCode::BAD_REQUEST); assert_eq!(error.error, "InvalidDelegation"); assert_eq!(error.message.as_deref(), Some("delegate cap reached")); Mock::given(path("/xrpc/_delegation.createDelegatedAccount")) .respond_with(ResponseTemplate::new(503)) .mount(&server) .await; let error = app .create(ACTOR, "create-token", input()) .await .err() .unwrap(); assert_eq!(error.status, StatusCode::BAD_GATEWAY); assert_eq!(error.error, "UpstreamUnavailable"); } #[tokio::test] async fn rejects_missing_nonce() { let (app, _) = setup().await; let claims: ServiceAuthClaims = serde_json::from_value(json!({ "iss": ACTOR, "aud": GATE, "iat": now(), "exp": now() + 60, "lxm": CREATE, "jti": null })) .unwrap(); assert!(app.verify_claims(&claims).await.is_err()); } #[tokio::test] async fn forwards_the_create_token_without_controller_overrides() { let (app, server) = setup().await; let create = token(&app, GATE, CREATE, now() + 60, None); email(&server, vec!["person@example.com"]).await; handle_free(&server).await; Mock::given(method("POST")) .and(path("/xrpc/_delegation.createDelegatedAccount")) .and(header("authorization", format!("Bearer {create}"))) .and(body_json( json!({"handle":"org.example", "controllerScopes":OWNER}), )) .respond_with( ResponseTemplate::new(200) .set_body_json(json!({"did":"did:plc:org", "handle":"org.example"})), ) .expect(1) .mount(&server) .await; let response = request(app, Some(&create)).await; assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), 4096) .await .unwrap(); let account: Account = serde_json::from_slice(&body).unwrap(); assert_eq!(account.controller_did, ACTOR); // The gate never enumerates delegates or opens a session of its own. assert!(server.received_requests().await.unwrap().iter().all(|r| { (r.url.query().is_none() || r.url.path().ends_with("describeRepo")) && !r.url.path().contains("createSession") && !r.url.path().contains("listControlledAccounts") })); } #[test] fn qualifies_only_single_labels_under_the_handle_domain() { assert_eq!( qualify("Acme.Example", "example").as_deref(), Some("acme.example") ); assert_eq!( qualify("a-1.example", "example").as_deref(), Some("a-1.example") ); for handle in [ "example", ".example", "acme.other", "acme.notexample", "a.b.example", "-acme.example", "acme-.example", "ac_me.example", "acme.example.evil", ] { assert_eq!(qualify(handle, "example"), None, "{handle}"); } } #[tokio::test] async fn refuses_handles_outside_the_domain_or_reserved_before_checking_email() { let (app, server) = setup().await; for (handle, code) in [ ("org.elsewhere", "InvalidHandle"), ("gate.example", "HandleNotAvailable"), ] { let error = app .create( ACTOR, "create-token", CreateInput { handle: handle.into(), }, ) .await .err() .unwrap(); assert_eq!(error.error, code, "{handle}"); } assert!(server.received_requests().await.unwrap().is_empty()); } #[tokio::test] async fn refuses_a_handle_any_pds_in_the_domain_already_holds() { let input = || CreateInput { handle: "org.example".into(), }; for (status, body) in [ ( 200, json!({"did": "did:plc:someone", "handle": "org.example"}), ), (400, json!({"error": "RepoDeactivated"})), ] { let peer = MockServer::start().await; describe_repo(&peer, status, body).await; let (app, server) = setup_with_peers(&[&peer]).await; email(&server, vec!["person@example.com"]).await; handle_free(&server).await; let error = app .create(ACTOR, "create-token", input()) .await .err() .unwrap(); assert_eq!(error.error, "HandleNotAvailable"); assert!( server .received_requests() .await .unwrap() .iter() .all(|r| !r.url.path().contains("createDelegatedAccount")) ); } let peer = MockServer::start().await; describe_repo(&peer, 503, json!({})).await; let (app, server) = setup_with_peers(&[&peer]).await; email(&server, vec!["person@example.com"]).await; handle_free(&server).await; let error = app .create(ACTOR, "create-token", input()) .await .err() .unwrap(); assert_eq!(error.error, "UpstreamUnavailable"); }