+78
Cargo.lock
+78
Cargo.lock
···
215
215
]
216
216
217
217
[[package]]
218
+
name = "atomic-polyfill"
219
+
version = "1.0.3"
220
+
source = "registry+https://github.com/rust-lang/crates.io-index"
221
+
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
222
+
dependencies = [
223
+
"critical-section",
224
+
]
225
+
226
+
[[package]]
218
227
name = "atomic-waker"
219
228
version = "1.1.2"
220
229
source = "registry+https://github.com/rust-lang/crates.io-index"
···
745
754
]
746
755
747
756
[[package]]
757
+
name = "cobs"
758
+
version = "0.3.0"
759
+
source = "registry+https://github.com/rust-lang/crates.io-index"
760
+
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
761
+
dependencies = [
762
+
"thiserror 2.0.17",
763
+
]
764
+
765
+
[[package]]
748
766
name = "color_quant"
749
767
version = "1.1.0"
750
768
source = "registry+https://github.com/rust-lang/crates.io-index"
···
881
899
]
882
900
883
901
[[package]]
902
+
name = "critical-section"
903
+
version = "1.2.0"
904
+
source = "registry+https://github.com/rust-lang/crates.io-index"
905
+
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
906
+
907
+
[[package]]
884
908
name = "crossbeam-channel"
885
909
version = "0.5.15"
886
910
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1232
1256
dependencies = [
1233
1257
"serde",
1234
1258
]
1259
+
1260
+
[[package]]
1261
+
name = "embedded-io"
1262
+
version = "0.4.0"
1263
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1264
+
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
1265
+
1266
+
[[package]]
1267
+
name = "embedded-io"
1268
+
version = "0.6.1"
1269
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1270
+
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
1235
1271
1236
1272
[[package]]
1237
1273
name = "encode_unicode"
···
1722
1758
]
1723
1759
1724
1760
[[package]]
1761
+
name = "hash32"
1762
+
version = "0.2.1"
1763
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1764
+
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
1765
+
dependencies = [
1766
+
"byteorder",
1767
+
]
1768
+
1769
+
[[package]]
1725
1770
name = "hashbrown"
1726
1771
version = "0.12.3"
1727
1772
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1738
1783
version = "0.16.0"
1739
1784
source = "registry+https://github.com/rust-lang/crates.io-index"
1740
1785
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
1786
+
1787
+
[[package]]
1788
+
name = "heapless"
1789
+
version = "0.7.17"
1790
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1791
+
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
1792
+
dependencies = [
1793
+
"atomic-polyfill",
1794
+
"hash32",
1795
+
"rustc_version",
1796
+
"serde",
1797
+
"spin 0.9.8",
1798
+
"stable_deref_trait",
1799
+
]
1741
1800
1742
1801
[[package]]
1743
1802
name = "heck"
···
2307
2366
"miette",
2308
2367
"rustversion",
2309
2368
"serde",
2369
+
"serde_bytes",
2310
2370
"serde_ipld_dagcbor",
2311
2371
"thiserror 2.0.17",
2312
2372
"unicode-segmentation",
···
2367
2427
"n0-future",
2368
2428
"ouroboros",
2369
2429
"p256",
2430
+
"postcard",
2370
2431
"rand 0.9.2",
2371
2432
"regex",
2372
2433
"regex-lite",
2373
2434
"reqwest",
2374
2435
"serde",
2436
+
"serde_bytes",
2375
2437
"serde_html_form",
2376
2438
"serde_ipld_dagcbor",
2377
2439
"serde_json",
···
3487
3549
]
3488
3550
3489
3551
[[package]]
3552
+
name = "postcard"
3553
+
version = "1.1.3"
3554
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3555
+
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
3556
+
dependencies = [
3557
+
"cobs",
3558
+
"embedded-io 0.4.0",
3559
+
"embedded-io 0.6.1",
3560
+
"heapless",
3561
+
"serde",
3562
+
]
3563
+
3564
+
[[package]]
3490
3565
name = "potential_utf"
3491
3566
version = "0.1.3"
3492
3567
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4585
4660
version = "0.9.8"
4586
4661
source = "registry+https://github.com/rust-lang/crates.io-index"
4587
4662
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
4663
+
dependencies = [
4664
+
"lock_api",
4665
+
]
4588
4666
4589
4667
[[package]]
4590
4668
name = "spin"
+1
crates/jacquard-api/Cargo.toml
+1
crates/jacquard-api/Cargo.toml
+3
crates/jacquard-common/Cargo.toml
+3
crates/jacquard-common/Cargo.toml
···
45
45
thiserror.workspace = true
46
46
url.workspace = true
47
47
http.workspace = true
48
+
serde_bytes = "0.11"
49
+
48
50
49
51
reqwest = { workspace = true, optional = true, features = ["json", "charset", "gzip", "stream"] }
50
52
serde_ipld_dagcbor.workspace = true
···
58
60
tokio-tungstenite-wasm = { version = "0.4", features = ["rustls-tls-native-roots"], optional = true }
59
61
ciborium = {version = "0.2.0", optional = true }
60
62
zstd = { version = "0.13", optional = true }
63
+
postcard = { version = "1.1.3", features = ["use-std"] }
61
64
62
65
[target.'cfg(target_family = "wasm")'.dependencies]
63
66
getrandom = { version = "0.3.4", features = ["wasm_js"] }
+6
-4
crates/jacquard-common/src/lib.rs
+6
-4
crates/jacquard-common/src/lib.rs
···
219
219
/// Baseline fundamental AT Protocol data types.
220
220
pub mod types;
221
221
// XRPC protocol types and traits
222
-
pub mod xrpc;
222
+
pub mod opt_serde_bytes_helper;
223
+
pub mod serde_bytes_helper;
223
224
/// Stream abstractions for HTTP request/response bodies.
224
225
#[cfg(feature = "streaming")]
225
226
pub mod stream;
227
+
pub mod xrpc;
226
228
227
229
#[cfg(feature = "streaming")]
228
-
pub use stream::{ByteStream, ByteSink, StreamError, StreamErrorKind};
230
+
pub use stream::{ByteSink, ByteStream, StreamError, StreamErrorKind};
229
231
230
232
#[cfg(feature = "streaming")]
231
233
pub use xrpc::StreamingResponse;
···
238
240
239
241
#[cfg(feature = "websocket")]
240
242
pub use websocket::{
241
-
tungstenite_client::TungsteniteClient, CloseCode, CloseFrame, WebSocketClient,
242
-
WebSocketConnection, WsMessage, WsSink, WsStream, WsText,
243
+
CloseCode, CloseFrame, WebSocketClient, WebSocketConnection, WsMessage, WsSink, WsStream,
244
+
WsText, tungstenite_client::TungsteniteClient,
243
245
};
244
246
245
247
pub use types::value::*;
+25
crates/jacquard-common/src/opt_serde_bytes_helper.rs
+25
crates/jacquard-common/src/opt_serde_bytes_helper.rs
···
1
+
//! Custom serde helpers for bytes::Bytes using serde_bytes
2
+
3
+
use bytes::Bytes;
4
+
use serde::{Deserializer, Serializer};
5
+
6
+
/// Serialize Bytes as a CBOR byte string
7
+
pub fn serialize<S>(bytes: &Option<Bytes>, serializer: S) -> Result<S::Ok, S::Error>
8
+
where
9
+
S: Serializer,
10
+
{
11
+
if let Some(bytes) = bytes {
12
+
serde_bytes::serialize(bytes.as_ref(), serializer)
13
+
} else {
14
+
serializer.serialize_none()
15
+
}
16
+
}
17
+
18
+
/// Deserialize Bytes from a CBOR byte string
19
+
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Bytes>, D::Error>
20
+
where
21
+
D: Deserializer<'de>,
22
+
{
23
+
let vec: Option<Vec<u8>> = serde_bytes::deserialize(deserializer)?;
24
+
Ok(vec.map(Bytes::from))
25
+
}
+21
crates/jacquard-common/src/serde_bytes_helper.rs
+21
crates/jacquard-common/src/serde_bytes_helper.rs
···
1
+
//! Custom serde helpers for bytes::Bytes using serde_bytes
2
+
3
+
use bytes::Bytes;
4
+
use serde::{Deserializer, Serializer};
5
+
6
+
/// Serialize Bytes as a CBOR byte string
7
+
pub fn serialize<S>(bytes: &Bytes, serializer: S) -> Result<S::Ok, S::Error>
8
+
where
9
+
S: Serializer,
10
+
{
11
+
serde_bytes::serialize(bytes.as_ref(), serializer)
12
+
}
13
+
14
+
/// Deserialize Bytes from a CBOR byte string
15
+
pub fn deserialize<'de, D>(deserializer: D) -> Result<Bytes, D::Error>
16
+
where
17
+
D: Deserializer<'de>,
18
+
{
19
+
let vec: Vec<u8> = serde_bytes::deserialize(deserializer)?;
20
+
Ok(Bytes::from(vec))
21
+
}
+1
crates/jacquard-common/src/types/language.rs
+1
crates/jacquard-common/src/types/language.rs
+23
-1
crates/jacquard-common/src/types/value.rs
+23
-1
crates/jacquard-common/src/types/value.rs
···
5
5
use bytes::Bytes;
6
6
use ipld_core::ipld::Ipld;
7
7
use smol_str::{SmolStr, ToSmolStr};
8
-
use std::collections::BTreeMap;
8
+
use std::{collections::BTreeMap, convert::Infallible};
9
9
10
10
/// Conversion utilities for Data types
11
11
pub mod convert;
···
854
854
T: serde::Deserialize<'de> + IntoStatic,
855
855
{
856
856
T::deserialize(json).map(IntoStatic::into_static)
857
+
}
858
+
859
+
/// Deserialize a typed value from cbor bytes
860
+
///
861
+
/// Returns an owned version, will allocate
862
+
pub fn from_cbor<'de, T>(
863
+
cbor: &'de [u8],
864
+
) -> Result<<T as IntoStatic>::Output, serde_ipld_dagcbor::DecodeError<Infallible>>
865
+
where
866
+
T: serde::Deserialize<'de> + IntoStatic,
867
+
{
868
+
serde_ipld_dagcbor::from_slice::<T>(cbor).map(|d| d.into_static())
869
+
}
870
+
871
+
/// Deserialize a typed value from postcard bytes
872
+
///
873
+
/// Returns an owned version, will allocate
874
+
pub fn from_postcard<'de, T>(bytes: &'de [u8]) -> Result<<T as IntoStatic>::Output, postcard::Error>
875
+
where
876
+
T: serde::Deserialize<'de> + IntoStatic,
877
+
{
878
+
postcard::from_bytes::<T>(bytes).map(|d| d.into_static())
857
879
}
858
880
859
881
/// Deserialize a typed value from a `RawData` value
+2
-2
crates/jacquard-identity/Cargo.toml
+2
-2
crates/jacquard-identity/Cargo.toml
···
15
15
[features]
16
16
dns = ["dep:hickory-resolver"]
17
17
tracing = ["dep:tracing"]
18
-
streaming = ["jacquard-common/streaming", "dep:n0-future"]
18
+
streaming = ["jacquard-common/streaming"]
19
19
cache = ["dep:mini-moka"]
20
20
21
21
[dependencies]
···
36
36
serde_html_form.workspace = true
37
37
urlencoding.workspace = true
38
38
tracing = { workspace = true, optional = true }
39
-
n0-future = { workspace = true, optional = true }
39
+
n0-future.workspace = true
40
40
mini-moka = { version = "0.10", path = "../mini-moka-vendored", optional = true }
41
41
# mini-moka = { version = "0.10", optional = true }
42
42
+64
-12
crates/jacquard-identity/src/lib.rs
+64
-12
crates/jacquard-identity/src/lib.rs
···
390
390
self
391
391
}
392
392
393
+
/// Set the HTTP request timeout. Pass `None` to disable timeout.
394
+
pub fn with_request_timeout(mut self, timeout: Option<n0_future::time::Duration>) -> Self {
395
+
self.opts.request_timeout = timeout;
396
+
self
397
+
}
398
+
393
399
#[cfg(feature = "cache")]
394
400
/// Enable caching with default configuration
395
401
pub fn with_cache(mut self) -> Self {
···
849
855
}
850
856
851
857
impl HttpClient for JacquardResolver {
858
+
type Error = IdentityError;
859
+
852
860
async fn send_http(
853
861
&self,
854
862
request: http::Request<Vec<u8>>,
855
863
) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
856
-
self.http.send_http(request).await
864
+
match self.opts.request_timeout {
865
+
Some(duration) => n0_future::time::timeout(duration, self.http.send_http(request))
866
+
.await
867
+
.map_err(|_| IdentityError::timeout())?
868
+
.map_err(IdentityError::transport),
869
+
None => self
870
+
.http
871
+
.send_http(request)
872
+
.await
873
+
.map_err(IdentityError::transport),
874
+
}
857
875
}
858
-
859
-
type Error = reqwest::Error;
860
876
}
861
877
862
878
#[cfg(feature = "streaming")]
863
879
impl jacquard_common::http_client::HttpClientExt for JacquardResolver {
864
880
/// Send HTTP request and return streaming response
865
-
fn send_http_streaming(
881
+
async fn send_http_streaming(
866
882
&self,
867
883
request: http::Request<Vec<u8>>,
868
-
) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> {
869
-
self.http.send_http_streaming(request)
884
+
) -> Result<http::Response<ByteStream>, Self::Error> {
885
+
match self.opts.request_timeout {
886
+
Some(duration) => {
887
+
n0_future::time::timeout(duration, self.http.send_http_streaming(request))
888
+
.await
889
+
.map_err(|_| IdentityError::timeout())?
890
+
.map_err(IdentityError::transport)
891
+
}
892
+
None => self
893
+
.http
894
+
.send_http_streaming(request)
895
+
.await
896
+
.map_err(IdentityError::transport),
897
+
}
870
898
}
871
899
872
900
/// Send HTTP request with streaming body and receive streaming response
873
901
#[cfg(not(target_arch = "wasm32"))]
874
-
fn send_http_bidirectional<S>(
902
+
async fn send_http_bidirectional<S>(
875
903
&self,
876
904
parts: http::request::Parts,
877
905
body: S,
878
-
) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>>
906
+
) -> Result<http::Response<ByteStream>, Self::Error>
879
907
where
880
908
S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>>
881
909
+ Send
882
910
+ 'static,
883
911
{
884
-
self.http.send_http_bidirectional(parts, body)
912
+
match self.opts.request_timeout {
913
+
Some(duration) => {
914
+
n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body))
915
+
.await
916
+
.map_err(|_| IdentityError::timeout())?
917
+
.map_err(IdentityError::transport)
918
+
}
919
+
None => self
920
+
.http
921
+
.send_http_bidirectional(parts, body)
922
+
.await
923
+
.map_err(IdentityError::transport),
924
+
}
885
925
}
886
926
887
927
/// Send HTTP request with streaming body and receive streaming response (WASM)
888
928
#[cfg(target_arch = "wasm32")]
889
-
fn send_http_bidirectional<S>(
929
+
async fn send_http_bidirectional<S>(
890
930
&self,
891
931
parts: http::request::Parts,
892
932
body: S,
893
-
) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>>
933
+
) -> Result<http::Response<ByteStream>, Self::Error>
894
934
where
895
935
S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> + 'static,
896
936
{
897
-
self.http.send_http_bidirectional(parts, body)
937
+
match self.opts.request_timeout {
938
+
Some(duration) => {
939
+
n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body))
940
+
.await
941
+
.map_err(|_| IdentityError::timeout())?
942
+
.map_err(IdentityError::transport)
943
+
}
944
+
None => self
945
+
.http
946
+
.send_http_bidirectional(parts, body)
947
+
.await
948
+
.map_err(IdentityError::transport),
949
+
}
898
950
}
899
951
}
900
952
+17
crates/jacquard-identity/src/resolver.rs
+17
crates/jacquard-identity/src/resolver.rs
···
20
20
use jacquard_common::types::uri::Uri;
21
21
use jacquard_common::types::value::{AtDataError, Data};
22
22
use jacquard_common::{CowStr, IntoStatic, smol_str};
23
+
use n0_future::time::Duration;
23
24
use smol_str::SmolStr;
24
25
use std::collections::BTreeMap;
25
26
use std::marker::Sync;
···
219
220
pub validate_doc_id: bool,
220
221
/// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
221
222
pub public_fallback_for_handle: bool,
223
+
/// HTTP request timeout. Default: 10 seconds. Set to None to disable.
224
+
pub request_timeout: Option<Duration>,
222
225
}
223
226
224
227
impl Default for ResolverOptions {
···
250
253
.did_order(did_order)
251
254
.validate_doc_id(true)
252
255
.public_fallback_for_handle(true)
256
+
.request_timeout(Duration::from_secs(20))
253
257
.build()
254
258
}
255
259
}
···
538
542
)]
539
543
Transport,
540
544
545
+
/// Request timeout
546
+
#[error("request timed out")]
547
+
#[diagnostic(
548
+
code(jacquard::identity::timeout),
549
+
help("the server took too long to respond")
550
+
)]
551
+
Timeout,
552
+
541
553
/// HTTP status error
542
554
#[error("HTTP {0}")]
543
555
#[diagnostic(
···
651
663
/// Create a transport error
652
664
pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self {
653
665
Self::new(IdentityErrorKind::Transport, Some(Box::new(source)))
666
+
}
667
+
668
+
/// Create a timeout error
669
+
pub fn timeout() -> Self {
670
+
Self::new(IdentityErrorKind::Timeout, None)
654
671
}
655
672
656
673
/// Create an HTTP status error
+9
-1
crates/jacquard-lexicon/src/codegen/structs.rs
+9
-1
crates/jacquard-lexicon/src/codegen/structs.rs
···
323
323
field_name: &str,
324
324
field_type: &LexObjectProperty<'static>,
325
325
is_required: bool,
326
-
is_builder: bool,
326
+
_is_builder: bool,
327
327
) -> Result<TokenStream> {
328
328
if field_name.is_empty() {
329
329
eprintln!(
···
368
368
// Add serde(borrow) to all fields with lifetimes
369
369
if needs_lifetime {
370
370
attrs.push(quote! { #[serde(borrow)] });
371
+
}
372
+
373
+
if matches!(field_type, LexObjectProperty::Bytes(_)) {
374
+
if is_required {
375
+
attrs.push(quote! { #[serde(with = "jacquard_common::serde_bytes_helper")] });
376
+
} else {
377
+
attrs.push(quote! {#[serde(with = "jacquard_common::opt_serde_bytes_helper")] });
378
+
}
371
379
}
372
380
373
381
Ok(quote! {
+6
-1
crates/jacquard-lexicon/src/error.rs
+6
-1
crates/jacquard-lexicon/src/error.rs
···
76
76
)]
77
77
Unsupported {
78
78
/// Description of the unsupported feature
79
+
#[allow(unused)]
79
80
feature: String,
80
81
/// NSID of lexicon containing the feature
82
+
#[allow(unused)]
81
83
lexicon_nsid: String,
82
84
/// Optional suggestion for workaround
85
+
#[allow(unused)]
83
86
suggestion: Option<String>,
84
87
},
85
88
···
87
90
#[error("Name collision: {name}")]
88
91
#[diagnostic(
89
92
code(lexicon::name_collision),
90
-
help("Multiple types would generate the same Rust identifier. Module paths will disambiguate.")
93
+
help(
94
+
"Multiple types would generate the same Rust identifier. Module paths will disambiguate."
95
+
)
91
96
)]
92
97
NameCollision {
93
98
/// The colliding name
+23
-8
crates/jacquard-oauth/src/atproto.rs
+23
-8
crates/jacquard-oauth/src/atproto.rs
···
152
152
#[derive(serde::Serialize)]
153
153
struct Parameters<'a> {
154
154
#[serde(skip_serializing_if = "Option::is_none")]
155
-
redirect_uri: Option<Vec<CowStr<'a>>>,
155
+
redirect_uri: Option<Vec<Url>>,
156
156
#[serde(skip_serializing_if = "Option::is_none")]
157
157
scope: Option<CowStr<'a>>,
158
158
}
159
159
let query = serde_html_form::to_string(Parameters {
160
-
redirect_uri: redirect_uris.as_ref().map(|u| {
161
-
u.iter()
162
-
.map(|u| u.as_str().trim_end_matches("/").to_cowstr().into_static())
163
-
.collect()
164
-
}),
160
+
redirect_uri: redirect_uris.clone(),
165
161
scope: scopes
166
162
.as_ref()
167
163
.map(|s| Scope::serialize_multiple(s.as_slice())),
···
196
192
keyset: &Option<Keyset>,
197
193
) -> Result<OAuthClientMetadata<'m>> {
198
194
// For non-loopback clients, require a keyset/JWKs.
199
-
// let is_loopback =
200
-
// metadata.client_id.scheme() == "http" && metadata.client_id.host_str() == Some("localhost");
195
+
let is_loopback =
196
+
metadata.client_id.scheme() == "http" && metadata.client_id.host_str() == Some("localhost");
197
+
let application_type = if is_loopback {
198
+
Some(CowStr::new_static("native"))
199
+
} else {
200
+
Some(CowStr::new_static("web"))
201
+
};
201
202
// if !is_loopback && keyset.is_none() {
202
203
// return Err(Error::EmptyJwks);
203
204
// }
···
234
235
client_id: client_id.to_cowstr().into_static(),
235
236
client_uri,
236
237
redirect_uris,
238
+
application_type,
237
239
token_endpoint_auth_method: Some(auth_method.into()),
238
240
grant_types: if keyset.is_some() {
239
241
Some(metadata.grant_types.into_iter().map(|v| v.into()).collect())
240
242
} else {
241
243
None
242
244
},
245
+
response_types: vec!["code".to_cowstr()],
243
246
scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())),
244
247
dpop_bound_access_tokens: Some(true),
245
248
jwks_uri,
···
287
290
CowStr::new_static("http://127.0.0.1"),
288
291
CowStr::new_static("http://[::1]"),
289
292
],
293
+
application_type: Some(CowStr::new_static("native")),
290
294
scope: Some(CowStr::new_static("atproto")),
291
295
grant_types: None,
296
+
response_types: vec!["code".to_cowstr()],
292
297
token_endpoint_auth_method: Some(AuthMethod::None.into()),
293
298
dpop_bound_access_tokens: Some(true),
294
299
jwks_uri: None,
···
333
338
scope: Some(CowStr::new_static(
334
339
"account:email atproto transition:generic"
335
340
)),
341
+
application_type: Some(CowStr::new_static("native")),
336
342
grant_types: None,
343
+
response_types: vec!["code".to_cowstr()],
337
344
token_endpoint_auth_method: Some(AuthMethod::None.into()),
338
345
dpop_bound_access_tokens: Some(true),
339
346
jwks_uri: None,
···
365
372
client_id: CowStr::new_static(
366
373
"http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1"
367
374
),
375
+
application_type: Some(CowStr::new_static("native")),
368
376
client_uri: None,
369
377
redirect_uris: vec![CowStr::new_static("http://127.0.0.1")],
370
378
scope: Some(CowStr::new_static("atproto")),
371
379
grant_types: None,
380
+
response_types: vec!["code".to_cowstr()],
372
381
token_endpoint_auth_method: Some(AuthMethod::None.into()),
373
382
dpop_bound_access_tokens: Some(true),
374
383
jwks_uri: None,
···
400
409
redirect_uris: vec![CowStr::new_static("http://127.0.0.1:8000")],
401
410
scope: Some(CowStr::new_static("atproto")),
402
411
grant_types: None,
412
+
application_type: Some(CowStr::new_static("native")),
413
+
response_types: vec!["code".to_cowstr()],
403
414
token_endpoint_auth_method: Some(AuthMethod::None.into()),
404
415
dpop_bound_access_tokens: Some(true),
405
416
jwks_uri: None,
···
431
442
redirect_uris: vec![CowStr::new_static("http://127.0.0.1")],
432
443
scope: Some(CowStr::new_static("atproto")),
433
444
grant_types: None,
445
+
application_type: Some(CowStr::new_static("native")),
446
+
response_types: vec!["code".to_cowstr()],
434
447
token_endpoint_auth_method: Some(AuthMethod::None.into()),
435
448
dpop_bound_access_tokens: Some(true),
436
449
jwks_uri: None,
···
484
497
client_id: CowStr::new_static("https://example.com/client_metadata.json"),
485
498
client_uri: Some(CowStr::new_static("https://example.com")),
486
499
redirect_uris: vec![CowStr::new_static("https://example.com/callback")],
500
+
application_type: Some(CowStr::new_static("web")),
487
501
scope: Some(CowStr::new_static("atproto")),
488
502
grant_types: Some(vec![CowStr::new_static("authorization_code")]),
489
503
token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()),
490
504
dpop_bound_access_tokens: Some(true),
505
+
response_types: vec!["code".to_cowstr()],
491
506
jwks_uri: None,
492
507
jwks: Some(keyset.public_jwks()),
493
508
token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")),
+11
-3
crates/jacquard-oauth/src/loopback.rs
+11
-3
crates/jacquard-oauth/src/loopback.rs
···
1
1
#![cfg(feature = "loopback")]
2
2
3
3
use crate::{
4
+
atproto::AtprotoClientMetadata,
4
5
authstore::ClientAuthStore,
5
6
client::OAuthClient,
6
7
dpop::DpopExt,
···
121
122
))
122
123
.unwrap();
123
124
124
-
let mut client_data = self.registry.client_data.clone();
125
-
// Ensure the redirect URI is set correctly for the loopback server
126
-
client_data.config.redirect_uris = vec![redirect];
125
+
let scopes = if opts.scopes.is_empty() {
126
+
Some(self.registry.client_data.config.scopes.clone())
127
+
} else {
128
+
Some(opts.scopes.clone().into_static())
129
+
};
130
+
131
+
let client_data = crate::session::ClientData {
132
+
keyset: self.registry.client_data.keyset.clone(),
133
+
config: AtprotoClientMetadata::new_localhost(Some(vec![redirect.clone()]), scopes),
134
+
};
127
135
// Build client using store and resolver
128
136
let flow_client = OAuthClient::new_with_shared(
129
137
self.registry.store.clone(),
+19
crates/jacquard-oauth/src/request.rs
+19
crates/jacquard-oauth/src/request.rs
···
302
302
pub fn atproto(source: impl std::error::Error + Send + Sync + 'static) -> Self {
303
303
Self::new(RequestErrorKind::Atproto, Some(Box::new(source)))
304
304
}
305
+
306
+
/// Returns true if this error indicates permanent auth failure
307
+
/// (token revoked, refresh_token expired, etc.)
308
+
///
309
+
/// When this returns true, the session should be cleared from storage
310
+
/// rather than retried.
311
+
pub fn is_permanent(&self) -> bool {
312
+
match &self.kind {
313
+
RequestErrorKind::NoRefreshToken => true,
314
+
RequestErrorKind::HttpStatusWithBody { body, .. } => {
315
+
body.get("error")
316
+
.and_then(|e| e.as_str())
317
+
.is_some_and(|e| matches!(e, "invalid_grant" | "access_denied"))
318
+
}
319
+
_ => false,
320
+
}
321
+
}
305
322
}
306
323
307
324
// From impls for common error types
···
939
956
redirect_uris: vec![CowStr::new_static("https://client/cb")],
940
957
scope: Some(CowStr::from("atproto")),
941
958
grant_types: None,
959
+
response_types: vec![CowStr::new_static("code")],
960
+
application_type: Some(CowStr::new_static("web")),
942
961
token_endpoint_auth_method: Some(CowStr::from("none")),
943
962
dpop_bound_access_tokens: None,
944
963
jwks_uri: None,
+1
crates/jacquard-oauth/src/resolver.rs
+1
crates/jacquard-oauth/src/resolver.rs
+45
-6
crates/jacquard-oauth/src/session.rs
+45
-6
crates/jacquard-oauth/src/session.rs
···
1
1
use std::sync::Arc;
2
2
3
+
use chrono::TimeDelta;
4
+
3
5
use crate::{
4
6
atproto::{AtprotoClientMetadata, atproto_client_metadata},
5
7
authstore::ClientAuthStore,
···
295
297
#[error("session does not exist")]
296
298
#[diagnostic(code(jacquard_oauth::session::not_found))]
297
299
SessionNotFound,
300
+
#[error("session refresh failed permanently")]
301
+
#[diagnostic(
302
+
code(jacquard_oauth::session::refresh_failed),
303
+
help("the session has been cleared - user must re-authenticate")
304
+
)]
305
+
RefreshFailed(#[source] crate::request::RequestError),
306
+
}
307
+
308
+
impl Error {
309
+
/// Returns true if this error indicates a permanent auth failure
310
+
/// where the user needs to re-authenticate.
311
+
pub fn is_permanent(&self) -> bool {
312
+
match self {
313
+
Error::RefreshFailed(_) => true,
314
+
Error::SessionNotFound => true,
315
+
Error::ServerAgent(e) => e.is_permanent(),
316
+
Error::Store(_) => false,
317
+
}
318
+
}
298
319
}
299
320
300
321
pub struct SessionRegistry<T, S>
···
351
372
.clone();
352
373
let _guard = lock.lock().await;
353
374
354
-
let mut session = self
375
+
let session = self
355
376
.store
356
377
.get_session(did, session_id)
357
378
.await?
358
379
.ok_or(Error::SessionNotFound)?;
380
+
381
+
// Check if token is still valid with a 60-second buffer before expiry.
382
+
// This triggers proactive refresh before the token actually expires,
383
+
// avoiding the race condition where a token expires mid-request.
384
+
const EXPIRY_BUFFER_SECS: i64 = 60;
359
385
if let Some(expires_at) = &session.token_set.expires_at {
360
-
if expires_at > &Datetime::now() {
386
+
let now_with_buffer = Datetime::now()
387
+
.as_ref()
388
+
.checked_add_signed(TimeDelta::seconds(EXPIRY_BUFFER_SECS))
389
+
.map(Datetime::new)
390
+
.unwrap_or_else(Datetime::now);
391
+
if expires_at > &now_with_buffer {
361
392
return Ok(session);
362
393
}
363
394
}
364
395
let metadata =
365
396
OAuthMetadata::new(self.client.as_ref(), &self.client_data, &session).await?;
366
-
session = refresh(self.client.as_ref(), session, &metadata).await?;
367
-
self.store.upsert_session(session.clone()).await?;
368
-
369
-
Ok(session)
397
+
match refresh(self.client.as_ref(), session, &metadata).await {
398
+
Ok(refreshed) => {
399
+
self.store.upsert_session(refreshed.clone()).await?;
400
+
Ok(refreshed)
401
+
}
402
+
Err(e) if e.is_permanent() => {
403
+
// Session is permanently dead - clean it up
404
+
let _ = self.store.delete_session(did, session_id).await;
405
+
Err(Error::RefreshFailed(e))
406
+
}
407
+
Err(e) => Err(Error::ServerAgent(e)),
408
+
}
370
409
}
371
410
pub async fn get(
372
411
&self,
+1
-1
crates/jacquard-oauth/src/types.rs
+1
-1
crates/jacquard-oauth/src/types.rs
+5
crates/jacquard-oauth/src/types/client_metadata.rs
+5
crates/jacquard-oauth/src/types/client_metadata.rs
···
13
13
#[serde(borrow)]
14
14
pub scope: Option<CowStr<'c>>,
15
15
#[serde(skip_serializing_if = "Option::is_none")]
16
+
pub application_type: Option<CowStr<'c>>,
17
+
#[serde(skip_serializing_if = "Option::is_none")]
16
18
pub grant_types: Option<Vec<CowStr<'c>>>,
17
19
#[serde(skip_serializing_if = "Option::is_none")]
18
20
pub token_endpoint_auth_method: Option<CowStr<'c>>,
21
+
pub response_types: Vec<CowStr<'c>>,
19
22
// https://datatracker.ietf.org/doc/html/rfc9449#section-5.2
20
23
#[serde(skip_serializing_if = "Option::is_none")]
21
24
pub dpop_bound_access_tokens: Option<bool>,
···
48
51
client_uri: self.client_uri.into_static(),
49
52
redirect_uris: self.redirect_uris.into_static(),
50
53
scope: self.scope.map(|scope| scope.into_static()),
54
+
application_type: self.application_type.map(|app_type| app_type.into_static()),
51
55
grant_types: self.grant_types.map(|types| types.into_static()),
56
+
response_types: self.response_types.into_static(),
52
57
token_endpoint_auth_method: self
53
58
.token_endpoint_auth_method
54
59
.map(|method| method.into_static()),
+2
-1
crates/jacquard/Cargo.toml
+2
-1
crates/jacquard/Cargo.toml
···
12
12
license.workspace = true
13
13
14
14
[features]
15
-
default = ["api_full", "dns", "loopback", "derive"]
15
+
default = ["api_full", "dns", "loopback", "derive", "cache"]
16
16
derive = ["dep:jacquard-derive"]
17
17
# Minimal API bindings
18
18
api = ["jacquard-api/minimal"]
···
44
44
"dep:n0-future",
45
45
"jacquard-api/streaming",
46
46
]
47
+
cache = ["jacquard-identity/cache"]
47
48
websocket = ["jacquard-common/websocket"]
48
49
zstd = ["jacquard-common/zstd"]
49
50