A better Rust ATProto crate

oauth and loopback callback handler work properly, able to make authed xrpc requests

Orual 2ff4604b 2112129b

Changed files
+658 -111
crates
+190 -1
Cargo.lock
···
]
[[package]]
+
name = "cesu8"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+
[[package]]
+
name = "combine"
+
version = "4.6.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+
dependencies = [
+
"bytes",
+
"memchr",
+
]
[[package]]
name = "compression-codecs"
···
[[package]]
+
name = "home"
+
version = "0.5.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
+
dependencies = [
+
"windows-sys 0.59.0",
+
]
+
+
[[package]]
name = "http"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"percent-encoding",
"rand_core 0.6.4",
"reqwest",
-
"rouille",
"serde",
"serde_html_form",
"serde_ipld_dagcbor",
···
"rand 0.8.5",
"rand_core 0.6.4",
"reqwest",
+
"rouille",
"serde",
"serde_html_form",
"serde_json",
···
"trait-variant",
"url",
"uuid",
+
"webbrowser",
[[package]]
+
name = "jni"
+
version = "0.21.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+
dependencies = [
+
"cesu8",
+
"cfg-if",
+
"combine",
+
"jni-sys",
+
"log",
+
"thiserror 1.0.69",
+
"walkdir",
+
"windows-sys 0.45.0",
+
]
+
+
[[package]]
+
name = "jni-sys"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
+
[[package]]
name = "jose-b64"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
+
name = "malloc_buf"
+
version = "0.0.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+
dependencies = [
+
"libc",
+
]
+
+
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
+
name = "ndk-context"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
+
name = "objc"
+
version = "0.2.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+
dependencies = [
+
"malloc_buf",
+
]
+
+
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab"
[[package]]
+
name = "raw-window-handle"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
+
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
+
+
[[package]]
+
name = "same-file"
+
version = "1.0.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+
dependencies = [
+
"winapi-util",
+
]
[[package]]
name = "schemars"
···
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
+
name = "walkdir"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+
dependencies = [
+
"same-file",
+
"winapi-util",
+
]
+
+
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
+
name = "webbrowser"
+
version = "0.8.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b"
+
dependencies = [
+
"core-foundation",
+
"home",
+
"jni",
+
"log",
+
"ndk-context",
+
"objc",
+
"raw-window-handle",
+
"url",
+
"web-sys",
+
]
+
+
[[package]]
name = "webpki-roots"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
[[package]]
+
name = "winapi-util"
+
version = "0.1.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+
dependencies = [
+
"windows-sys 0.60.2",
+
]
+
+
[[package]]
name = "windows-core"
version = "0.62.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "windows-sys"
+
version = "0.45.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+
dependencies = [
+
"windows-targets 0.42.2",
+
]
+
+
[[package]]
+
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
···
[[package]]
name = "windows-targets"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+
dependencies = [
+
"windows_aarch64_gnullvm 0.42.2",
+
"windows_aarch64_msvc 0.42.2",
+
"windows_i686_gnu 0.42.2",
+
"windows_i686_msvc 0.42.2",
+
"windows_x86_64_gnu 0.42.2",
+
"windows_x86_64_gnullvm 0.42.2",
+
"windows_x86_64_msvc 0.42.2",
+
]
+
+
[[package]]
+
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
···
[[package]]
name = "windows_aarch64_gnullvm"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+
[[package]]
+
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
···
[[package]]
name = "windows_aarch64_msvc"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+
[[package]]
+
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
···
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
+
+
[[package]]
+
name = "windows_i686_gnu"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
···
[[package]]
name = "windows_i686_msvc"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+
[[package]]
+
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
···
[[package]]
name = "windows_x86_64_gnu"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+
[[package]]
+
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
···
[[package]]
name = "windows_x86_64_gnullvm"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+
[[package]]
+
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
···
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
+
+
[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
+3
crates/jacquard-common/src/session.rs
···
impl FileTokenStore {
/// Create a new file token store at the given path.
pub fn new(path: impl AsRef<Path>) -> Self {
+
std::fs::create_dir_all(path.as_ref().parent().unwrap()).unwrap();
+
std::fs::write(path.as_ref(), b"{}").unwrap();
+
Self {
path: path.as_ref().to_path_buf(),
}
+31 -19
crates/jacquard-common/src/types/xrpc.rs
···
.await
.map_err(|e| crate::error::TransportError::Other(Box::new(e)))?;
-
let status = http_response.status();
-
// If the server returned 401 with a WWW-Authenticate header, expose it so higher layers
-
// (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh.
-
if status.as_u16() == 401 {
-
if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
-
return Err(crate::error::ClientError::Auth(
-
crate::error::AuthError::Other(hv.clone()),
-
));
-
}
-
}
-
let buffer = Bytes::from(http_response.into_body());
+
process_response(http_response)
+
}
+
}
-
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
-
return Err(crate::error::HttpError {
-
status,
-
body: Some(buffer),
-
}
-
.into());
+
/// Process the HTTP response from the server into a proper xrpc response statelessly.
+
///
+
/// Exposed to make things more easily pluggable
+
#[inline]
+
pub fn process_response<R: XrpcRequest + Send>(
+
http_response: http::Response<Vec<u8>>,
+
) -> XrpcResult<Response<R>> {
+
let status = http_response.status();
+
// If the server returned 401 with a WWW-Authenticate header, expose it so higher layers
+
// (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh.
+
if status.as_u16() == 401 {
+
if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
+
return Err(crate::error::ClientError::Auth(
+
crate::error::AuthError::Other(hv.clone()),
+
));
}
+
}
+
let buffer = Bytes::from(http_response.into_body());
-
Ok(Response::new(buffer, status))
+
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
+
return Err(crate::error::HttpError {
+
status,
+
body: Some(buffer),
+
}
+
.into());
}
+
+
Ok(Response::new(buffer, status))
}
/// HTTP headers commonly used in XRPC requests
···
struct Err<'a>(#[serde(borrow)] CowStr<'a>);
impl IntoStatic for Err<'_> {
type Output = Err<'static>;
-
fn into_static(self) -> Self::Output { Err(self.0.into_static()) }
+
fn into_static(self) -> Self::Output {
+
Err(self.0.into_static())
+
}
}
impl XrpcRequest for Req {
const NSID: &'static str = "com.example.test";
+8 -1
crates/jacquard-oauth/Cargo.toml
···
rand = { version = "0.8.5", features = ["small_rng"] }
async-trait.workspace = true
dashmap = "6.1.0"
-
tokio = { workspace = true, features = ["sync"] }
+
tokio = { workspace = true, features = ["sync", "net", "time"] }
reqwest.workspace = true
trait-variant.workspace = true
+
webbrowser = { version = "0.8", optional = true }
+
rouille = { version = "3.6.2", optional = true }
+
+
[features]
+
default = []
+
loopback = ["dep:rouille"]
+
browser-open = ["dep:webbrowser"]
+16 -12
crates/jacquard-oauth/src/atproto.rs
···
if let Some(redirect_uris) = &mut redirect_uris {
for redirect_uri in redirect_uris {
let _ = redirect_uri.set_scheme("http");
-
redirect_uri.set_host(Some("localhost")).unwrap();
-
let _ = redirect_uri.set_port(None);
+
redirect_uri.set_host(Some("127.0.0.1")).unwrap();
}
}
// determine client_id
···
keyset: &Option<Keyset>,
) -> Result<OAuthClientMetadata<'m>> {
// For non-loopback clients, require a keyset/JWKs.
-
let is_loopback = metadata.client_id.scheme() == "http"
-
&& metadata.client_id.host_str() == Some("localhost");
+
let is_loopback =
+
metadata.client_id.scheme() == "http" && metadata.client_id.host_str() == Some("localhost");
if !is_loopback && keyset.is_none() {
return Err(Error::EmptyJwks);
}
···
} else {
None
},
-
scope: if keyset.is_some() {
-
Some(Scope::serialize_multiple(metadata.scopes.as_slice()))
-
} else {
-
None
-
},
+
scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())),
dpop_bound_access_tokens: if keyset.is_some() { Some(true) } else { None },
jwks_uri,
jwks,
···
assert_eq!(
out,
OAuthClientMetadata {
-
client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(),
+
client_id: Url::from_str(
+
"http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F"
+
)
+
.unwrap(),
client_uri: None,
redirect_uris: vec![Url::from_str("http://localhost/").unwrap()],
scope: None,
···
assert_eq!(
out,
OAuthClientMetadata {
-
client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(),
+
client_id: Url::from_str(
+
"http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F"
+
)
+
.unwrap(),
client_uri: None,
redirect_uris: vec![Url::from_str("http://localhost/").unwrap()],
scope: None,
···
assert_eq!(
out,
OAuthClientMetadata {
-
client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(),
+
client_id: Url::from_str(
+
"http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F"
+
)
+
.unwrap(),
client_uri: None,
redirect_uris: vec![Url::from_str("http://localhost/").unwrap()],
scope: None,
+86 -19
crates/jacquard-oauth/src/client.rs
···
http_client::HttpClient,
types::{
did::Did,
-
xrpc::{CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest},
+
xrpc::{
+
CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest, build_http_request,
+
process_response,
+
},
},
};
use jacquard_identity::JacquardResolver;
···
let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data));
Self { registry, client }
}
+
+
pub fn new_with_shared(
+
store: Arc<S>,
+
client: Arc<T>,
+
client_data: ClientData<'static>,
+
) -> Self {
+
let registry = Arc::new(SessionRegistry::new_shared(
+
store,
+
client.clone(),
+
client_data,
+
));
+
Self { registry, client }
+
}
}
impl<T, S> OAuthClient<T, S>
···
};
let auth_req_info =
par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?;
+
// Persist state for callback handling
+
self.registry
+
.store
+
.save_auth_req_info(&auth_req_info)
+
.await?;
#[derive(serde::Serialize)]
struct Parameters<'s> {
···
if let Some(iss) = params.iss {
if !crate::resolver::issuer_equivalent(&iss, &metadata.issuer) {
-
return Err(CallbackError::IssuerMismatch { expected: metadata.issuer.to_string(), got: iss.to_string() }.into());
+
return Err(CallbackError::IssuerMismatch {
+
expected: metadata.issuer.to_string(),
+
got: iss.to_string(),
+
}
+
.into());
}
} else if metadata.authorization_response_iss_parameter_supported == Some(true) {
return Err(CallbackError::MissingIssuer.into());
···
}
pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> {
-
self.data.read().await.token_set.refresh_token.as_ref().map(|t| AuthorizationToken::Dpop(t.clone()))
+
self.data
+
.read()
+
.await
+
.token_set
+
.refresh_token
+
.as_ref()
+
.map(|t| AuthorizationToken::Dpop(t.clone()))
+
}
+
}
+
impl<T, S> OAuthSession<T, S>
+
where
+
S: ClientAuthStore + Send + Sync + 'static,
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
+
{
+
pub async fn logout(&self) -> Result<()> {
+
use crate::request::{OAuthMetadata, revoke};
+
let mut data = self.data.write().await;
+
let meta =
+
OAuthMetadata::new(self.client.as_ref(), &self.registry.client_data, &data).await?;
+
if meta.server_metadata.revocation_endpoint.is_some() {
+
let token = data.token_set.access_token.clone();
+
revoke(self.client.as_ref(), &mut data.dpop_data, &token, &meta)
+
.await
+
.ok();
+
}
+
// Remove from store
+
self.registry
+
.del(&data.account_did, &data.session_id)
+
.await?;
+
Ok(())
+
}
+
}
+
+
impl<T, S> OAuthClient<T, S>
+
where
+
T: OAuthResolver,
+
S: ClientAuthStore,
+
{
+
pub fn from_session(session: &OAuthSession<T, S>) -> Self {
+
Self {
+
registry: session.registry.clone(),
+
client: session.client.clone(),
+
}
}
}
impl<T, S> OAuthSession<T, S>
···
let data = self.data.read().await;
(data.account_did.clone(), data.session_id.clone())
};
-
let refreshed = self
-
.registry
-
.as_ref()
-
.get(&did, &sid, true)
-
.await?;
+
let refreshed = self.registry.as_ref().get(&did, &sid, true).await?;
let token = AuthorizationToken::Dpop(refreshed.token_set.access_token.clone());
// Write back updated session
*self.data.write().await = refreshed.into_static();
···
request: &R,
) -> XrpcResult<Response<R>> {
let base_uri = self.base_uri();
-
let auth = self.access_token().await;
let mut opts = self.options.read().await.clone();
-
opts.auth = Some(auth);
-
let res = self
+
opts.auth = Some(self.access_token().await);
+
let guard = self.data.read().await;
+
let mut dpop = guard.dpop_data.clone();
+
let http_response = self
.client
-
.xrpc(base_uri.clone())
-
.with_options(opts.clone())
-
.send(request)
-
.await;
+
.dpop_call(&mut dpop)
+
.send(build_http_request(&base_uri, request, &opts)?)
+
.await
+
.map_err(|e| TransportError::Other(Box::new(e)))?;
+
let res = process_response(http_response);
if is_invalid_token_response(&res) {
opts.auth = Some(
self.refresh()
.await
.map_err(|e| ClientError::Transport(TransportError::Other(e.into())))?,
);
-
self.client
-
.xrpc(base_uri)
-
.with_options(opts)
-
.send(request)
+
let http_response = self
+
.client
+
.dpop_call(&mut dpop)
+
.send(build_http_request(&base_uri, request, &opts)?)
.await
+
.map_err(|e| TransportError::Other(Box::new(e)))?;
+
process_response(http_response)
} else {
res
}
+3
crates/jacquard-oauth/src/dpop.rs
···
use chrono::Utc;
use http::{Request, Response, header::InvalidHeaderValue};
use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient};
+
use jacquard_identity::JacquardResolver;
use jose_jwa::{Algorithm, Signing};
use jose_jwk::{Jwk, Key, crypto};
use p256::ecdsa::SigningKey;
···
claims,
)?)
}
+
+
impl DpopExt for JacquardResolver {}
+5 -2
crates/jacquard-oauth/src/error.rs
···
/// Typed callback validation errors (redirect handling).
#[derive(Debug, thiserror::Error, Diagnostic)]
pub enum CallbackError {
-
#[error("missing state parameter in callback")]
+
#[error("missing state parameter in callback")]
#[diagnostic(code(jacquard_oauth::callback::missing_state))]
MissingState,
-
#[error("missing `iss` parameter")]
+
#[error("missing `iss` parameter")]
#[diagnostic(code(jacquard_oauth::callback::missing_iss))]
MissingIssuer,
#[error("issuer mismatch: expected {expected}, got {got}")]
#[diagnostic(code(jacquard_oauth::callback::issuer_mismatch))]
IssuerMismatch { expected: String, got: String },
+
#[error("timeout")]
+
#[diagnostic(code(jacquard_oauth::callback::timeout))]
+
Timeout,
}
pub type Result<T> = core::result::Result<T, OAuthError>;
+3
crates/jacquard-oauth/src/lib.rs
···
pub mod utils;
pub const FALLBACK_ALG: &str = "ES256";
+
+
#[cfg(feature = "loopback")]
+
pub mod loopback;
+178
crates/jacquard-oauth/src/loopback.rs
···
+
#![cfg(feature = "loopback")]
+
+
use crate::{
+
atproto::AtprotoClientMetadata,
+
authstore::ClientAuthStore,
+
client::OAuthClient,
+
dpop::DpopExt,
+
error::{CallbackError, OAuthError},
+
resolver::OAuthResolver,
+
scopes::Scope,
+
types::{AuthorizeOptions, CallbackParams},
+
};
+
use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr};
+
use rouille::Server;
+
use std::{net::SocketAddr, sync::Arc};
+
use tokio::{
+
net::TcpListener,
+
sync::{Mutex, mpsc, oneshot},
+
};
+
use url::Url;
+
+
#[derive(Clone, Debug)]
+
pub enum LoopbackPort {
+
Fixed(u16),
+
Ephemeral,
+
}
+
+
#[derive(Clone, Debug)]
+
pub struct LoopbackConfig {
+
pub host: String,
+
pub port: LoopbackPort,
+
pub open_browser: bool,
+
pub timeout_ms: u64,
+
}
+
+
impl Default for LoopbackConfig {
+
fn default() -> Self {
+
Self {
+
host: "127.0.0.1".into(),
+
port: LoopbackPort::Fixed(4000),
+
open_browser: true,
+
timeout_ms: 5 * 60 * 1000,
+
}
+
}
+
}
+
+
#[cfg(feature = "browser-open")]
+
fn try_open_in_browser(url: &str) -> bool {
+
webbrowser::open(url).is_ok()
+
}
+
#[cfg(not(feature = "browser-open"))]
+
fn try_open_in_browser(_url: &str) -> bool {
+
false
+
}
+
+
pub fn create_callback_router(
+
request: &rouille::Request,
+
tx: mpsc::Sender<CallbackParams>,
+
) -> rouille::Response {
+
rouille::router!(request,
+
(GET) (/oauth/callback) => {
+
let state = request.get_param("state").unwrap();
+
let code = request.get_param("code").unwrap();
+
let iss = request.get_param("iss").unwrap();
+
let callback_params = CallbackParams {
+
state: Some(state.to_cowstr().into_static()),
+
code: code.to_cowstr().into_static(),
+
iss: Some(iss.to_cowstr().into_static()),
+
};
+
tx.try_send(callback_params).unwrap();
+
rouille::Response::text("Logged in!")
+
},
+
_ => rouille::Response::empty_404()
+
)
+
}
+
+
struct CallbackHandle {
+
#[allow(dead_code)]
+
server_handle: std::thread::JoinHandle<()>,
+
server_stop: std::sync::mpsc::Sender<()>,
+
callback_rx: mpsc::Receiver<CallbackParams<'static>>,
+
}
+
+
fn one_shot_server(addr: SocketAddr) -> (SocketAddr, CallbackHandle) {
+
let (tx, callback_rx) = mpsc::channel(5);
+
let server = Server::new(addr, move |request| {
+
create_callback_router(request, tx.clone())
+
})
+
.expect("Could not start server");
+
let (server_handle, server_stop) = server.stoppable();
+
let handle = CallbackHandle {
+
server_handle,
+
server_stop,
+
callback_rx,
+
};
+
(addr, handle)
+
}
+
+
impl<T, S> OAuthClient<T, S>
+
where
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
+
S: ClientAuthStore + Send + Sync + 'static,
+
{
+
/// Drive the full OAuth flow using a local loopback server.
+
pub async fn login_with_local_server(
+
&self,
+
input: impl AsRef<str>,
+
opts: AuthorizeOptions<'_>,
+
cfg: LoopbackConfig,
+
) -> crate::error::Result<super::client::OAuthSession<T, S>> {
+
// 1) Bind server first to learn effective port
+
let port = match cfg.port {
+
LoopbackPort::Fixed(p) => p,
+
LoopbackPort::Ephemeral => 0,
+
};
+
// TODO: fix this to it also accepts ipv6
+
let bind_addr: SocketAddr = format!("0.0.0.0:{}", port)
+
.parse()
+
.expect("invalid loopback host/port");
+
let (local_addr, handle) = one_shot_server(bind_addr);
+
println!("Listening on {}", local_addr);
+
+
// 2) Build per-flow metadata with the actual redirect URI
+
let redirect = Url::parse(&format!(
+
"http://{}:{}/oauth/callback",
+
cfg.host,
+
local_addr.port(),
+
))
+
.unwrap();
+
let client_data = crate::session::ClientData {
+
keyset: self.registry.client_data.keyset.clone(),
+
config: AtprotoClientMetadata::new_localhost(
+
Some(vec![redirect.clone()]),
+
Some(vec![
+
Scope::Atproto,
+
Scope::Transition(crate::scopes::TransitionScope::Generic),
+
]),
+
),
+
};
+
+
// Build a per-flow client using shared store and resolver
+
let flow_client = OAuthClient::new_with_shared(
+
self.registry.store.clone(),
+
self.client.clone(),
+
client_data.clone(),
+
);
+
+
// 3) Start auth (persists state) and get authorization URL
+
let auth_url = flow_client.start_auth(input.as_ref(), opts).await?;
+
// Print URL for copy/paste
+
println!("Open this URL to authorize:\n{}\n", auth_url);
+
// Optionally open browser
+
if cfg.open_browser {
+
let _ = try_open_in_browser(&auth_url);
+
}
+
+
// 4) Await callback or timeout
+
let mut callback_rx = handle.callback_rx;
+
let cb = tokio::time::timeout(
+
std::time::Duration::from_millis(cfg.timeout_ms),
+
callback_rx.recv(),
+
)
+
.await;
+
// trigger shutdown
+
let _ = handle.server_stop.send(());
+
if let Err(_) = cb {
+
return Err(OAuthError::Callback(CallbackError::Timeout));
+
}
+
+
if let Ok(Some(cb)) = cb {
+
// 5) Continue with callback flow
+
let session = flow_client.callback(cb).await?;
+
Ok(session)
+
} else {
+
Err(OAuthError::Callback(CallbackError::Timeout))
+
}
+
}
+
}
+2 -1
crates/jacquard-oauth/src/request.rs
···
login_hint: login_hint,
prompt: prompt.map(CowStr::from),
};
+
println!("Parameters: {:?}", parameters);
if metadata
.server_metadata
.pushed_authorization_request_endpoint
···
metadata.client_metadata.redirect_uris[0]
.clone()
.to_smolstr(),
-
), // ?
+
),
code_verifier: verifier.into(),
}),
metadata,
+79 -20
crates/jacquard-oauth/src/resolver.rs
···
use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata};
use http::{Request, StatusCode};
-
use jacquard_common::{IntoStatic, error::TransportError};
+
use jacquard_common::CowStr;
use jacquard_common::types::did_doc::DidDocument;
use jacquard_common::types::ident::AtIdentifier;
+
use jacquard_common::{IntoStatic, error::TransportError};
use jacquard_common::{http_client::HttpClient, types::did::Did};
use jacquard_identity::resolver::{IdentityError, IdentityResolver};
use url::Url;
···
#[derive(thiserror::Error, Debug, miette::Diagnostic)]
pub enum ResolverError {
#[error("resource not found")]
-
#[diagnostic(code(jacquard_oauth::resolver::not_found), help("check the base URL or identifier"))]
+
#[diagnostic(
+
code(jacquard_oauth::resolver::not_found),
+
help("check the base URL or identifier")
+
)]
NotFound,
#[error("invalid at identifier: {0}")]
-
#[diagnostic(code(jacquard_oauth::resolver::at_identifier), help("ensure a valid handle or DID was provided"))]
+
#[diagnostic(
+
code(jacquard_oauth::resolver::at_identifier),
+
help("ensure a valid handle or DID was provided")
+
)]
AtIdentifier(String),
#[error("invalid did: {0}")]
-
#[diagnostic(code(jacquard_oauth::resolver::did), help("ensure DID is correctly formed (did:plc or did:web)"))]
+
#[diagnostic(
+
code(jacquard_oauth::resolver::did),
+
help("ensure DID is correctly formed (did:plc or did:web)")
+
)]
Did(String),
#[error("invalid did document: {0}")]
-
#[diagnostic(code(jacquard_oauth::resolver::did_document), help("verify the DID document structure and service entries"))]
+
#[diagnostic(
+
code(jacquard_oauth::resolver::did_document),
+
help("verify the DID document structure and service entries")
+
)]
DidDocument(String),
#[error("protected resource metadata is invalid: {0}")]
-
#[diagnostic(code(jacquard_oauth::resolver::protected_resource_metadata), help("PDS must advertise an authorization server in its protected resource metadata"))]
+
#[diagnostic(
+
code(jacquard_oauth::resolver::protected_resource_metadata),
+
help("PDS must advertise an authorization server in its protected resource metadata")
+
)]
ProtectedResourceMetadata(String),
#[error("authorization server metadata is invalid: {0}")]
-
#[diagnostic(code(jacquard_oauth::resolver::authorization_server_metadata), help("issuer must match and include the PDS resource"))]
+
#[diagnostic(
+
code(jacquard_oauth::resolver::authorization_server_metadata),
+
help("issuer must match and include the PDS resource")
+
)]
AuthorizationServerMetadata(String),
#[error("error resolving identity: {0}")]
#[diagnostic(code(jacquard_oauth::resolver::identity))]
IdentityResolverError(#[from] IdentityError),
#[error("unsupported did method: {0:?}")]
-
#[diagnostic(code(jacquard_oauth::resolver::unsupported_did_method), help("supported DID methods: did:web, did:plc"))]
+
#[diagnostic(
+
code(jacquard_oauth::resolver::unsupported_did_method),
+
help("supported DID methods: did:web, did:plc")
+
)]
UnsupportedDidMethod(Did<'static>),
#[error(transparent)]
#[diagnostic(code(jacquard_oauth::resolver::transport))]
Transport(#[from] TransportError),
#[error("http status: {0:?}")]
-
#[diagnostic(code(jacquard_oauth::resolver::http_status), help("check well-known paths and server configuration"))]
+
#[diagnostic(
+
code(jacquard_oauth::resolver::http_status),
+
help("check well-known paths and server configuration")
+
)]
HttpStatus(StatusCode),
#[error(transparent)]
#[diagnostic(code(jacquard_oauth::resolver::serde_json))]
···
let as_metadata = self.get_authorization_server_metadata(issuer).await?;
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
if let Some(protected_resources) = &as_metadata.protected_resources {
-
if !protected_resources.contains(&rs_metadata.resource) {
+
let resource_url = rs_metadata
+
.resource
+
.strip_suffix('/')
+
.unwrap_or(rs_metadata.resource.as_str());
+
if !protected_resources.contains(&CowStr::Borrowed(resource_url)) {
return Err(ResolverError::AuthorizationServerMetadata(format!(
-
"pds {pds} does not protected by issuer: {issuer}",
+
"pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}",
+
rs_metadata.resource, protected_resources
)));
}
}
···
#[tokio::test]
async fn authorization_server_http_status() {
let client = MockHttp::default();
-
*client.next.lock().await = Some(HttpResponse::builder().status(StatusCode::NOT_FOUND).body(Vec::new()).unwrap());
+
*client.next.lock().await = Some(
+
HttpResponse::builder()
+
.status(StatusCode::NOT_FOUND)
+
.body(Vec::new())
+
.unwrap(),
+
);
let issuer = url::Url::parse("https://issuer").unwrap();
-
let err = super::resolve_authorization_server(&client, &issuer).await.unwrap_err();
+
let err = super::resolve_authorization_server(&client, &issuer)
+
.await
+
.unwrap_err();
matches!(err, ResolverError::HttpStatus(StatusCode::NOT_FOUND));
}
#[tokio::test]
async fn authorization_server_bad_json() {
let client = MockHttp::default();
-
*client.next.lock().await = Some(HttpResponse::builder().status(StatusCode::OK).body(b"{not json}".to_vec()).unwrap());
+
*client.next.lock().await = Some(
+
HttpResponse::builder()
+
.status(StatusCode::OK)
+
.body(b"{not json}".to_vec())
+
.unwrap(),
+
);
let issuer = url::Url::parse("https://issuer").unwrap();
-
let err = super::resolve_authorization_server(&client, &issuer).await.unwrap_err();
+
let err = super::resolve_authorization_server(&client, &issuer)
+
.await
+
.unwrap_err();
matches!(err, ResolverError::SerdeJson(_));
}
#[test]
fn issuer_equivalence_rules() {
-
assert!(super::issuer_equivalent("https://issuer", "https://issuer/"));
-
assert!(super::issuer_equivalent("https://issuer:443/", "https://issuer/"));
-
assert!(!super::issuer_equivalent("http://issuer/", "https://issuer/"));
-
assert!(!super::issuer_equivalent("https://issuer/foo", "https://issuer/"));
-
assert!(!super::issuer_equivalent("https://issuer/?q=1", "https://issuer/"));
+
assert!(super::issuer_equivalent(
+
"https://issuer",
+
"https://issuer/"
+
));
+
assert!(super::issuer_equivalent(
+
"https://issuer:443/",
+
"https://issuer/"
+
));
+
assert!(!super::issuer_equivalent(
+
"http://issuer/",
+
"https://issuer/"
+
));
+
assert!(!super::issuer_equivalent(
+
"https://issuer/foo",
+
"https://issuer/"
+
));
+
assert!(!super::issuer_equivalent(
+
"https://issuer/?q=1",
+
"https://issuer/"
+
));
}
}
+9
crates/jacquard-oauth/src/session.rs
···
pending: DashMap::new(),
}
}
+
+
pub fn new_shared(store: Arc<S>, client: Arc<T>, client_data: ClientData<'static>) -> Self {
+
Self {
+
store,
+
client,
+
client_data,
+
pending: DashMap::new(),
+
}
+
}
}
impl<T, S> SessionRegistry<T, S>
+4 -4
crates/jacquard-oauth/src/types/request.rs
···
use jacquard_common::{CowStr, IntoStatic};
use serde::{Deserialize, Serialize};
-
#[derive(Serialize, Deserialize)]
+
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum AuthorizationResponseType {
Code,
···
IdToken,
}
-
#[derive(Serialize, Deserialize)]
+
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum AuthorizationResponseMode {
Query,
···
FormPost,
}
-
#[derive(Serialize, Deserialize)]
+
#[derive(Serialize, Deserialize, Debug)]
pub enum AuthorizationCodeChallengeMethod {
S256,
#[serde(rename = "plain")]
Plain,
}
-
#[derive(Serialize, Deserialize)]
+
#[derive(Serialize, Deserialize, Debug)]
pub struct ParParameters<'a> {
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
pub response_type: AuthorizationResponseType,
+2 -2
crates/jacquard/Cargo.toml
···
api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"]
dns = ["jacquard-identity/dns"]
fancy = ["miette/fancy"]
-
loopback = ["dep:rouille"]
+
# Propagate loopback to oauth (server + browser helper)
+
loopback = ["jacquard-oauth/loopback", "jacquard-oauth/browser-open"]
[lib]
name = "jacquard"
···
jose-jwk = { workspace = true, features = ["p256"] }
p256 = { workspace = true, features = ["ecdsa"] }
rand_core.workspace = true
-
rouille = { version = "3.6.2", optional = true }
+39 -30
crates/jacquard/src/main.rs
···
use clap::Parser;
use jacquard::CowStr;
-
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
-
use jacquard::client::credential_session::{CredentialSession, SessionKey};
-
use jacquard::client::{AtpSession, MemorySessionStore};
+
use jacquard::client::{Agent, FileAuthStore};
use jacquard::types::xrpc::XrpcClient;
-
use jacquard_identity::slingshot_resolver_default;
+
use jacquard_api::app_bsky::feed::get_timeline::GetTimeline;
+
use jacquard_oauth::atproto::AtprotoClientMetadata;
+
use jacquard_oauth::client::OAuthClient;
+
#[cfg(feature = "loopback")]
+
use jacquard_oauth::loopback::LoopbackConfig;
+
use jacquard_oauth::scopes::Scope;
use miette::IntoDiagnostic;
-
use std::sync::Arc;
#[derive(Parser, Debug)]
-
#[command(author, version, about = "Jacquard - AT Protocol client demo")]
+
#[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")]
struct Args {
-
/// Username/handle (e.g., alice.bsky.social) or DID
-
#[arg(short, long)]
-
username: CowStr<'static>,
+
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
+
input: CowStr<'static>,
-
/// App password
-
#[arg(short, long)]
-
password: CowStr<'static>,
+
/// Path to auth store file (will be created if missing)
+
#[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
+
store: String,
}
+
#[tokio::main]
async fn main() -> miette::Result<()> {
let args = Args::parse();
-
// Resolver + in-memory store
-
let resolver = Arc::new(slingshot_resolver_default());
-
let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
-
let client = Arc::new(resolver.clone());
-
let session = CredentialSession::new(store, client);
+
// File-backed auth store shared by OAuthClient and session registry
+
let store = FileAuthStore::new(&args.store);
+
+
// Minimal localhost client metadata (redirect_uris get set by loopback helper)
+
let client_data = jacquard_oauth::session::ClientData {
+
keyset: None,
+
// scopes: include atproto; redirect_uris will be populated by the loopback helper
+
config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])),
+
};
+
+
// Build an OAuth client and run loopback flow
+
let oauth = OAuthClient::new(store, client_data);
-
let _ = session
-
.login(
-
args.username.clone(),
-
args.password.clone(),
-
None,
-
None,
-
None,
+
#[cfg(feature = "loopback")]
+
let session = oauth
+
.login_with_local_server(
+
args.input.clone(),
+
Default::default(),
+
LoopbackConfig::default(),
)
.await
.into_diagnostic()?;
-
// Fetch timeline
-
let timeline = session
+
#[cfg(not(feature = "loopback"))]
+
compile_error!("loopback feature must be enabled to run this example");
+
+
// Wrap in Agent and call a simple resource endpoint
+
let agent: Agent<_> = Agent::from(session);
+
let timeline = agent
.send(&GetTimeline::new().limit(5).build())
.await
.into_diagnostic()?
-
.into_output()
-
.into_diagnostic()?;
-
-
println!("\ntimeline ({} posts):", timeline.feed.len());
+
.into_output()?;
for (i, post) in timeline.feed.iter().enumerate() {
println!("\n{}. by {}", i + 1, post.post.author.handle);
println!(