#![cfg(all(test, feature = "scope-check"))]
use std::collections::VecDeque;
use std::sync::Arc;
use bytes::Bytes;
use http::{Response as HttpResponse, StatusCode};
use jacquard::client::Agent;
use jacquard::deps::fluent_uri::Uri;
use jacquard::types::did::Did;
use jacquard::types::string::{DidService, Nsid};
use jacquard::xrpc::XrpcClient;
use jacquard::{BosStr, IntoStatic};
use jacquard_common::http_client::HttpClient;
use jacquard_oauth::atproto::AtprotoClientMetadata;
use jacquard_oauth::client::OAuthSession;
use jacquard_oauth::resolver::OAuthResolver;
use jacquard_oauth::scopes::{
RepoAction, RepoCollection, RepoScope, RpcAudience, RpcLexicon, RpcScope, Scope, Scopes,
};
use jacquard_oauth::session::SessionRegistry;
use jacquard_oauth::session::{ClientData, ClientSessionData, DpopClientData};
use jacquard_oauth::types::{OAuthAuthorizationServerMetadata, OAuthTokenType, TokenSet};
use smol_str::{SmolStr, format_smolstr};
use std::collections::BTreeSet;
use tokio::sync::Mutex;
#[derive(Clone, Default)]
struct MockClient {
queue: Arc<Mutex<VecDeque<http::Response<Vec<u8>>>>>,
log: Arc<Mutex<Vec<http::Request<Vec<u8>>>>>,
}
impl MockClient {
async fn push(&self, resp: http::Response<Vec<u8>>) {
self.queue.lock().await.push_back(resp);
}
}
impl HttpClient for MockClient {
type Error = std::convert::Infallible;
fn send_http(
&self,
request: http::Request<Vec<u8>>,
) -> impl core::future::Future<
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
> + Send {
let log = self.log.clone();
let queue = self.queue.clone();
async move {
log.lock().await.push(request);
Ok(queue.lock().await.pop_front().expect("no queued response"))
}
}
}
impl jacquard::identity::resolver::IdentityResolver for MockClient {
fn options(&self) -> &jacquard::identity::resolver::ResolverOptions {
use std::sync::LazyLock;
static OPTS: LazyLock<jacquard::identity::resolver::ResolverOptions> =
LazyLock::new(jacquard::identity::resolver::ResolverOptions::default);
&OPTS
}
async fn resolve_handle<S: BosStr>(
&self,
_handle: &jacquard::types::string::Handle<S>,
) -> std::result::Result<Did, jacquard::identity::resolver::IdentityError> {
Ok(Did::new_static("did:plc:alice").unwrap())
}
async fn resolve_did_doc<S: BosStr>(
&self,
_did: &Did<S>,
) -> std::result::Result<
jacquard::identity::resolver::DidDocResponse,
jacquard::identity::resolver::IdentityError,
> {
let doc = serde_json::json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": "did:plc:alice",
"alsoKnownAs": ["at://alice.bsky.social"],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}]
});
Ok(jacquard::identity::resolver::DidDocResponse {
buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()),
status: StatusCode::OK,
requested: None,
})
}
}
impl OAuthResolver for MockClient {
async fn get_authorization_server_metadata(
&self,
issuer: &str,
) -> Result<OAuthAuthorizationServerMetadata, jacquard_oauth::resolver::ResolverError> {
let mut md = OAuthAuthorizationServerMetadata::default();
md.issuer = SmolStr::from(issuer);
md.token_endpoint = format_smolstr!("{}/token", issuer);
md.authorization_endpoint = format_smolstr!("{}/authorize", issuer);
md.require_pushed_authorization_requests = Some(true);
md.pushed_authorization_request_endpoint = Some(format_smolstr!("{}/par", issuer));
md.token_endpoint_auth_methods_supported = Some(vec![SmolStr::from("none")]);
md.dpop_signing_alg_values_supported = Some(vec![SmolStr::from("ES256")]);
Ok(md)
}
async fn get_resource_server_metadata(
&self,
_pds: &str,
) -> Result<OAuthAuthorizationServerMetadata, jacquard_oauth::resolver::ResolverError> {
let mut md = OAuthAuthorizationServerMetadata::default();
md.issuer = SmolStr::from("https://issuer");
md.token_endpoint = SmolStr::from("https://issuer/token");
md.authorization_endpoint = SmolStr::from("https://issuer/authorize");
md.require_pushed_authorization_requests = Some(true);
md.pushed_authorization_request_endpoint = Some(SmolStr::from("https://issuer/par"));
md.token_endpoint_auth_methods_supported = Some(vec![SmolStr::from("none")]);
md.dpop_signing_alg_values_supported = Some(vec![SmolStr::from("ES256")]);
Ok(md)
}
async fn verify_issuer<S: BosStr + Sync>(
&self,
_server_metadata: &OAuthAuthorizationServerMetadata,
_sub: &Did<S>,
) -> Result<jacquard::deps::fluent_uri::Uri<String>, jacquard_oauth::resolver::ResolverError>
{
Ok(
jacquard::deps::fluent_uri::Uri::parse("https://pds.example.com")
.unwrap()
.to_owned(),
)
}
}
impl jacquard_oauth::dpop::DpopExt for MockClient {}
fn get_session_ok() -> http::Response<Vec<u8>> {
HttpResponse::builder()
.status(StatusCode::OK)
.header(http::header::CONTENT_TYPE, "application/json")
.body(
serde_json::to_vec(&serde_json::json!({
"did":"did:plc:alice",
"handle":"alice.bsky.social",
"active":true
}))
.unwrap(),
)
.unwrap()
}
fn create_session_data(resolved_scopes: Option<Vec<Scope<SmolStr>>>) -> ClientSessionData {
ClientSessionData {
account_did: Did::new_static("did:plc:alice").unwrap(),
session_id: SmolStr::from("state"),
host_url: Uri::parse("https://pds.example.com")
.expect("valid uri")
.to_owned(),
authserver_url: SmolStr::new_static("https://issuer"),
authserver_token_endpoint: SmolStr::from("https://issuer/token"),
authserver_revocation_endpoint: None,
scopes: Scopes::new(SmolStr::new_static("atproto")).unwrap(),
dpop_data: DpopClientData {
dpop_key: jacquard_oauth::utils::generate_key(&[SmolStr::from("ES256")]).unwrap(),
dpop_authserver_nonce: SmolStr::from(""),
dpop_host_nonce: SmolStr::from(""),
},
token_set: TokenSet {
iss: SmolStr::from("https://issuer"),
sub: Did::new_static("did:plc:alice").unwrap(),
aud: SmolStr::from("https://pds.example.com"),
scope: None,
refresh_token: Some(SmolStr::from("rt1")),
access_token: SmolStr::from("atk1"),
token_type: OAuthTokenType::DPoP,
expires_at: None,
},
resolved_scopes,
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_scope_check_permits_matching_rpc() {
let client = Arc::new(MockClient::default());
client.push(get_session_ok()).await;
let mut path = std::env::temp_dir();
path.push(format!(
"jacquard-scope-test-matching-{}.json",
std::process::id()
));
std::fs::write(&path, "{}").unwrap();
let store = jacquard::client::FileAuthStore::new(&path);
let client_data = ClientData {
keyset: None,
config: AtprotoClientMetadata::new_localhost(
None,
Some(Scopes::new(SmolStr::new_static("atproto")).unwrap()),
),
};
let mut rpc_lexicon = BTreeSet::new();
rpc_lexicon.insert(RpcLexicon::Nsid(
Nsid::<SmolStr>::new_static("com.atproto.server.getSession").unwrap(),
));
let mut aud = BTreeSet::new();
aud.insert(RpcAudience::All);
let resolved_scopes = Some(vec![Scope::Rpc(RpcScope {
lxm: rpc_lexicon,
aud,
})]);
let session_data = create_session_data(resolved_scopes).into_static();
let client_arc = client.clone();
let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data));
registry.set(session_data.clone()).await.unwrap();
let session = OAuthSession::new(registry, client_arc, session_data);
let agent: Agent<_> = Agent::from(session);
let resp = agent
.send(jacquard::api::com_atproto::server::get_session::GetSession)
.await
.expect("xrpc send should succeed with matching scope");
assert_eq!(resp.status(), StatusCode::OK);
let log = client.log.lock().await;
assert_eq!(log.len(), 1, "expected 1 HTTP call");
let _ = std::fs::remove_file(&path);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_scope_check_denies_ungranted() {
let client = Arc::new(MockClient::default());
let mut path = std::env::temp_dir();
path.push(format!(
"jacquard-scope-test-deny-{}.json",
std::process::id()
));
std::fs::write(&path, "{}").unwrap();
let store = jacquard::client::FileAuthStore::new(&path);
let client_data = ClientData {
keyset: None,
config: AtprotoClientMetadata::new_localhost(
None,
Some(Scopes::new(SmolStr::new_static("atproto")).unwrap()),
),
};
let mut rpc_lexicon = BTreeSet::new();
rpc_lexicon.insert(RpcLexicon::Nsid(
Nsid::<SmolStr>::new_static("com.example.other").unwrap(),
));
let mut aud = BTreeSet::new();
aud.insert(RpcAudience::All);
let resolved_scopes = Some(vec![Scope::Rpc(RpcScope {
lxm: rpc_lexicon,
aud,
})]);
let session_data = create_session_data(resolved_scopes).into_static();
let client_arc = client.clone();
let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data));
registry.set(session_data.clone()).await.unwrap();
let session = OAuthSession::new(registry, client_arc, session_data);
let agent: Agent<_> = Agent::from(session);
let resp = agent
.send(jacquard::api::com_atproto::server::get_session::GetSession)
.await;
let err = match resp {
Err(e) => e,
Ok(_) => panic!("xrpc send should fail without matching scope"),
};
let err_msg = format!("{}", err);
assert!(
err_msg.contains("com.atproto.server.getSession"),
"error should mention the denied NSID, got: {err_msg}"
);
assert!(
err_msg.contains("com.example.other"),
"error should mention the granted scopes for diagnostics, got: {err_msg}"
);
let log = client.log.lock().await;
assert_eq!(
log.len(),
0,
"no HTTP request should be made when scope check fails"
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_scope_check_no_resolved_scopes_permits() {
let client = Arc::new(MockClient::default());
client.push(get_session_ok()).await;
let mut path = std::env::temp_dir();
path.push(format!(
"jacquard-scope-test-none-{}.json",
std::process::id()
));
std::fs::write(&path, "{}").unwrap();
let store = jacquard::client::FileAuthStore::new(&path);
let client_data = ClientData {
keyset: None,
config: AtprotoClientMetadata::new_localhost(
None,
Some(Scopes::new(SmolStr::new_static("atproto")).unwrap()),
),
};
let session_data = create_session_data(None).into_static();
let client_arc = client.clone();
let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data));
registry.set(session_data.clone()).await.unwrap();
let session = OAuthSession::new(registry, client_arc, session_data);
let agent: Agent<_> = Agent::from(session);
let resp = agent
.send(jacquard::api::com_atproto::server::get_session::GetSession)
.await
.expect("xrpc send should succeed when resolved_scopes is None");
assert_eq!(resp.status(), StatusCode::OK);
let log = client.log.lock().await;
assert_eq!(log.len(), 1, "expected 1 HTTP call");
let _ = std::fs::remove_file(&path);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_scope_check_wildcard_rpc_permits() {
let client = Arc::new(MockClient::default());
client.push(get_session_ok()).await;
let mut path = std::env::temp_dir();
path.push(format!(
"jacquard-scope-test-wildcard-{}.json",
std::process::id()
));
std::fs::write(&path, "{}").unwrap();
let store = jacquard::client::FileAuthStore::new(&path);
let client_data = ClientData {
keyset: None,
config: AtprotoClientMetadata::new_localhost(
None,
Some(Scopes::new(SmolStr::new_static("atproto")).unwrap()),
),
};
let mut rpc_lexicon = BTreeSet::new();
rpc_lexicon.insert(RpcLexicon::All);
let mut aud = BTreeSet::new();
aud.insert(RpcAudience::All);
let resolved_scopes = Some(vec![Scope::Rpc(RpcScope {
lxm: rpc_lexicon,
aud,
})]);
let session_data = create_session_data(resolved_scopes).into_static();
let client_arc = client.clone();
let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data));
registry.set(session_data.clone()).await.unwrap();
let session = OAuthSession::new(registry, client_arc, session_data);
let agent: Agent<_> = Agent::from(session);
let resp = agent
.send(jacquard::api::com_atproto::server::get_session::GetSession)
.await
.expect("xrpc send should succeed with wildcard scope");
assert_eq!(resp.status(), StatusCode::OK);
let log = client.log.lock().await;
assert_eq!(log.len(), 1, "expected 1 HTTP call");
let _ = std::fs::remove_file(&path);
}
fn resolved_scopes_auth_create_posts() -> Vec<Scope<SmolStr>> {
let video_rpcs = [
"app.bsky.video.uploadVideo",
"app.bsky.video.getJobStatus",
"app.bsky.video.getUploadLimits",
];
let collections = [
"app.bsky.feed.post",
"app.bsky.feed.postgate",
"app.bsky.feed.threadgate",
];
let mut scopes = Vec::new();
let mut lxm = BTreeSet::new();
for nsid in &video_rpcs {
lxm.insert(RpcLexicon::Nsid(Nsid::<SmolStr>::new_static(nsid).unwrap()));
}
let mut aud = BTreeSet::new();
aud.insert(RpcAudience::All);
scopes.push(Scope::Rpc(RpcScope { lxm, aud }));
for col in &collections {
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
scopes.push(Scope::Repo(RepoScope {
collection: RepoCollection::Nsid(Nsid::<SmolStr>::new_static(col).unwrap()),
actions,
}));
}
scopes
}
#[tokio::test(flavor = "multi_thread")]
async fn test_realistic_scopes_video_rpc_permitted() {
let client = Arc::new(MockClient::default());
client
.push(
HttpResponse::builder()
.status(StatusCode::OK)
.header(http::header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&serde_json::json!({"key": "value"})).unwrap())
.unwrap(),
)
.await;
let mut path = std::env::temp_dir();
path.push(format!(
"jacquard-scope-realistic-rpc-{}.json",
std::process::id(),
));
std::fs::write(&path, "{}").unwrap();
let store = jacquard::client::FileAuthStore::new(&path);
let client_data = ClientData {
keyset: None,
config: AtprotoClientMetadata::new_localhost(
None,
Some(Scopes::new(SmolStr::new_static("atproto")).unwrap()),
),
};
let session_data = create_session_data(Some(resolved_scopes_auth_create_posts())).into_static();
let client_arc = client.clone();
let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data));
registry.set(session_data.clone()).await.unwrap();
let session = OAuthSession::new(registry, client_arc, session_data);
let agent: Agent<_> = Agent::from(session);
let resp = agent
.send(jacquard::api::app_bsky::video::get_upload_limits::GetUploadLimits)
.await
.expect("video RPC should be permitted by authCreatePosts scope");
assert_eq!(resp.status(), StatusCode::OK);
let log = client.log.lock().await;
assert_eq!(log.len(), 1, "expected 1 HTTP call");
let _ = std::fs::remove_file(&path);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_realistic_scopes_ungated_rpc_denied() {
let client = Arc::new(MockClient::default());
let mut path = std::env::temp_dir();
path.push(format!(
"jacquard-scope-realistic-deny-{}.json",
std::process::id(),
));
std::fs::write(&path, "{}").unwrap();
let store = jacquard::client::FileAuthStore::new(&path);
let client_data = ClientData {
keyset: None,
config: AtprotoClientMetadata::new_localhost(
None,
Some(Scopes::new(SmolStr::new_static("atproto")).unwrap()),
),
};
let session_data = create_session_data(Some(resolved_scopes_auth_create_posts())).into_static();
let client_arc = client.clone();
let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data));
registry.set(session_data.clone()).await.unwrap();
let session = OAuthSession::new(registry, client_arc, session_data);
let agent: Agent<_> = Agent::from(session);
let err = match agent
.send(jacquard::api::com_atproto::server::get_session::GetSession)
.await
{
Err(e) => e,
Ok(_) => panic!("getSession should be denied — not in authCreatePosts permission set"),
};
let err_msg = format!("{}", err);
assert!(
err_msg.contains("com.atproto.server.getSession"),
"error should identify the denied NSID, got: {err_msg}"
);
let log = client.log.lock().await;
assert_eq!(log.len(), 0, "no HTTP request when scope check fails");
let _ = std::fs::remove_file(&path);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_realistic_scopes_audience_specific_rpc_permitted() {
let client = Arc::new(MockClient::default());
client
.push(
HttpResponse::builder()
.status(StatusCode::OK)
.header(http::header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&serde_json::json!({"key": "value"})).unwrap())
.unwrap(),
)
.await;
let mut path = std::env::temp_dir();
path.push(format!(
"jacquard-scope-aud-specific-{}.json",
std::process::id(),
));
std::fs::write(&path, "{}").unwrap();
let store = jacquard::client::FileAuthStore::new(&path);
let client_data = ClientData {
keyset: None,
config: AtprotoClientMetadata::new_localhost(
None,
Some(Scopes::new(SmolStr::new_static("atproto")).unwrap()),
),
};
let mut lxm = BTreeSet::new();
lxm.insert(RpcLexicon::Nsid(
Nsid::<SmolStr>::new_static("app.bsky.video.getUploadLimits").unwrap(),
));
let mut aud = BTreeSet::new();
aud.insert(RpcAudience::Did(
DidService::<SmolStr>::new_static("did:web:api.bsky.app").unwrap(),
));
let resolved_scopes = Some(vec![Scope::Rpc(RpcScope { lxm, aud })]);
let session_data = create_session_data(resolved_scopes).into_static();
let client_arc = client.clone();
let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data));
registry.set(session_data.clone()).await.unwrap();
let session = OAuthSession::new(registry, client_arc, session_data);
let agent: Agent<_> = Agent::from(session);
let resp = agent
.send(jacquard::api::app_bsky::video::get_upload_limits::GetUploadLimits)
.await
.expect("RPC with audience-specific scope should still be permitted at pre-flight");
assert_eq!(resp.status(), StatusCode::OK);
let log = client.log.lock().await;
assert_eq!(log.len(), 1, "expected 1 HTTP call");
let _ = std::fs::remove_file(&path);
}