+34
-4
Cargo.lock
+34
-4
Cargo.lock
···
244
244
245
245
[[package]]
246
246
name = "bon"
247
-
version = "3.7.2"
247
+
version = "3.8.0"
248
248
source = "registry+https://github.com/rust-lang/crates.io-index"
249
-
checksum = "c2529c31017402be841eb45892278a6c21a000c0a17643af326c73a73f83f0fb"
249
+
checksum = "f44aa969f86ffb99e5c2d51f393ec9ed6e9fe2f47b609c917b0071f129854d29"
250
250
dependencies = [
251
251
"bon-macros",
252
252
"rustversion",
···
254
254
255
255
[[package]]
256
256
name = "bon-macros"
257
-
version = "3.7.2"
257
+
version = "3.8.0"
258
258
source = "registry+https://github.com/rust-lang/crates.io-index"
259
-
checksum = "d82020dadcb845a345591863adb65d74fa8dc5c18a0b6d408470e13b7adc7005"
259
+
checksum = "e1e78cd86b6a6515d87392332fd63c4950ed3e50eab54275259a5f59f3666f90"
260
260
dependencies = [
261
261
"darling",
262
262
"ident_case",
···
566
566
]
567
567
568
568
[[package]]
569
+
name = "crossbeam-utils"
570
+
version = "0.8.21"
571
+
source = "registry+https://github.com/rust-lang/crates.io-index"
572
+
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
573
+
574
+
[[package]]
569
575
name = "crunchy"
570
576
version = "0.2.4"
571
577
source = "registry+https://github.com/rust-lang/crates.io-index"
···
655
661
]
656
662
657
663
[[package]]
664
+
name = "dashmap"
665
+
version = "6.1.0"
666
+
source = "registry+https://github.com/rust-lang/crates.io-index"
667
+
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
668
+
dependencies = [
669
+
"cfg-if",
670
+
"crossbeam-utils",
671
+
"hashbrown 0.14.5",
672
+
"lock_api",
673
+
"once_cell",
674
+
"parking_lot_core",
675
+
]
676
+
677
+
[[package]]
658
678
name = "data-encoding"
659
679
version = "2.9.0"
660
680
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1060
1080
version = "0.12.3"
1061
1081
source = "registry+https://github.com/rust-lang/crates.io-index"
1062
1082
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
1083
+
1084
+
[[package]]
1085
+
name = "hashbrown"
1086
+
version = "0.14.5"
1087
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1088
+
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
1063
1089
1064
1090
[[package]]
1065
1091
name = "hashbrown"
···
1631
1657
dependencies = [
1632
1658
"async-trait",
1633
1659
"base64 0.22.1",
1660
+
"bon",
1634
1661
"chrono",
1662
+
"dashmap",
1635
1663
"elliptic-curve",
1636
1664
"http",
1637
1665
"jacquard-common",
···
1641
1669
"p256",
1642
1670
"rand 0.8.5",
1643
1671
"rand_core 0.6.4",
1672
+
"reqwest",
1644
1673
"serde",
1645
1674
"serde_html_form",
1646
1675
"serde_json",
···
1648
1677
"signature",
1649
1678
"smol_str",
1650
1679
"thiserror 2.0.17",
1680
+
"tokio",
1651
1681
"url",
1652
1682
"uuid",
1653
1683
]
+84
-3
crates/jacquard-common/src/cowstr.rs
+84
-3
crates/jacquard-common/src/cowstr.rs
···
1
-
use serde::{Deserialize, Serialize};
2
-
use smol_str::SmolStr;
1
+
use serde::{Deserialize, Serialize, de::DeserializeOwned};
2
+
use smol_str::{SmolStr, ToSmolStr};
3
3
use std::{
4
4
borrow::Cow,
5
5
fmt,
···
62
62
#[inline]
63
63
pub unsafe fn from_utf8_unchecked(s: &'s [u8]) -> Self {
64
64
unsafe { Self::Owned(SmolStr::new(std::str::from_utf8_unchecked(s))) }
65
+
}
66
+
67
+
/// Returns a reference to the underlying string slice.
68
+
#[inline]
69
+
pub fn as_str(&self) -> &str {
70
+
match self {
71
+
CowStr::Borrowed(s) => s,
72
+
CowStr::Owned(s) => s.as_str(),
73
+
}
65
74
}
66
75
}
67
76
···
145
154
}
146
155
}
147
156
157
+
impl From<CowStr<'_>> for SmolStr {
158
+
#[inline]
159
+
fn from(s: CowStr<'_>) -> Self {
160
+
match s {
161
+
CowStr::Borrowed(s) => SmolStr::new(s),
162
+
CowStr::Owned(s) => SmolStr::new(s),
163
+
}
164
+
}
165
+
}
166
+
167
+
impl From<SmolStr> for CowStr<'_> {
168
+
#[inline]
169
+
fn from(s: SmolStr) -> Self {
170
+
CowStr::Owned(s)
171
+
}
172
+
}
173
+
148
174
impl From<CowStr<'_>> for Box<str> {
149
175
#[inline]
150
176
fn from(s: CowStr<'_>) -> Self {
···
257
283
}
258
284
}
259
285
260
-
impl<'de: 'a, 'a> Deserialize<'de> for CowStr<'a> {
286
+
// impl<'de> Deserialize<'de> for CowStr<'_> {
287
+
// #[inline]
288
+
// fn deserialize<D>(deserializer: D) -> Result<CowStr<'static>, D::Error>
289
+
// where
290
+
// D: serde::Deserializer<'de>,
291
+
// {
292
+
// struct CowStrVisitor;
293
+
294
+
// impl<'de> serde::de::Visitor<'de> for CowStrVisitor {
295
+
// type Value = CowStr<'static>;
296
+
297
+
// #[inline]
298
+
// fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
299
+
// write!(formatter, "a string")
300
+
// }
301
+
302
+
// #[inline]
303
+
// fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
304
+
// where
305
+
// E: serde::de::Error,
306
+
// {
307
+
// Ok(CowStr::copy_from_str(v))
308
+
// }
309
+
310
+
// #[inline]
311
+
// fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
312
+
// where
313
+
// E: serde::de::Error,
314
+
// {
315
+
// Ok(v.into())
316
+
// }
317
+
// }
318
+
319
+
// deserializer.deserialize_str(CowStrVisitor)
320
+
// }
321
+
// }
322
+
323
+
impl<'de, 'a, 'b> Deserialize<'de> for CowStr<'a>
324
+
where
325
+
'de: 'a,
326
+
{
261
327
#[inline]
262
328
fn deserialize<D>(deserializer: D) -> Result<CowStr<'a>, D::Error>
263
329
where
···
299
365
}
300
366
301
367
deserializer.deserialize_str(CowStrVisitor)
368
+
}
369
+
}
370
+
371
+
/// Convert to a CowStr.
372
+
pub trait ToCowStr {
373
+
/// Convert to a CowStr.
374
+
fn to_cowstr(&self) -> CowStr<'_>;
375
+
}
376
+
377
+
impl<T> ToCowStr for T
378
+
where
379
+
T: fmt::Display + ?Sized,
380
+
{
381
+
fn to_cowstr(&self) -> CowStr<'_> {
382
+
CowStr::Owned(smol_str::format_smolstr!("{}", self))
302
383
}
303
384
}
304
385
+18
-1
crates/jacquard-common/src/ident_resolver.rs
+18
-1
crates/jacquard-common/src/ident_resolver.rs
···
291
291
/// - PLC directory or Slingshot for `did:plc`
292
292
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
293
293
/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
294
-
#[async_trait::async_trait]
294
+
#[async_trait::async_trait()]
295
295
pub trait IdentityResolver {
296
296
/// Access options for validation decisions in default methods
297
297
fn options(&self) -> &ResolverOptions;
···
360
360
let did = self.resolve_handle(handle).await?;
361
361
let pds = self.pds_for_did(&did).await?;
362
362
Ok((did, pds))
363
+
}
364
+
}
365
+
366
+
#[async_trait::async_trait]
367
+
impl<T: IdentityResolver + Sync + Send> IdentityResolver for std::sync::Arc<T> {
368
+
fn options(&self) -> &ResolverOptions {
369
+
self.as_ref().options()
370
+
}
371
+
372
+
/// Resolve handle
373
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
374
+
self.as_ref().resolve_handle(handle).await
375
+
}
376
+
377
+
/// Resolve DID document
378
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
379
+
self.as_ref().resolve_did_doc(did).await
363
380
}
364
381
}
365
382
-6
crates/jacquard-common/src/types/datetime.rs
-6
crates/jacquard-common/src/types/datetime.rs
+4
crates/jacquard-oauth/Cargo.toml
+4
crates/jacquard-oauth/Cargo.toml
+103
-100
crates/jacquard-oauth/src/atproto.rs
+103
-100
crates/jacquard-oauth/src/atproto.rs
···
76
76
}
77
77
}
78
78
79
-
pub fn localhost_client_metadata<'s>(
80
-
redirect_uris: Option<Vec<Url>>,
81
-
scopes: Option<&'s [Scope<'s>]>,
82
-
) -> Result<OAuthClientMetadata<'s>> {
83
-
// validate redirect_uris
84
-
if let Some(redirect_uris) = &redirect_uris {
85
-
for redirect_uri in redirect_uris {
86
-
if redirect_uri.scheme() != "http" {
87
-
return Err(Error::LocalhostClient(LocalhostClientError::NotHttpScheme));
88
-
}
89
-
if redirect_uri.host().map(|h| h.to_owned()) == Some(Host::parse("localhost").unwrap())
90
-
{
91
-
return Err(Error::LocalhostClient(LocalhostClientError::Localhost));
92
-
}
93
-
if redirect_uri
94
-
.host()
95
-
.map(|h| h.to_owned())
96
-
.map_or(true, |host| {
97
-
host != Host::parse("127.0.0.1").unwrap()
98
-
&& host != Host::parse("[::1]").unwrap()
99
-
})
100
-
{
101
-
return Err(Error::LocalhostClient(
102
-
LocalhostClientError::NotLoopbackHost,
103
-
));
104
-
}
105
-
}
106
-
}
107
-
// determine client_id
108
-
#[derive(serde::Serialize)]
109
-
struct Parameters<'a> {
110
-
#[serde(skip_serializing_if = "Option::is_none")]
111
-
redirect_uri: Option<Vec<Url>>,
112
-
#[serde(skip_serializing_if = "Option::is_none")]
113
-
scope: Option<CowStr<'a>>,
114
-
}
115
-
let query = serde_html_form::to_string(Parameters {
116
-
redirect_uri: redirect_uris.clone(),
117
-
scope: scopes.map(|s| Scope::serialize_multiple(s)),
118
-
})?;
119
-
let mut client_id = String::from("http://localhost");
120
-
if !query.is_empty() {
121
-
client_id.push_str(&format!("?{query}"));
122
-
}
123
-
Ok(OAuthClientMetadata {
124
-
client_id: Url::parse(&client_id).unwrap(),
125
-
client_uri: None,
126
-
redirect_uris: redirect_uris.unwrap_or(vec![
127
-
Url::from_str("http://127.0.0.1/").unwrap(),
128
-
Url::from_str("http://[::1]/").unwrap(),
129
-
]),
130
-
scope: None,
131
-
grant_types: None, // will be set to `authorization_code` and `refresh_token`
132
-
token_endpoint_auth_method: Some(CowStr::new_static("none")),
133
-
dpop_bound_access_tokens: None, // will be set to `true`
134
-
jwks_uri: None,
135
-
jwks: None,
136
-
token_endpoint_auth_signing_alg: None,
137
-
})
138
-
}
139
-
140
79
#[derive(Clone, Debug, PartialEq, Eq)]
141
80
pub struct AtprotoClientMetadata<'m> {
142
81
pub client_id: Url,
143
82
pub client_uri: Option<Url>,
144
83
pub redirect_uris: Vec<Url>,
145
-
pub token_endpoint_auth_method: AuthMethod,
146
84
pub grant_types: Vec<GrantType>,
147
85
pub scopes: Vec<Scope<'m>>,
148
86
pub jwks_uri: Option<Url>,
149
-
pub token_endpoint_auth_signing_alg: Option<CowStr<'m>>,
87
+
}
88
+
89
+
impl<'m> AtprotoClientMetadata<'m> {
90
+
pub fn new(
91
+
client_id: Url,
92
+
client_uri: Option<Url>,
93
+
redirect_uris: Vec<Url>,
94
+
grant_types: Vec<GrantType>,
95
+
scopes: Vec<Scope<'m>>,
96
+
jwks_uri: Option<Url>,
97
+
) -> Self {
98
+
Self {
99
+
client_id,
100
+
client_uri,
101
+
redirect_uris,
102
+
grant_types,
103
+
scopes,
104
+
jwks_uri,
105
+
}
106
+
}
107
+
108
+
pub fn new_localhost(
109
+
mut redirect_uris: Option<Vec<Url>>,
110
+
scopes: Option<Vec<Scope<'m>>>,
111
+
) -> Self {
112
+
// coerce redirect uris to localhost
113
+
if let Some(redirect_uris) = &mut redirect_uris {
114
+
for redirect_uri in redirect_uris {
115
+
redirect_uri.set_host(Some("http://localhost")).unwrap();
116
+
}
117
+
}
118
+
// determine client_id
119
+
#[derive(serde::Serialize)]
120
+
struct Parameters<'a> {
121
+
#[serde(skip_serializing_if = "Option::is_none")]
122
+
redirect_uri: Option<Vec<Url>>,
123
+
#[serde(skip_serializing_if = "Option::is_none")]
124
+
scope: Option<CowStr<'a>>,
125
+
}
126
+
let query = serde_html_form::to_string(Parameters {
127
+
redirect_uri: redirect_uris.clone(),
128
+
scope: scopes
129
+
.as_ref()
130
+
.map(|s| Scope::serialize_multiple(s.as_slice())),
131
+
})
132
+
.ok();
133
+
let mut client_id = String::from("http://localhost");
134
+
if let Some(query) = query
135
+
&& !query.is_empty()
136
+
{
137
+
client_id.push_str(&format!("?{query}"));
138
+
}
139
+
Self {
140
+
client_id: Url::parse(&client_id).unwrap(),
141
+
client_uri: None,
142
+
redirect_uris: redirect_uris.unwrap_or(vec![
143
+
Url::from_str("http://127.0.0.1/").unwrap(),
144
+
Url::from_str("http://[::1]/").unwrap(),
145
+
]),
146
+
grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
147
+
scopes: scopes.unwrap_or(vec![Scope::Atproto]),
148
+
jwks_uri: None,
149
+
}
150
+
}
150
151
}
151
152
152
153
pub fn atproto_client_metadata<'m>(
···
162
163
if !metadata.scopes.contains(&Scope::Atproto) {
163
164
return Err(Error::InvalidScope);
164
165
}
165
-
let (jwks_uri, mut jwks) = (metadata.jwks_uri, None);
166
-
match metadata.token_endpoint_auth_method {
167
-
AuthMethod::None => {
168
-
if metadata.token_endpoint_auth_signing_alg.is_some() {
169
-
return Err(Error::AuthSigningAlg);
170
-
}
171
-
}
172
-
AuthMethod::PrivateKeyJwt => {
173
-
if let Some(keyset) = keyset {
174
-
if metadata.token_endpoint_auth_signing_alg.is_none() {
175
-
return Err(Error::AuthSigningAlg);
176
-
}
177
-
if jwks_uri.is_none() {
178
-
jwks = Some(keyset.public_jwks());
179
-
}
180
-
} else {
181
-
return Err(Error::EmptyJwks);
182
-
}
183
-
}
184
-
}
166
+
let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset {
167
+
let jwks = if metadata.jwks_uri.is_none() {
168
+
Some(keyset.public_jwks())
169
+
} else {
170
+
None
171
+
};
172
+
(AuthMethod::PrivateKeyJwt, metadata.jwks_uri, jwks)
173
+
} else {
174
+
(AuthMethod::None, None, None)
175
+
};
176
+
185
177
Ok(OAuthClientMetadata {
186
178
client_id: metadata.client_id,
187
179
client_uri: metadata.client_uri,
188
180
redirect_uris: metadata.redirect_uris,
189
-
token_endpoint_auth_method: Some(metadata.token_endpoint_auth_method.into()),
181
+
token_endpoint_auth_method: Some(auth_method.into()),
190
182
grant_types: Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()),
191
183
scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())),
192
184
dpop_bound_access_tokens: Some(true),
193
185
jwks_uri,
194
186
jwks,
195
-
token_endpoint_auth_signing_alg: metadata.token_endpoint_auth_signing_alg,
187
+
token_endpoint_auth_signing_alg: if keyset.is_some() {
188
+
Some(CowStr::new_static("ES256"))
189
+
} else {
190
+
None
191
+
},
196
192
})
197
193
}
198
194
···
216
212
#[test]
217
213
fn test_localhost_client_metadata_default() {
218
214
assert_eq!(
219
-
localhost_client_metadata(None, None).expect("failed to convert metadata"),
215
+
atproto_client_metadata(AtprotoClientMetadata::new_localhost(None, None), &None)
216
+
.unwrap(),
220
217
OAuthClientMetadata {
221
218
client_id: Url::from_str("http://localhost").unwrap(),
222
219
client_uri: None,
···
238
235
#[test]
239
236
fn test_localhost_client_metadata_custom() {
240
237
assert_eq!(
241
-
localhost_client_metadata(
238
+
atproto_client_metadata(AtprotoClientMetadata::new_localhost(
242
239
Some(vec![
243
240
Url::from_str("http://127.0.0.1/callback").unwrap(),
244
241
Url::from_str("http://[::1]/callback").unwrap(),
···
249
246
Scope::Transition(TransitionScope::Generic),
250
247
Scope::parse("account:email").unwrap()
251
248
]
252
-
.as_slice()
253
249
)
254
-
)
250
+
), &None)
255
251
.expect("failed to convert metadata"),
256
252
OAuthClientMetadata {
257
253
client_id: Url::from_str(
···
276
272
#[test]
277
273
fn test_localhost_client_metadata_invalid() {
278
274
{
279
-
let err = localhost_client_metadata(
280
-
Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]),
281
-
None,
275
+
let err = atproto_client_metadata(
276
+
AtprotoClientMetadata::new_localhost(
277
+
Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]),
278
+
None,
279
+
),
280
+
&None,
282
281
)
283
282
.expect_err("expected to fail");
284
283
assert!(matches!(
···
287
286
));
288
287
}
289
288
{
290
-
let err = localhost_client_metadata(
291
-
Some(vec![Url::from_str("http://localhost:8000/").unwrap()]),
292
-
None,
289
+
let err = atproto_client_metadata(
290
+
AtprotoClientMetadata::new_localhost(
291
+
Some(vec![Url::from_str("http://localhost:8000/").unwrap()]),
292
+
None,
293
+
),
294
+
&None,
293
295
)
294
296
.expect_err("expected to fail");
295
297
assert!(matches!(
···
298
300
));
299
301
}
300
302
{
301
-
let err = localhost_client_metadata(
302
-
Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]),
303
-
None,
303
+
let err = atproto_client_metadata(
304
+
AtprotoClientMetadata::new_localhost(
305
+
Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]),
306
+
None,
307
+
),
308
+
&None,
304
309
)
305
310
.expect_err("expected to fail");
306
311
assert!(matches!(
···
316
321
client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(),
317
322
client_uri: Some(Url::from_str("https://example.com").unwrap()),
318
323
redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()],
319
-
token_endpoint_auth_method: AuthMethod::PrivateKeyJwt,
320
324
grant_types: vec![GrantType::AuthorizationCode],
321
325
scopes: vec![Scope::Atproto],
322
326
jwks_uri: None,
323
-
token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")),
324
327
};
325
328
{
326
329
let metadata = metadata.clone();
+33
crates/jacquard-oauth/src/authstore.rs
+33
crates/jacquard-oauth/src/authstore.rs
···
1
+
use jacquard_common::{session::SessionStoreError, types::did::Did};
2
+
3
+
use crate::session::{AuthRequestData, ClientSessionData};
4
+
5
+
#[async_trait::async_trait]
6
+
pub trait ClientAuthStore {
7
+
async fn get_session(
8
+
&self,
9
+
did: &Did<'_>,
10
+
session_id: &str,
11
+
) -> Result<Option<ClientSessionData<'_>>, SessionStoreError>;
12
+
13
+
async fn upsert_session(&self, session: ClientSessionData<'_>)
14
+
-> Result<(), SessionStoreError>;
15
+
16
+
async fn delete_session(
17
+
&self,
18
+
did: &Did<'_>,
19
+
session_id: &str,
20
+
) -> Result<(), SessionStoreError>;
21
+
22
+
async fn get_auth_req_info(
23
+
&self,
24
+
state: &str,
25
+
) -> Result<Option<AuthRequestData<'_>>, SessionStoreError>;
26
+
27
+
async fn save_auth_req_info(
28
+
&self,
29
+
auth_req_info: &AuthRequestData<'_>,
30
+
) -> Result<(), SessionStoreError>;
31
+
32
+
async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>;
33
+
}
+186
crates/jacquard-oauth/src/client.rs
+186
crates/jacquard-oauth/src/client.rs
···
1
+
use std::sync::Arc;
2
+
3
+
use jacquard_common::{CowStr, IntoStatic, types::did::Did};
4
+
use jose_jwk::JwkSet;
5
+
use url::Url;
6
+
7
+
use crate::{
8
+
atproto::atproto_client_metadata,
9
+
authstore::ClientAuthStore,
10
+
dpop::DpopExt,
11
+
error::{OAuthError, Result},
12
+
request::{OAuthMetadata, exchange_code, par},
13
+
resolver::OAuthResolver,
14
+
scopes::Scope,
15
+
session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry},
16
+
types::{AuthorizeOptions, CallbackParams},
17
+
};
18
+
19
+
pub struct OAuthClient<T, S>
20
+
where
21
+
T: OAuthResolver,
22
+
S: ClientAuthStore,
23
+
{
24
+
pub registry: Arc<SessionRegistry<T, S>>,
25
+
pub client: Arc<T>,
26
+
}
27
+
28
+
impl<T, S> OAuthClient<T, S>
29
+
where
30
+
T: OAuthResolver,
31
+
S: ClientAuthStore,
32
+
{
33
+
pub fn new_from_resolver(store: S, client: T, client_data: ClientData<'static>) -> Self {
34
+
let client = Arc::new(client);
35
+
let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data));
36
+
Self { registry, client }
37
+
}
38
+
}
39
+
40
+
impl<T, S> OAuthClient<T, S>
41
+
where
42
+
S: ClientAuthStore + Send + Sync + 'static,
43
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
44
+
{
45
+
pub fn jwks(&self) -> JwkSet {
46
+
self.registry
47
+
.client_data
48
+
.keyset
49
+
.as_ref()
50
+
.map(|keyset| keyset.public_jwks())
51
+
.unwrap_or_default()
52
+
}
53
+
pub async fn start_auth(
54
+
&self,
55
+
input: impl AsRef<str>,
56
+
options: AuthorizeOptions<'_>,
57
+
) -> Result<String> {
58
+
let client_metadata = atproto_client_metadata(
59
+
self.registry.client_data.config.clone(),
60
+
&self.registry.client_data.keyset,
61
+
)?;
62
+
63
+
let (server_metadata, identity) = self.client.resolve_oauth(input.as_ref()).await?;
64
+
let login_hint = if identity.is_some() {
65
+
Some(input.as_ref().into())
66
+
} else {
67
+
None
68
+
};
69
+
let metadata = OAuthMetadata {
70
+
server_metadata,
71
+
client_metadata,
72
+
keyset: self.registry.client_data.keyset.clone(),
73
+
};
74
+
let auth_req_info =
75
+
par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?;
76
+
77
+
#[derive(serde::Serialize)]
78
+
struct Parameters<'s> {
79
+
client_id: Url,
80
+
request_uri: CowStr<'s>,
81
+
}
82
+
Ok(metadata.server_metadata.authorization_endpoint.to_string()
83
+
+ "?"
84
+
+ &serde_html_form::to_string(Parameters {
85
+
client_id: metadata.client_metadata.client_id.clone(),
86
+
request_uri: auth_req_info.request_uri,
87
+
})
88
+
.unwrap())
89
+
}
90
+
91
+
pub async fn callback(&self, params: CallbackParams<'_>) -> Result<ClientSessionData<'static>> {
92
+
let Some(state_key) = params.state else {
93
+
return Err(OAuthError::Callback("missing state parameter".into()));
94
+
};
95
+
96
+
let Some(auth_req_info) = self.registry.store.get_auth_req_info(&state_key).await? else {
97
+
return Err(OAuthError::Callback(format!(
98
+
"unknown authorization state: {state_key}"
99
+
)));
100
+
};
101
+
102
+
self.registry.store.delete_auth_req_info(&state_key).await?;
103
+
104
+
let metadata = self
105
+
.client
106
+
.get_authorization_server_metadata(&auth_req_info.authserver_url)
107
+
.await?;
108
+
109
+
if let Some(iss) = params.iss {
110
+
if iss != metadata.issuer {
111
+
return Err(OAuthError::Callback(format!(
112
+
"issuer mismatch: expected {}, got {iss}",
113
+
metadata.issuer
114
+
)));
115
+
}
116
+
} else if metadata.authorization_response_iss_parameter_supported == Some(true) {
117
+
return Err(OAuthError::Callback("missing `iss` parameter".into()));
118
+
}
119
+
let metadata = OAuthMetadata {
120
+
server_metadata: metadata,
121
+
client_metadata: atproto_client_metadata(
122
+
self.registry.client_data.config.clone(),
123
+
&self.registry.client_data.keyset,
124
+
)?,
125
+
keyset: self.registry.client_data.keyset.clone(),
126
+
};
127
+
let authserver_nonce = auth_req_info.dpop_data.dpop_authserver_nonce.clone();
128
+
129
+
match exchange_code(
130
+
self.client.as_ref(),
131
+
&mut auth_req_info.dpop_data.clone(),
132
+
¶ms.code,
133
+
&auth_req_info.pkce_verifier,
134
+
&metadata,
135
+
)
136
+
.await
137
+
{
138
+
Ok(token_set) => {
139
+
let scopes = if let Some(scope) = &token_set.scope {
140
+
Scope::parse_multiple_reduced(&scope)
141
+
.expect("Failed to parse scopes")
142
+
.into_static()
143
+
} else {
144
+
vec![]
145
+
};
146
+
let client_data = ClientSessionData {
147
+
account_did: token_set.sub.clone(),
148
+
session_id: auth_req_info.state,
149
+
host_url: Url::parse(&token_set.iss).expect("Failed to parse host URL"),
150
+
authserver_url: auth_req_info.authserver_url,
151
+
authserver_token_endpoint: auth_req_info.authserver_token_endpoint,
152
+
authserver_revocation_endpoint: auth_req_info.authserver_revocation_endpoint,
153
+
scopes,
154
+
dpop_data: DpopClientData {
155
+
dpop_key: auth_req_info.dpop_data.dpop_key.clone(),
156
+
dpop_authserver_nonce: authserver_nonce.unwrap_or(CowStr::default()),
157
+
dpop_host_nonce: auth_req_info
158
+
.dpop_data
159
+
.dpop_authserver_nonce
160
+
.unwrap_or(CowStr::default()),
161
+
},
162
+
token_set,
163
+
};
164
+
165
+
Ok(client_data.into_static())
166
+
}
167
+
Err(e) => Err(e.into()),
168
+
}
169
+
}
170
+
171
+
pub async fn restore(
172
+
&self,
173
+
did: &Did<'_>,
174
+
session_id: &str,
175
+
) -> Result<ClientSessionData<'static>> {
176
+
Ok(self
177
+
.registry
178
+
.get(did, session_id, false)
179
+
.await?
180
+
.into_static())
181
+
}
182
+
183
+
pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> {
184
+
Ok(self.registry.del(did, session_id).await?)
185
+
}
186
+
}
+179
-179
crates/jacquard-oauth/src/dpop.rs
+179
-179
crates/jacquard-oauth/src/dpop.rs
···
1
1
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2
2
use chrono::Utc;
3
3
use http::{Request, Response, header::InvalidHeaderValue};
4
-
use jacquard_common::{
5
-
CowStr,
6
-
http_client::HttpClient,
7
-
session::{MemorySessionStore, SessionStore, SessionStoreError},
8
-
};
4
+
use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient};
9
5
use jose_jwa::{Algorithm, Signing};
10
6
use jose_jwk::{Jwk, Key, crypto};
11
7
use p256::ecdsa::SigningKey;
12
8
use rand::{RngCore, SeedableRng};
13
9
use sha2::Digest;
14
-
use smol_str::{SmolStr, ToSmolStr};
15
10
16
-
use crate::jose::{
17
-
create_signed_jwt,
18
-
jws::RegisteredHeader,
19
-
jwt::{Claims, PublicClaims, RegisteredClaims},
11
+
use crate::{
12
+
jose::{
13
+
create_signed_jwt,
14
+
jws::RegisteredHeader,
15
+
jwt::{Claims, PublicClaims, RegisteredClaims},
16
+
},
17
+
session::DpopDataSource,
20
18
};
21
19
22
20
pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt";
···
30
28
pub enum Error {
31
29
#[error(transparent)]
32
30
InvalidHeaderValue(#[from] InvalidHeaderValue),
33
-
#[error(transparent)]
34
-
SessionStore(#[from] SessionStoreError),
35
31
#[error("crypto error: {0:?}")]
36
32
JwkCrypto(crypto::Error),
37
33
#[error("key does not match any alg supported by the server")]
···
44
40
45
41
type Result<T> = core::result::Result<T, Error>;
46
42
43
+
#[async_trait::async_trait]
44
+
pub trait DpopClient: HttpClient {
45
+
async fn dpop_server(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
46
+
async fn dpop_client(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
47
+
async fn wrap_request(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
48
+
}
49
+
50
+
pub trait DpopExt: HttpClient {
51
+
fn dpop_server_call<'r, D>(&'r self, data_source: &'r mut D) -> DpopCall<'r, Self, D>
52
+
where
53
+
Self: Sized,
54
+
D: DpopDataSource,
55
+
{
56
+
DpopCall::server(self, data_source)
57
+
}
58
+
59
+
fn dpop_call<'r, N>(&'r self, data_source: &'r mut N) -> DpopCall<'r, Self, N>
60
+
where
61
+
Self: Sized,
62
+
N: DpopDataSource,
63
+
{
64
+
DpopCall::client(self, data_source)
65
+
}
66
+
67
+
async fn wrap_with_dpop<'r, D>(
68
+
&'r self,
69
+
is_to_auth_server: bool,
70
+
data_source: &'r mut D,
71
+
request: Request<Vec<u8>>,
72
+
) -> Result<Response<Vec<u8>>>
73
+
where
74
+
Self: Sized,
75
+
D: DpopDataSource,
76
+
{
77
+
wrap_request_with_dpop(self, data_source, is_to_auth_server, request).await
78
+
}
79
+
}
80
+
81
+
pub struct DpopCall<'r, C: HttpClient, D: DpopDataSource> {
82
+
pub client: &'r C,
83
+
pub is_to_auth_server: bool,
84
+
pub data_source: &'r mut D,
85
+
}
86
+
87
+
impl<'r, C: HttpClient, N: DpopDataSource> DpopCall<'r, C, N> {
88
+
pub fn server(client: &'r C, data_source: &'r mut N) -> Self {
89
+
Self {
90
+
client,
91
+
is_to_auth_server: true,
92
+
data_source,
93
+
}
94
+
}
95
+
96
+
pub fn client(client: &'r C, data_source: &'r mut N) -> Self {
97
+
Self {
98
+
client,
99
+
is_to_auth_server: false,
100
+
data_source,
101
+
}
102
+
}
103
+
104
+
pub async fn send(self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>> {
105
+
wrap_request_with_dpop(
106
+
self.client,
107
+
self.data_source,
108
+
self.is_to_auth_server,
109
+
request,
110
+
)
111
+
.await
112
+
}
113
+
}
114
+
115
+
pub async fn wrap_request_with_dpop<T, N>(
116
+
client: &T,
117
+
data_source: &mut N,
118
+
is_to_auth_server: bool,
119
+
mut request: Request<Vec<u8>>,
120
+
) -> Result<Response<Vec<u8>>>
121
+
where
122
+
T: HttpClient,
123
+
N: DpopDataSource,
124
+
{
125
+
let uri = request.uri().clone();
126
+
let method = request.method().to_cowstr().into_static();
127
+
let uri = uri.to_cowstr();
128
+
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
129
+
let ath = request
130
+
.headers()
131
+
.get("Authorization")
132
+
.filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP ")))
133
+
.map(|auth| {
134
+
URL_SAFE_NO_PAD
135
+
.encode(sha2::Sha256::digest(&auth.as_bytes()[5..]))
136
+
.into()
137
+
});
138
+
139
+
let init_nonce = if is_to_auth_server {
140
+
data_source.authserver_nonce()
141
+
} else {
142
+
data_source.host_nonce()
143
+
};
144
+
let init_proof = build_dpop_proof(
145
+
data_source.key(),
146
+
method.clone(),
147
+
uri.clone(),
148
+
init_nonce.clone(),
149
+
ath.clone(),
150
+
)?;
151
+
request.headers_mut().insert("DPoP", init_proof.parse()?);
152
+
let response = client
153
+
.send_http(request.clone())
154
+
.await
155
+
.map_err(|e| Error::Inner(e.into()))?;
156
+
157
+
let next_nonce = response
158
+
.headers()
159
+
.get("DPoP-Nonce")
160
+
.and_then(|v| v.to_str().ok())
161
+
.map(|c| c.to_cowstr());
162
+
match &next_nonce {
163
+
Some(s) if next_nonce != init_nonce => {
164
+
// Store the fresh nonce for future requests
165
+
if is_to_auth_server {
166
+
data_source.set_authserver_nonce(s.clone());
167
+
} else {
168
+
data_source.set_host_nonce(s.clone());
169
+
}
170
+
}
171
+
_ => {
172
+
// No nonce was returned or it is the same as the one we sent. No need to
173
+
// update the nonce store, or retry the request.
174
+
return Ok(response);
175
+
}
176
+
}
177
+
178
+
if !is_use_dpop_nonce_error(is_to_auth_server, &response) {
179
+
return Ok(response);
180
+
}
181
+
let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?;
182
+
request.headers_mut().insert("DPoP", next_proof.parse()?);
183
+
let response = client
184
+
.send_http(request)
185
+
.await
186
+
.map_err(|e| Error::Inner(e.into()))?;
187
+
Ok(response)
188
+
}
189
+
190
+
#[inline]
191
+
fn is_use_dpop_nonce_error(is_to_auth_server: bool, response: &Response<Vec<u8>>) -> bool {
192
+
// https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
193
+
if is_to_auth_server {
194
+
if response.status() == 400 {
195
+
if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) {
196
+
return res.error == "use_dpop_nonce";
197
+
};
198
+
}
199
+
}
200
+
// https://datatracker.ietf.org/doc/html/rfc6750#section-3
201
+
// https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
202
+
else if response.status() == 401 {
203
+
if let Some(www_auth) = response
204
+
.headers()
205
+
.get("WWW-Authenticate")
206
+
.and_then(|v| v.to_str().ok())
207
+
{
208
+
return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#);
209
+
}
210
+
}
211
+
false
212
+
}
213
+
47
214
#[inline]
48
215
pub(crate) fn generate_jti() -> CowStr<'static> {
49
216
let mut rng = rand::rngs::SmallRng::from_entropy();
···
65
232
crypto::Key::P256(crypto::Kind::Secret(sk)) => sk,
66
233
_ => return Err(Error::UnsupportedKey),
67
234
};
68
-
build_dpop_proof_with_secret(&secret, method, url, nonce, ath)
69
-
}
70
-
71
-
/// Same as build_dpop_proof but takes a parsed secret key to avoid JSON roundtrips.
72
-
#[inline]
73
-
pub fn build_dpop_proof_with_secret<'s>(
74
-
secret: &p256::SecretKey,
75
-
method: CowStr<'s>,
76
-
url: CowStr<'s>,
77
-
nonce: Option<CowStr<'s>>,
78
-
ath: Option<CowStr<'s>>,
79
-
) -> Result<CowStr<'s>> {
80
235
let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
81
236
header.typ = Some(JWT_HEADER_TYP_DPOP.into());
82
237
header.jwk = Some(Jwk {
···
103
258
claims,
104
259
)?)
105
260
}
106
-
107
-
pub struct DpopClient<T, S = MemorySessionStore<CowStr<'static>, CowStr<'static>>>
108
-
where
109
-
S: SessionStore<CowStr<'static>, CowStr<'static>>,
110
-
{
111
-
inner: T,
112
-
pub(crate) key: Key,
113
-
nonces: S,
114
-
is_auth_server: bool,
115
-
}
116
-
117
-
impl<T> DpopClient<T> {
118
-
pub fn new(
119
-
key: Key,
120
-
http_client: T,
121
-
is_auth_server: bool,
122
-
supported_algs: &Option<Vec<CowStr<'static>>>,
123
-
) -> Result<Self> {
124
-
if let Some(algs) = supported_algs {
125
-
let alg = CowStr::from(match &key {
126
-
Key::Ec(ec) => match &ec.crv {
127
-
jose_jwk::EcCurves::P256 => "ES256",
128
-
_ => unimplemented!(),
129
-
},
130
-
_ => unimplemented!(),
131
-
});
132
-
if !algs.contains(&alg) {
133
-
return Err(Error::UnsupportedKey);
134
-
}
135
-
}
136
-
let nonces = MemorySessionStore::<CowStr<'static>, CowStr<'static>>::default();
137
-
Ok(Self {
138
-
inner: http_client,
139
-
key,
140
-
nonces,
141
-
is_auth_server,
142
-
})
143
-
}
144
-
}
145
-
146
-
impl<T, S> DpopClient<T, S>
147
-
where
148
-
S: SessionStore<CowStr<'static>, CowStr<'static>>,
149
-
{
150
-
fn build_proof<'s>(
151
-
&self,
152
-
method: CowStr<'s>,
153
-
url: CowStr<'s>,
154
-
ath: Option<CowStr<'s>>,
155
-
nonce: Option<CowStr<'s>>,
156
-
) -> Result<CowStr<'s>> {
157
-
build_dpop_proof(&self.key, method, url, nonce, ath)
158
-
}
159
-
fn is_use_dpop_nonce_error(&self, response: &http::Response<Vec<u8>>) -> bool {
160
-
// https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
161
-
if self.is_auth_server {
162
-
if response.status() == 400 {
163
-
if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) {
164
-
return res.error == "use_dpop_nonce";
165
-
};
166
-
}
167
-
}
168
-
// https://datatracker.ietf.org/doc/html/rfc6750#section-3
169
-
// https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
170
-
else if response.status() == 401 {
171
-
if let Some(www_auth) = response
172
-
.headers()
173
-
.get("WWW-Authenticate")
174
-
.and_then(|v| v.to_str().ok())
175
-
{
176
-
return www_auth.starts_with("DPoP")
177
-
&& www_auth.contains(r#"error="use_dpop_nonce""#);
178
-
}
179
-
}
180
-
false
181
-
}
182
-
}
183
-
184
-
impl<T, S> HttpClient for DpopClient<T, S>
185
-
where
186
-
T: HttpClient + Send + Sync + 'static,
187
-
S: SessionStore<CowStr<'static>, CowStr<'static>> + Send + Sync + 'static,
188
-
{
189
-
type Error = Error;
190
-
191
-
async fn send_http(
192
-
&self,
193
-
mut request: Request<Vec<u8>>,
194
-
) -> core::result::Result<Response<Vec<u8>>, Self::Error> {
195
-
let uri = request.uri();
196
-
let nonce_key = CowStr::Owned(uri.authority().unwrap().to_smolstr());
197
-
let method = CowStr::Owned(request.method().to_smolstr());
198
-
let uri = CowStr::Owned(uri.to_smolstr());
199
-
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
200
-
let ath = request
201
-
.headers()
202
-
.get("Authorization")
203
-
.filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP ")))
204
-
.map(|auth| {
205
-
URL_SAFE_NO_PAD
206
-
.encode(sha2::Sha256::digest(&auth.as_bytes()[5..]))
207
-
.into()
208
-
});
209
-
210
-
let init_nonce = self.nonces.get(&nonce_key).await;
211
-
let init_proof =
212
-
self.build_proof(method.clone(), uri.clone(), ath.clone(), init_nonce.clone())?;
213
-
request.headers_mut().insert("DPoP", init_proof.parse()?);
214
-
let response = self
215
-
.inner
216
-
.send_http(request.clone())
217
-
.await
218
-
.map_err(|e| Error::Inner(e.into()))?;
219
-
220
-
let next_nonce = response
221
-
.headers()
222
-
.get("DPoP-Nonce")
223
-
.and_then(|v| v.to_str().ok())
224
-
.map(|c| CowStr::Owned(SmolStr::new(c)));
225
-
match &next_nonce {
226
-
Some(s) if next_nonce != init_nonce => {
227
-
// Store the fresh nonce for future requests
228
-
self.nonces.set(nonce_key, s.clone()).await?;
229
-
}
230
-
_ => {
231
-
// No nonce was returned or it is the same as the one we sent. No need to
232
-
// update the nonce store, or retry the request.
233
-
return Ok(response);
234
-
}
235
-
}
236
-
237
-
if !self.is_use_dpop_nonce_error(&response) {
238
-
return Ok(response);
239
-
}
240
-
let next_proof = self.build_proof(method, uri, ath, next_nonce)?;
241
-
request.headers_mut().insert("DPoP", next_proof.parse()?);
242
-
let response = self
243
-
.inner
244
-
.send_http(request)
245
-
.await
246
-
.map_err(|e| Error::Inner(e.into()))?;
247
-
Ok(response)
248
-
}
249
-
}
250
-
251
-
impl<T: Clone> Clone for DpopClient<T> {
252
-
fn clone(&self) -> Self {
253
-
Self {
254
-
inner: self.inner.clone(),
255
-
key: self.key.clone(),
256
-
nonces: self.nonces.clone(),
257
-
is_auth_server: self.is_auth_server,
258
-
}
259
-
}
260
-
}
+18
-2
crates/jacquard-oauth/src/error.rs
+18
-2
crates/jacquard-oauth/src/error.rs
···
1
+
use jacquard_common::session::SessionStoreError;
1
2
use miette::Diagnostic;
2
-
use thiserror::Error;
3
+
4
+
use crate::resolver::ResolverError;
3
5
4
6
/// Errors emitted by OAuth helpers.
5
-
#[derive(Debug, Error, Diagnostic)]
7
+
#[derive(Debug, thiserror::Error, Diagnostic)]
6
8
pub enum OAuthError {
7
9
/// Invalid or unsupported JWK
8
10
#[error("invalid JWK: {0}")]
···
37
39
help("PKCE must use S256; ensure verifier/challenge generated")
38
40
)]
39
41
Pkce(String),
42
+
#[error("authorize error: {0}")]
43
+
Authorize(String),
44
+
#[error(transparent)]
45
+
Atproto(#[from] crate::atproto::Error),
46
+
#[error("callback error: {0}")]
47
+
Callback(String),
48
+
#[error(transparent)]
49
+
Storage(#[from] SessionStoreError),
50
+
#[error(transparent)]
51
+
Session(#[from] crate::session::Error),
52
+
#[error(transparent)]
53
+
Request(#[from] crate::request::Error),
54
+
#[error(transparent)]
55
+
Client(#[from] ResolverError),
40
56
}
41
57
42
58
pub type Result<T> = core::result::Result<T, OAuthError>;
+6
-7
crates/jacquard-oauth/src/keyset.rs
+6
-7
crates/jacquard-oauth/src/keyset.rs
···
1
1
use crate::jose::create_signed_jwt;
2
2
use crate::jose::jws::RegisteredHeader;
3
3
use crate::jose::jwt::Claims;
4
-
use jacquard_common::CowStr;
4
+
use jacquard_common::{CowStr, IntoStatic};
5
5
use jose_jwa::{Algorithm, Signing};
6
6
use jose_jwk::{Class, EcCurves, crypto};
7
7
use jose_jwk::{Jwk, JwkSet, Key};
8
-
use smol_str::{SmolStr, ToSmolStr};
9
8
use std::collections::HashSet;
10
9
use thiserror::Error;
11
10
···
18
17
#[error("key must have a `kid`")]
19
18
EmptyKid,
20
19
#[error("no signing key found for algorithms: {0:?}")]
21
-
NotFound(Vec<SmolStr>),
20
+
NotFound(Vec<CowStr<'static>>),
22
21
#[error("key for signing must be a secret key")]
23
22
PublicKey,
24
23
#[error("crypto error: {0:?}")]
···
49
48
}
50
49
JwkSet { keys }
51
50
}
52
-
pub fn create_jwt(&self, algs: &[SmolStr], claims: Claims) -> Result<CowStr<'static>> {
51
+
pub fn create_jwt(&self, algs: &[CowStr], claims: Claims) -> Result<CowStr<'static>> {
53
52
let Some(jwk) = self.find_key(algs, Class::Signing) else {
54
-
return Err(Error::NotFound(algs.to_vec()));
53
+
return Err(Error::NotFound(algs.to_vec().into_static()));
55
54
};
56
55
self.create_jwt_with_key(jwk, claims)
57
56
}
58
-
fn find_key(&self, algs: &[SmolStr], cls: Class) -> Option<&Jwk> {
57
+
fn find_key(&self, algs: &[CowStr], cls: Class) -> Option<&Jwk> {
59
58
let candidates = self
60
59
.0
61
60
.iter()
···
70
69
},
71
70
_ => unimplemented!(),
72
71
};
73
-
Some((alg, key)).filter(|(alg, _)| algs.contains(&alg.to_smolstr()))
72
+
Some((alg, key)).filter(|(alg, _)| algs.contains(&CowStr::Borrowed(&alg)))
74
73
})
75
74
.collect::<Vec<_>>();
76
75
for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS {
+4
crates/jacquard-oauth/src/lib.rs
+4
crates/jacquard-oauth/src/lib.rs
···
2
2
//! Transport, discovery, and orchestration live in `jacquard`.
3
3
4
4
pub mod atproto;
5
+
pub mod authstore;
6
+
pub mod client;
5
7
pub mod dpop;
6
8
pub mod error;
7
9
pub mod jose;
8
10
pub mod keyset;
11
+
pub mod request;
9
12
pub mod resolver;
10
13
pub mod scopes;
11
14
pub mod session;
12
15
pub mod types;
16
+
pub mod utils;
13
17
14
18
pub const FALLBACK_ALG: &str = "ES256";
+536
crates/jacquard-oauth/src/request.rs
+536
crates/jacquard-oauth/src/request.rs
···
1
+
use chrono::{DateTime, FixedOffset, TimeDelta, Utc};
2
+
use http::{Method, Request, StatusCode};
3
+
use jacquard_common::{
4
+
CowStr, IntoStatic,
5
+
cowstr::ToCowStr,
6
+
http_client::HttpClient,
7
+
ident_resolver::{IdentityError, IdentityResolver},
8
+
session::SessionStoreError,
9
+
types::{
10
+
did::Did,
11
+
string::{AtStrError, Datetime},
12
+
},
13
+
};
14
+
use jose_jwk::Key;
15
+
use serde::{Serialize, de::DeserializeOwned};
16
+
use serde_json::Value;
17
+
use smol_str::ToSmolStr;
18
+
use std::sync::Arc;
19
+
use thiserror::Error;
20
+
use url::Url;
21
+
22
+
use crate::{
23
+
FALLBACK_ALG,
24
+
atproto::{AtprotoClientMetadata, atproto_client_metadata},
25
+
dpop::{DpopClient, DpopExt},
26
+
jose::jwt::{RegisteredClaims, RegisteredClaimsAud},
27
+
keyset::Keyset,
28
+
resolver::OAuthResolver,
29
+
scopes::Scope,
30
+
session::{
31
+
AuthRequestData, ClientData, ClientSessionData, DpopClientData, DpopDataSource, DpopReqData,
32
+
},
33
+
types::{
34
+
AuthorizationCodeChallengeMethod, AuthorizationResponseType, AuthorizeOptionPrompt,
35
+
OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthParResponse,
36
+
OAuthTokenResponse, ParParameters, RefreshRequestParameters, RevocationRequestParameters,
37
+
TokenGrantType, TokenRequestParameters, TokenSet,
38
+
},
39
+
utils::{compare_algos, generate_dpop_key, generate_nonce, generate_pkce},
40
+
};
41
+
42
+
// https://datatracker.ietf.org/doc/html/rfc7523#section-2.2
43
+
const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
44
+
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
45
+
46
+
#[derive(Error, Debug)]
47
+
pub enum Error {
48
+
#[error("no {0} endpoint available")]
49
+
NoEndpoint(CowStr<'static>),
50
+
#[error("token response verification failed")]
51
+
Token(CowStr<'static>),
52
+
#[error("unsupported authentication method")]
53
+
UnsupportedAuthMethod,
54
+
#[error("no refresh token available")]
55
+
TokenRefresh,
56
+
#[error("failed to parse DID: {0}")]
57
+
InvalidDid(#[from] AtStrError),
58
+
#[error(transparent)]
59
+
DpopClient(#[from] crate::dpop::Error),
60
+
#[error(transparent)]
61
+
Storage(#[from] SessionStoreError),
62
+
63
+
#[error(transparent)]
64
+
ResolverError(#[from] crate::resolver::ResolverError),
65
+
// #[error(transparent)]
66
+
// OAuthSession(#[from] crate::oauth_session::Error),
67
+
#[error(transparent)]
68
+
Http(#[from] http::Error),
69
+
#[error("http client error: {0}")]
70
+
HttpClient(Box<dyn std::error::Error + Send + Sync + 'static>),
71
+
#[error("http status: {0}")]
72
+
HttpStatus(StatusCode),
73
+
#[error("http status: {0}, body: {1:?}")]
74
+
HttpStatusWithBody(StatusCode, Value),
75
+
#[error(transparent)]
76
+
Identity(#[from] IdentityError),
77
+
#[error(transparent)]
78
+
Keyset(#[from] crate::keyset::Error),
79
+
#[error(transparent)]
80
+
SerdeHtmlForm(#[from] serde_html_form::ser::Error),
81
+
#[error(transparent)]
82
+
SerdeJson(#[from] serde_json::Error),
83
+
#[error(transparent)]
84
+
Atproto(#[from] crate::atproto::Error),
85
+
}
86
+
87
+
pub type Result<T> = core::result::Result<T, Error>;
88
+
89
+
#[allow(dead_code)]
90
+
pub enum OAuthRequest<'a> {
91
+
Token(TokenRequestParameters<'a>),
92
+
Refresh(RefreshRequestParameters<'a>),
93
+
Revocation(RevocationRequestParameters<'a>),
94
+
Introspection,
95
+
PushedAuthorizationRequest(ParParameters<'a>),
96
+
}
97
+
98
+
impl OAuthRequest<'_> {
99
+
pub fn name(&self) -> CowStr<'static> {
100
+
CowStr::new_static(match self {
101
+
Self::Token(_) => "token",
102
+
Self::Refresh(_) => "refresh",
103
+
Self::Revocation(_) => "revocation",
104
+
Self::Introspection => "introspection",
105
+
Self::PushedAuthorizationRequest(_) => "pushed_authorization_request",
106
+
})
107
+
}
108
+
pub fn expected_status(&self) -> StatusCode {
109
+
match self {
110
+
Self::Token(_) | Self::Refresh(_) => StatusCode::OK,
111
+
Self::PushedAuthorizationRequest(_) => StatusCode::CREATED,
112
+
// Unlike https://datatracker.ietf.org/doc/html/rfc7009#section-2.2, oauth-provider seems to return `204`.
113
+
Self::Revocation(_) => StatusCode::NO_CONTENT,
114
+
_ => unimplemented!(),
115
+
}
116
+
}
117
+
}
118
+
119
+
#[derive(Debug, Serialize)]
120
+
pub struct RequestPayload<'a, T>
121
+
where
122
+
T: Serialize,
123
+
{
124
+
client_id: CowStr<'a>,
125
+
#[serde(skip_serializing_if = "Option::is_none")]
126
+
client_assertion_type: Option<CowStr<'a>>,
127
+
#[serde(skip_serializing_if = "Option::is_none")]
128
+
client_assertion: Option<CowStr<'a>>,
129
+
#[serde(flatten)]
130
+
parameters: T,
131
+
}
132
+
133
+
#[derive(Debug, Clone)]
134
+
pub struct OAuthMetadata {
135
+
pub server_metadata: OAuthAuthorizationServerMetadata<'static>,
136
+
pub client_metadata: OAuthClientMetadata<'static>,
137
+
pub keyset: Option<Keyset>,
138
+
}
139
+
140
+
impl OAuthMetadata {
141
+
pub async fn new<'r, T: HttpClient + OAuthResolver + Send + Sync>(
142
+
client: &T,
143
+
ClientData { keyset, config }: &ClientData<'r>,
144
+
session_data: &ClientSessionData<'r>,
145
+
) -> Result<Self> {
146
+
Ok(OAuthMetadata {
147
+
server_metadata: client
148
+
.get_authorization_server_metadata(&session_data.authserver_url)
149
+
.await?,
150
+
client_metadata: atproto_client_metadata(config.clone(), &keyset)
151
+
.unwrap()
152
+
.into_static(),
153
+
keyset: keyset.clone(),
154
+
})
155
+
}
156
+
}
157
+
158
+
pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>(
159
+
client: &T,
160
+
login_hint: Option<CowStr<'r>>,
161
+
prompt: Option<AuthorizeOptionPrompt>,
162
+
metadata: &OAuthMetadata,
163
+
) -> crate::request::Result<AuthRequestData<'r>> {
164
+
let state = generate_nonce();
165
+
let (code_challenge, verifier) = generate_pkce();
166
+
167
+
let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else {
168
+
return Err(Error::Token("none of the algorithms worked".into()));
169
+
};
170
+
let mut dpop_data = DpopReqData {
171
+
dpop_key,
172
+
dpop_authserver_nonce: None,
173
+
};
174
+
let parameters = ParParameters {
175
+
response_type: AuthorizationResponseType::Code,
176
+
redirect_uri: metadata.client_metadata.redirect_uris[0].to_cowstr(),
177
+
state: state.clone(),
178
+
scope: metadata.client_metadata.scope.clone(),
179
+
response_mode: None,
180
+
code_challenge,
181
+
code_challenge_method: AuthorizationCodeChallengeMethod::S256,
182
+
login_hint: login_hint,
183
+
prompt: prompt.map(CowStr::from),
184
+
};
185
+
if metadata
186
+
.server_metadata
187
+
.pushed_authorization_request_endpoint
188
+
.is_some()
189
+
{
190
+
let par_response = oauth_request::<OAuthParResponse, T, DpopReqData>(
191
+
&client,
192
+
&mut dpop_data,
193
+
OAuthRequest::PushedAuthorizationRequest(parameters),
194
+
metadata,
195
+
)
196
+
.await?;
197
+
198
+
let scopes = if let Some(scope) = &metadata.client_metadata.scope {
199
+
Scope::parse_multiple_reduced(&scope)
200
+
.expect("Failed to parse scopes")
201
+
.into_static()
202
+
} else {
203
+
vec![]
204
+
};
205
+
let auth_req_data = AuthRequestData {
206
+
state,
207
+
authserver_url: url::Url::parse(&metadata.server_metadata.issuer)
208
+
.expect("Failed to parse issuer URL"),
209
+
account_did: None,
210
+
scopes,
211
+
request_uri: par_response.request_uri.to_cowstr().into_static(),
212
+
authserver_token_endpoint: metadata.server_metadata.token_endpoint.clone(),
213
+
authserver_revocation_endpoint: metadata.server_metadata.revocation_endpoint.clone(),
214
+
pkce_verifier: verifier,
215
+
dpop_data,
216
+
};
217
+
218
+
Ok(auth_req_data)
219
+
} else if metadata
220
+
.server_metadata
221
+
.require_pushed_authorization_requests
222
+
== Some(true)
223
+
{
224
+
Err(Error::NoEndpoint(CowStr::new_static(
225
+
"server requires PAR but no endpoint is available",
226
+
)))
227
+
} else {
228
+
todo!("use of PAR is mandatory")
229
+
}
230
+
}
231
+
232
+
pub async fn refresh<'r, T>(
233
+
client: &T,
234
+
mut session_data: ClientSessionData<'r>,
235
+
metadata: &OAuthMetadata,
236
+
) -> Result<ClientSessionData<'r>>
237
+
where
238
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
239
+
{
240
+
let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else {
241
+
return Err(Error::TokenRefresh);
242
+
};
243
+
244
+
// /!\ IMPORTANT /!\
245
+
//
246
+
// The "sub" MUST be a DID, whose issuer authority is indeed the server we
247
+
// are trying to obtain credentials from. Note that we are doing this
248
+
// *before* we actually try to refresh the token:
249
+
// 1) To avoid unnecessary refresh
250
+
// 2) So that the refresh is the last async operation, ensuring as few
251
+
// async operations happen before the result gets a chance to be stored.
252
+
let aud = client
253
+
.verify_issuer(&metadata.server_metadata, &session_data.token_set.sub)
254
+
.await?;
255
+
let iss = metadata.server_metadata.issuer.clone();
256
+
257
+
let response = oauth_request::<OAuthTokenResponse, T, DpopClientData>(
258
+
client,
259
+
&mut session_data.dpop_data,
260
+
OAuthRequest::Refresh(RefreshRequestParameters {
261
+
grant_type: TokenGrantType::RefreshToken,
262
+
refresh_token: refresh_token.clone(),
263
+
scope: None,
264
+
}),
265
+
metadata,
266
+
)
267
+
.await?;
268
+
269
+
let expires_at = response.expires_in.and_then(|expires_in| {
270
+
let now = Datetime::now();
271
+
now.as_ref()
272
+
.checked_add_signed(TimeDelta::seconds(expires_in))
273
+
.map(Datetime::new)
274
+
});
275
+
276
+
session_data.update_with_tokens(TokenSet {
277
+
iss,
278
+
sub: session_data.token_set.sub.clone(),
279
+
aud: CowStr::Owned(aud.to_smolstr()),
280
+
scope: response.scope.map(CowStr::Owned),
281
+
access_token: CowStr::Owned(response.access_token),
282
+
refresh_token: response.refresh_token.map(CowStr::Owned),
283
+
token_type: response.token_type,
284
+
expires_at,
285
+
});
286
+
287
+
Ok(session_data)
288
+
}
289
+
290
+
pub async fn exchange_code<'r, T, D>(
291
+
client: &T,
292
+
data_source: &'r mut D,
293
+
code: &str,
294
+
verifier: &str,
295
+
metadata: &OAuthMetadata,
296
+
) -> Result<TokenSet<'r>>
297
+
where
298
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
299
+
D: DpopDataSource,
300
+
{
301
+
let token_response = oauth_request::<OAuthTokenResponse, T, D>(
302
+
client,
303
+
data_source,
304
+
OAuthRequest::Token(TokenRequestParameters {
305
+
grant_type: TokenGrantType::AuthorizationCode,
306
+
code: code.into(),
307
+
redirect_uri: CowStr::Owned(
308
+
metadata.client_metadata.redirect_uris[0]
309
+
.clone()
310
+
.to_smolstr(),
311
+
), // ?
312
+
code_verifier: verifier.into(),
313
+
}),
314
+
metadata,
315
+
)
316
+
.await?;
317
+
let Some(sub) = token_response.sub else {
318
+
return Err(Error::Token("missing `sub` in token response".into()));
319
+
};
320
+
let sub = Did::new_owned(sub)?;
321
+
let iss = metadata.server_metadata.issuer.clone();
322
+
// /!\ IMPORTANT /!\
323
+
//
324
+
// The token_response MUST always be valid before the "sub" it contains
325
+
// can be trusted (see Atproto's OAuth spec for details).
326
+
let aud = client
327
+
.verify_issuer(&metadata.server_metadata, &sub)
328
+
.await?;
329
+
330
+
let expires_at = token_response.expires_in.and_then(|expires_in| {
331
+
Datetime::now()
332
+
.as_ref()
333
+
.checked_add_signed(TimeDelta::seconds(expires_in))
334
+
.map(Datetime::new)
335
+
});
336
+
Ok(TokenSet {
337
+
iss,
338
+
sub,
339
+
aud: CowStr::Owned(aud.to_smolstr()),
340
+
scope: token_response.scope.map(CowStr::Owned),
341
+
access_token: CowStr::Owned(token_response.access_token),
342
+
refresh_token: token_response.refresh_token.map(CowStr::Owned),
343
+
token_type: token_response.token_type,
344
+
expires_at,
345
+
})
346
+
}
347
+
348
+
pub async fn revoke<'r, T, D>(
349
+
client: &T,
350
+
data_source: &'r mut D,
351
+
token: &str,
352
+
metadata: &OAuthMetadata,
353
+
) -> Result<()>
354
+
where
355
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
356
+
D: DpopDataSource,
357
+
{
358
+
oauth_request::<(), T, D>(
359
+
client,
360
+
data_source,
361
+
OAuthRequest::Revocation(RevocationRequestParameters {
362
+
token: token.into(),
363
+
}),
364
+
metadata,
365
+
)
366
+
.await?;
367
+
Ok(())
368
+
}
369
+
370
+
pub async fn oauth_request<'de: 'r, 'r, O, T, D>(
371
+
client: &T,
372
+
data_source: &'r mut D,
373
+
request: OAuthRequest<'r>,
374
+
metadata: &OAuthMetadata,
375
+
) -> Result<O>
376
+
where
377
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
378
+
O: serde::de::DeserializeOwned,
379
+
D: DpopDataSource,
380
+
{
381
+
let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else {
382
+
return Err(Error::NoEndpoint(request.name()));
383
+
};
384
+
let client_assertions = build_auth(
385
+
metadata.keyset.as_ref(),
386
+
&metadata.server_metadata,
387
+
&metadata.client_metadata,
388
+
)?;
389
+
let body = match &request {
390
+
OAuthRequest::Token(params) => build_oauth_req_body(client_assertions, params)?,
391
+
OAuthRequest::Refresh(params) => build_oauth_req_body(client_assertions, params)?,
392
+
OAuthRequest::Revocation(params) => build_oauth_req_body(client_assertions, params)?,
393
+
OAuthRequest::PushedAuthorizationRequest(params) => {
394
+
build_oauth_req_body(client_assertions, params)?
395
+
}
396
+
_ => unimplemented!(),
397
+
};
398
+
let req = Request::builder()
399
+
.uri(url.to_string())
400
+
.method(Method::POST)
401
+
.header("Content-Type", "application/x-www-form-urlencoded")
402
+
.body(body.into_bytes())?;
403
+
let res = client
404
+
.dpop_server_call(data_source)
405
+
.send(req)
406
+
.await
407
+
.map_err(Error::DpopClient)?;
408
+
if res.status() == request.expected_status() {
409
+
let body = res.body();
410
+
if body.is_empty() {
411
+
// since an empty body cannot be deserialized, use “null” temporarily to allow deserialization to `()`.
412
+
Ok(serde_json::from_slice(b"null")?)
413
+
} else {
414
+
let output: O = serde_json::from_slice(body)?;
415
+
Ok(output)
416
+
}
417
+
} else if res.status().is_client_error() {
418
+
Err(Error::HttpStatusWithBody(
419
+
res.status(),
420
+
serde_json::from_slice(res.body())?,
421
+
))
422
+
} else {
423
+
Err(Error::HttpStatus(res.status()))
424
+
}
425
+
}
426
+
427
+
fn endpoint_for_req<'a, 'r>(
428
+
server_metadata: &'r OAuthAuthorizationServerMetadata<'a>,
429
+
request: &'r OAuthRequest,
430
+
) -> Option<&'r CowStr<'a>> {
431
+
match request {
432
+
OAuthRequest::Token(_) | OAuthRequest::Refresh(_) => Some(&server_metadata.token_endpoint),
433
+
OAuthRequest::Revocation(_) => server_metadata.revocation_endpoint.as_ref(),
434
+
OAuthRequest::Introspection => server_metadata.introspection_endpoint.as_ref(),
435
+
OAuthRequest::PushedAuthorizationRequest(_) => server_metadata
436
+
.pushed_authorization_request_endpoint
437
+
.as_ref(),
438
+
}
439
+
}
440
+
441
+
fn build_oauth_req_body<'a, S>(
442
+
client_assertions: ClientAssertions<'a>,
443
+
parameters: S,
444
+
) -> Result<String>
445
+
where
446
+
S: Serialize,
447
+
{
448
+
Ok(serde_html_form::to_string(RequestPayload {
449
+
client_id: client_assertions.client_id,
450
+
client_assertion_type: client_assertions.assertion_type,
451
+
client_assertion: client_assertions.assertion,
452
+
parameters,
453
+
})?)
454
+
}
455
+
456
+
#[derive(Debug, Clone, Default)]
457
+
pub struct ClientAssertions<'a> {
458
+
client_id: CowStr<'a>,
459
+
assertion_type: Option<CowStr<'a>>, // either none or `CLIENT_ASSERTION_TYPE_JWT_BEARER`
460
+
assertion: Option<CowStr<'a>>,
461
+
}
462
+
463
+
impl<'s> ClientAssertions<'s> {
464
+
pub fn new_id(client_id: CowStr<'s>) -> Self {
465
+
Self {
466
+
client_id,
467
+
assertion_type: None,
468
+
assertion: None,
469
+
}
470
+
}
471
+
}
472
+
473
+
fn build_auth<'a>(
474
+
keyset: Option<&Keyset>,
475
+
server_metadata: &OAuthAuthorizationServerMetadata<'a>,
476
+
client_metadata: &OAuthClientMetadata<'a>,
477
+
) -> Result<ClientAssertions<'a>> {
478
+
let method_supported = server_metadata
479
+
.token_endpoint_auth_methods_supported
480
+
.as_ref();
481
+
482
+
let client_id = client_metadata.client_id.to_cowstr().into_static();
483
+
if let Some(method) = client_metadata.token_endpoint_auth_method.as_ref() {
484
+
match (*method).as_ref() {
485
+
"private_key_jwt"
486
+
if method_supported
487
+
.as_ref()
488
+
.is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) =>
489
+
{
490
+
if let Some(keyset) = &keyset {
491
+
let mut algs = server_metadata
492
+
.token_endpoint_auth_signing_alg_values_supported
493
+
.clone()
494
+
.unwrap_or(vec![FALLBACK_ALG.into()]);
495
+
algs.sort_by(compare_algos);
496
+
let iat = Utc::now().timestamp();
497
+
return Ok(ClientAssertions {
498
+
client_id: client_id.clone(),
499
+
assertion_type: Some(CowStr::new_static(CLIENT_ASSERTION_TYPE_JWT_BEARER)),
500
+
assertion: Some(
501
+
keyset.create_jwt(
502
+
&algs,
503
+
// https://datatracker.ietf.org/doc/html/rfc7523#section-3
504
+
RegisteredClaims {
505
+
iss: Some(client_id.clone()),
506
+
sub: Some(client_id),
507
+
aud: Some(RegisteredClaimsAud::Single(
508
+
server_metadata.issuer.clone(),
509
+
)),
510
+
exp: Some(iat + 60),
511
+
// "iat" is required and **MUST** be less than one minute
512
+
// https://datatracker.ietf.org/doc/html/rfc9101
513
+
iat: Some(iat),
514
+
// atproto oauth-provider requires "jti" to be present
515
+
jti: Some(generate_nonce()),
516
+
..Default::default()
517
+
}
518
+
.into(),
519
+
)?,
520
+
),
521
+
});
522
+
}
523
+
}
524
+
"none"
525
+
if method_supported
526
+
.as_ref()
527
+
.is_some_and(|v| v.contains(&CowStr::new_static("none"))) =>
528
+
{
529
+
return Ok(ClientAssertions::new_id(client_id));
530
+
}
531
+
_ => {}
532
+
}
533
+
}
534
+
535
+
Err(Error::UnsupportedAuthMethod)
536
+
}
+17
crates/jacquard-oauth/src/resolver.rs
+17
crates/jacquard-oauth/src/resolver.rs
···
5
5
use jacquard_common::types::did_doc::DidDocument;
6
6
use jacquard_common::types::ident::AtIdentifier;
7
7
use jacquard_common::{http_client::HttpClient, types::did::Did};
8
+
use sha2::digest::const_oid::Arc;
8
9
use url::Url;
9
10
10
11
#[derive(thiserror::Error, Debug, miette::Diagnostic)]
···
41
42
42
43
#[async_trait::async_trait]
43
44
pub trait OAuthResolver: IdentityResolver + HttpClient {
45
+
async fn verify_issuer(
46
+
&self,
47
+
server_metadata: &OAuthAuthorizationServerMetadata<'_>,
48
+
sub: &Did<'_>,
49
+
) -> Result<Url, ResolverError> {
50
+
let (metadata, identity) = self.resolve_from_identity(sub).await?;
51
+
if metadata.issuer != server_metadata.issuer {
52
+
return Err(ResolverError::Did(format!("DIDs did not match")));
53
+
}
54
+
Ok(identity
55
+
.pds_endpoint()
56
+
.ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?)
57
+
}
44
58
async fn resolve_oauth(
45
59
&self,
46
60
input: &str,
···
146
160
Ok(as_metadata)
147
161
}
148
162
}
163
+
164
+
#[async_trait::async_trait]
165
+
impl<T: OAuthResolver + Sync + Send> OAuthResolver for std::sync::Arc<T> {}
149
166
150
167
pub async fn resolve_authorization_server<T: HttpClient + ?Sized>(
151
168
client: &T,
+41
-1
crates/jacquard-oauth/src/scopes.rs
+41
-1
crates/jacquard-oauth/src/scopes.rs
···
26
26
use jacquard_common::types::nsid::Nsid;
27
27
use jacquard_common::types::string::AtStrError;
28
28
use jacquard_common::{CowStr, IntoStatic};
29
+
use serde::de::Visitor;
30
+
use serde::{Deserialize, Serialize};
29
31
use smol_str::{SmolStr, ToSmolStr};
30
32
31
33
/// Represents an AT Protocol OAuth scope
···
51
53
Profile,
52
54
/// Email scope - access to user email address
53
55
Email,
56
+
}
57
+
58
+
impl Serialize for Scope<'_> {
59
+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
60
+
where
61
+
S: serde::Serializer,
62
+
{
63
+
serializer.serialize_str(&self.to_string_normalized())
64
+
}
65
+
}
66
+
67
+
impl<'de> Deserialize<'de> for Scope<'_> {
68
+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69
+
where
70
+
D: serde::Deserializer<'de>,
71
+
{
72
+
struct ScopeVisitor;
73
+
74
+
impl Visitor<'_> for ScopeVisitor {
75
+
type Value = Scope<'static>;
76
+
77
+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
78
+
write!(formatter, "a scope string")
79
+
}
80
+
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
81
+
where
82
+
E: serde::de::Error,
83
+
{
84
+
Scope::parse(v)
85
+
.map(|s| s.into_static())
86
+
.map_err(|e| serde::de::Error::custom(format!("{:?}", e)))
87
+
}
88
+
}
89
+
deserializer.deserialize_str(ScopeVisitor)
90
+
}
54
91
}
55
92
56
93
impl IntoStatic for Scope<'_> {
···
370
407
return CowStr::default();
371
408
}
372
409
373
-
let mut serialized: Vec<String> = scopes.iter().map(|scope| scope.to_string()).collect();
410
+
let mut serialized: Vec<String> = scopes
411
+
.iter()
412
+
.map(|scope| scope.to_string_normalized())
413
+
.collect();
374
414
375
415
serialized.sort();
376
416
serialized.join(" ").into()
+326
-8
crates/jacquard-oauth/src/session.rs
+326
-8
crates/jacquard-oauth/src/session.rs
···
1
-
use crate::types::TokenSet;
1
+
use std::sync::Arc;
2
+
3
+
use crate::{
4
+
atproto::{AtprotoClientMetadata, atproto_client_metadata},
5
+
authstore::ClientAuthStore,
6
+
dpop::DpopExt,
7
+
keyset::Keyset,
8
+
request::{OAuthMetadata, refresh},
9
+
resolver::OAuthResolver,
10
+
scopes::Scope,
11
+
types::TokenSet,
12
+
};
2
13
3
-
use jacquard_common::IntoStatic;
14
+
use dashmap::DashMap;
15
+
use jacquard_common::{
16
+
CowStr, IntoStatic,
17
+
http_client::HttpClient,
18
+
session::SessionStoreError,
19
+
types::{did::Did, string::Datetime},
20
+
};
4
21
use jose_jwk::Key;
5
22
use serde::{Deserialize, Serialize};
23
+
use smol_str::{SmolStr, format_smolstr};
24
+
use tokio::sync::Mutex;
25
+
use url::Url;
26
+
27
+
pub trait DpopDataSource {
28
+
fn key(&self) -> &Key;
29
+
fn authserver_nonce(&self) -> Option<CowStr<'_>>;
30
+
fn set_authserver_nonce(&mut self, nonce: CowStr<'_>);
31
+
fn host_nonce(&self) -> Option<CowStr<'_>>;
32
+
fn set_host_nonce(&mut self, nonce: CowStr<'_>);
33
+
}
6
34
35
+
/// Persisted information about an OAuth session. Used to resume an active session.
7
36
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
8
-
pub struct OauthSession<'s> {
37
+
pub struct ClientSessionData<'s> {
38
+
// Account DID for this session. Assuming only one active session per account, this can be used as "primary key" for storing and retrieving this information.
39
+
#[serde(borrow)]
40
+
pub account_did: Did<'s>,
41
+
42
+
// Identifier to distinguish this particular session for the account. Server backends generally support multiple sessions for the same account. This package will re-use the random 'state' token from the auth flow as the session ID.
43
+
pub session_id: CowStr<'s>,
44
+
45
+
// Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info.
46
+
pub host_url: Url,
47
+
48
+
// Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info.
49
+
pub authserver_url: Url,
50
+
51
+
// Full token endpoint
52
+
pub authserver_token_endpoint: CowStr<'s>,
53
+
54
+
// Full revocation endpoint, if it exists
55
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
56
+
pub authserver_revocation_endpoint: Option<CowStr<'s>>,
57
+
58
+
// The set of scopes approved for this session (returned in the initial token request)
59
+
pub scopes: Vec<Scope<'s>>,
60
+
61
+
#[serde(flatten)]
62
+
pub dpop_data: DpopClientData<'s>,
63
+
64
+
#[serde(flatten)]
65
+
pub token_set: TokenSet<'s>,
66
+
}
67
+
68
+
impl IntoStatic for ClientSessionData<'_> {
69
+
type Output = ClientSessionData<'static>;
70
+
71
+
fn into_static(self) -> Self::Output {
72
+
ClientSessionData {
73
+
authserver_url: self.authserver_url,
74
+
authserver_token_endpoint: self.authserver_token_endpoint.into_static(),
75
+
authserver_revocation_endpoint: self
76
+
.authserver_revocation_endpoint
77
+
.map(IntoStatic::into_static),
78
+
scopes: self.scopes.into_static(),
79
+
dpop_data: self.dpop_data.into_static(),
80
+
token_set: self.token_set.into_static(),
81
+
account_did: self.account_did.into_static(),
82
+
session_id: self.session_id.into_static(),
83
+
host_url: self.host_url,
84
+
}
85
+
}
86
+
}
87
+
88
+
impl ClientSessionData<'_> {
89
+
pub fn update_with_tokens(&mut self, token_set: TokenSet<'_>) {
90
+
if let Some(Ok(scopes)) = token_set
91
+
.scope
92
+
.as_ref()
93
+
.map(|scope| Scope::parse_multiple_reduced(&scope).map(IntoStatic::into_static))
94
+
{
95
+
self.scopes = scopes;
96
+
}
97
+
self.token_set = token_set.into_static();
98
+
}
99
+
}
100
+
101
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102
+
pub struct DpopClientData<'s> {
9
103
pub dpop_key: Key,
104
+
// Current auth server DPoP nonce
10
105
#[serde(borrow)]
11
-
pub token_set: TokenSet<'s>,
106
+
pub dpop_authserver_nonce: CowStr<'s>,
107
+
// Current host ("resource server", eg PDS) DPoP nonce
108
+
pub dpop_host_nonce: CowStr<'s>,
12
109
}
13
110
14
-
impl IntoStatic for OauthSession<'_> {
15
-
type Output = OauthSession<'static>;
111
+
impl IntoStatic for DpopClientData<'_> {
112
+
type Output = DpopClientData<'static>;
16
113
17
114
fn into_static(self) -> Self::Output {
18
-
OauthSession {
115
+
DpopClientData {
19
116
dpop_key: self.dpop_key,
20
-
token_set: self.token_set.into_static(),
117
+
dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(),
118
+
dpop_host_nonce: self.dpop_host_nonce.into_static(),
21
119
}
22
120
}
23
121
}
122
+
123
+
impl DpopDataSource for DpopClientData<'_> {
124
+
fn key(&self) -> &Key {
125
+
&self.dpop_key
126
+
}
127
+
fn authserver_nonce(&self) -> Option<CowStr<'_>> {
128
+
Some(self.dpop_authserver_nonce.clone())
129
+
}
130
+
131
+
fn host_nonce(&self) -> Option<CowStr<'_>> {
132
+
Some(self.dpop_host_nonce.clone())
133
+
}
134
+
135
+
fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) {
136
+
self.dpop_authserver_nonce = nonce.into_static();
137
+
}
138
+
139
+
fn set_host_nonce(&mut self, nonce: CowStr<'_>) {
140
+
self.dpop_host_nonce = nonce.into_static();
141
+
}
142
+
}
143
+
144
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145
+
pub struct AuthRequestData<'s> {
146
+
// The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information.
147
+
#[serde(borrow)]
148
+
pub state: CowStr<'s>,
149
+
150
+
// URL of the auth server (eg, PDS or entryway)
151
+
pub authserver_url: Url,
152
+
153
+
// If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response.
154
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
155
+
pub account_did: Option<Did<'s>>,
156
+
157
+
// OAuth scope strings
158
+
pub scopes: Vec<Scope<'s>>,
159
+
160
+
// unique token in URI format, which will be used by the client in the auth flow redirect
161
+
pub request_uri: CowStr<'s>,
162
+
163
+
// Full token endpoint URL
164
+
pub authserver_token_endpoint: CowStr<'s>,
165
+
166
+
// Full revocation endpoint, if it exists
167
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
168
+
pub authserver_revocation_endpoint: Option<CowStr<'s>>,
169
+
170
+
// The secret token/nonce which a code challenge was generated from
171
+
pub pkce_verifier: CowStr<'s>,
172
+
173
+
#[serde(flatten)]
174
+
pub dpop_data: DpopReqData<'s>,
175
+
}
176
+
177
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
178
+
pub struct DpopReqData<'s> {
179
+
// The secret cryptographic key generated by the client for this specific OAuth session
180
+
pub dpop_key: Key,
181
+
// Server-provided DPoP nonce from auth request (PAR)
182
+
#[serde(borrow)]
183
+
pub dpop_authserver_nonce: Option<CowStr<'s>>,
184
+
}
185
+
186
+
impl DpopDataSource for DpopReqData<'_> {
187
+
fn key(&self) -> &Key {
188
+
&self.dpop_key
189
+
}
190
+
fn authserver_nonce(&self) -> Option<CowStr<'_>> {
191
+
self.dpop_authserver_nonce.clone()
192
+
}
193
+
194
+
fn host_nonce(&self) -> Option<CowStr<'_>> {
195
+
None
196
+
}
197
+
198
+
fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) {
199
+
self.dpop_authserver_nonce = Some(nonce.into_static());
200
+
}
201
+
202
+
fn set_host_nonce(&mut self, _nonce: CowStr<'_>) {}
203
+
}
204
+
205
+
#[derive(Clone, Debug)]
206
+
pub struct ClientData<'s> {
207
+
pub keyset: Option<Keyset>,
208
+
pub config: AtprotoClientMetadata<'s>,
209
+
}
210
+
211
+
pub struct ClientSession<'s> {
212
+
pub keyset: Option<Keyset>,
213
+
pub config: AtprotoClientMetadata<'s>,
214
+
pub session_data: ClientSessionData<'s>,
215
+
}
216
+
217
+
impl<'s> ClientSession<'s> {
218
+
pub fn new(
219
+
ClientData { keyset, config }: ClientData<'s>,
220
+
session_data: ClientSessionData<'s>,
221
+
) -> Self {
222
+
Self {
223
+
keyset,
224
+
config,
225
+
session_data,
226
+
}
227
+
}
228
+
229
+
pub async fn metadata<T: HttpClient + OAuthResolver + Send + Sync>(
230
+
&self,
231
+
client: &T,
232
+
) -> Result<OAuthMetadata, Error> {
233
+
Ok(OAuthMetadata {
234
+
server_metadata: client
235
+
.get_authorization_server_metadata(&self.session_data.authserver_url)
236
+
.await
237
+
.map_err(|e| Error::ServerAgent(crate::request::Error::ResolverError(e)))?,
238
+
client_metadata: atproto_client_metadata(self.config.clone(), &self.keyset)
239
+
.unwrap()
240
+
.into_static(),
241
+
keyset: self.keyset.clone(),
242
+
})
243
+
}
244
+
}
245
+
246
+
#[derive(thiserror::Error, Debug)]
247
+
pub enum Error {
248
+
#[error(transparent)]
249
+
ServerAgent(#[from] crate::request::Error),
250
+
#[error(transparent)]
251
+
Store(#[from] SessionStoreError),
252
+
#[error("session does not exist")]
253
+
SessionNotFound,
254
+
}
255
+
256
+
pub struct SessionRegistry<T, S>
257
+
where
258
+
T: OAuthResolver,
259
+
S: ClientAuthStore,
260
+
{
261
+
pub store: Arc<S>,
262
+
pub client: Arc<T>,
263
+
pub client_data: ClientData<'static>,
264
+
pending: DashMap<SmolStr, Arc<Mutex<()>>>,
265
+
}
266
+
267
+
impl<T, S> SessionRegistry<T, S>
268
+
where
269
+
S: ClientAuthStore,
270
+
T: OAuthResolver,
271
+
{
272
+
pub fn new(store: S, client: Arc<T>, client_data: ClientData<'static>) -> Self {
273
+
let store = Arc::new(store);
274
+
Self {
275
+
store: Arc::clone(&store),
276
+
client,
277
+
client_data,
278
+
pending: DashMap::new(),
279
+
}
280
+
}
281
+
}
282
+
283
+
impl<T, S> SessionRegistry<T, S>
284
+
where
285
+
S: ClientAuthStore + Send + Sync + 'static,
286
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
287
+
{
288
+
async fn get_refreshed(
289
+
&self,
290
+
did: &Did<'_>,
291
+
session_id: &str,
292
+
) -> Result<ClientSessionData<'_>, Error> {
293
+
let key = format_smolstr!("{}_{}", did, session_id);
294
+
let lock = self
295
+
.pending
296
+
.entry(key)
297
+
.or_insert_with(|| Arc::new(Mutex::new(())))
298
+
.clone();
299
+
let _guard = lock.lock().await;
300
+
301
+
let mut session = self
302
+
.store
303
+
.get_session(did, session_id)
304
+
.await?
305
+
.ok_or(Error::SessionNotFound)?;
306
+
if let Some(expires_at) = &session.token_set.expires_at {
307
+
if expires_at > &Datetime::now() {
308
+
return Ok(session);
309
+
}
310
+
}
311
+
let metadata = OAuthMetadata::new(&self.client, &self.client_data, &session).await?;
312
+
session = refresh(self.client.as_ref(), session, &metadata).await?;
313
+
self.store.upsert_session(session.clone()).await?;
314
+
315
+
Ok(session)
316
+
}
317
+
pub async fn get(
318
+
&self,
319
+
did: &Did<'_>,
320
+
session_id: &str,
321
+
refresh: bool,
322
+
) -> Result<ClientSessionData<'_>, Error> {
323
+
if refresh {
324
+
self.get_refreshed(did, session_id).await
325
+
} else {
326
+
// TODO: cached?
327
+
self.store
328
+
.get_session(did, session_id)
329
+
.await?
330
+
.ok_or(Error::SessionNotFound)
331
+
}
332
+
}
333
+
pub async fn set(&self, value: ClientSessionData<'_>) -> Result<(), Error> {
334
+
self.store.upsert_session(value).await?;
335
+
Ok(())
336
+
}
337
+
pub async fn del(&self, did: &Did<'_>, session_id: &str) -> Result<(), Error> {
338
+
self.store.delete_session(did, session_id).await?;
339
+
Ok(())
340
+
}
341
+
}
+3
-2
crates/jacquard-oauth/src/types.rs
+3
-2
crates/jacquard-oauth/src/types.rs
···
13
13
pub use self::token::*;
14
14
use jacquard_common::CowStr;
15
15
use serde::Deserialize;
16
+
use url::Url;
16
17
17
-
#[derive(Debug, Deserialize)]
18
+
#[derive(Debug, Deserialize, Clone, Copy)]
18
19
pub enum AuthorizeOptionPrompt {
19
20
Login,
20
21
None,
···
35
36
36
37
#[derive(Debug)]
37
38
pub struct AuthorizeOptions<'s> {
38
-
pub redirect_uri: Option<CowStr<'s>>,
39
+
pub redirect_uri: Option<Url>,
39
40
pub scopes: Vec<Scope<'s>>,
40
41
pub prompt: Option<AuthorizeOptionPrompt>,
41
42
pub state: Option<CowStr<'s>>,
+3
-3
crates/jacquard-oauth/src/types/request.rs
+3
-3
crates/jacquard-oauth/src/types/request.rs
···
27
27
}
28
28
29
29
#[derive(Serialize, Deserialize)]
30
-
pub struct PushedAuthorizationRequestParameters<'a> {
30
+
pub struct ParParameters<'a> {
31
31
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
32
32
pub response_type: AuthorizationResponseType,
33
33
#[serde(borrow)]
···
115
115
}
116
116
}
117
117
118
-
impl IntoStatic for PushedAuthorizationRequestParameters<'_> {
119
-
type Output = PushedAuthorizationRequestParameters<'static>;
118
+
impl IntoStatic for ParParameters<'_> {
119
+
type Output = ParParameters<'static>;
120
120
121
121
fn into_static(self) -> Self::Output {
122
122
Self::Output {
+8
-36
crates/jacquard-oauth/src/types/response.rs
+8
-36
crates/jacquard-oauth/src/types/response.rs
···
1
-
use jacquard_common::{CowStr, IntoStatic};
2
1
use serde::{Deserialize, Serialize};
2
+
use smol_str::SmolStr;
3
3
4
4
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
5
-
pub struct OAuthParResponse<'r> {
6
-
#[serde(borrow)]
7
-
pub request_uri: CowStr<'r>,
5
+
pub struct OAuthParResponse {
6
+
pub request_uri: SmolStr,
8
7
pub expires_in: Option<u32>,
9
8
}
10
9
···
16
15
17
16
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
18
17
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
19
-
pub struct OAuthTokenResponse<'r> {
20
-
#[serde(borrow)]
21
-
pub access_token: CowStr<'r>,
18
+
pub struct OAuthTokenResponse {
19
+
pub access_token: SmolStr,
22
20
pub token_type: OAuthTokenType,
23
21
pub expires_in: Option<i64>,
24
-
pub refresh_token: Option<CowStr<'r>>,
25
-
pub scope: Option<CowStr<'r>>,
22
+
pub refresh_token: Option<SmolStr>,
23
+
pub scope: Option<SmolStr>,
26
24
// ATPROTO extension: add the sub claim to the token response to allow
27
25
// clients to resolve the PDS url (audience) using the did resolution
28
26
// mechanism.
29
-
pub sub: Option<CowStr<'r>>,
30
-
}
31
-
32
-
impl IntoStatic for OAuthTokenResponse<'_> {
33
-
type Output = OAuthTokenResponse<'static>;
34
-
35
-
fn into_static(self) -> Self::Output {
36
-
OAuthTokenResponse {
37
-
access_token: self.access_token.into_static(),
38
-
token_type: self.token_type,
39
-
expires_in: self.expires_in,
40
-
refresh_token: self.refresh_token.map(|s| s.into_static()),
41
-
scope: self.scope.map(|s| s.into_static()),
42
-
sub: self.sub.map(|s| s.into_static()),
43
-
}
44
-
}
45
-
}
46
-
47
-
impl IntoStatic for OAuthParResponse<'_> {
48
-
type Output = OAuthParResponse<'static>;
49
-
50
-
fn into_static(self) -> Self::Output {
51
-
OAuthParResponse {
52
-
request_uri: self.request_uri.into_static(),
53
-
expires_in: self.expires_in,
54
-
}
55
-
}
27
+
pub sub: Option<SmolStr>,
56
28
}
+95
crates/jacquard-oauth/src/utils.rs
+95
crates/jacquard-oauth/src/utils.rs
···
1
+
use base64::Engine;
2
+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3
+
use elliptic_curve::SecretKey;
4
+
use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr};
5
+
use jose_jwk::{Key, crypto};
6
+
use rand::{CryptoRng, RngCore, rngs::ThreadRng};
7
+
use sha2::{Digest, Sha256};
8
+
use smol_str::ToSmolStr;
9
+
use std::cmp::Ordering;
10
+
11
+
use crate::{FALLBACK_ALG, types::OAuthAuthorizationServerMetadata};
12
+
13
+
pub fn generate_key(allowed_algos: &[CowStr]) -> Option<Key> {
14
+
for alg in allowed_algos {
15
+
#[allow(clippy::single_match)]
16
+
match alg.as_ref() {
17
+
"ES256" => {
18
+
return Some(Key::from(&crypto::Key::from(
19
+
SecretKey::<p256::NistP256>::random(&mut ThreadRng::default()),
20
+
)));
21
+
}
22
+
_ => {
23
+
// TODO: Implement other algorithms?
24
+
}
25
+
}
26
+
}
27
+
None
28
+
}
29
+
30
+
pub fn generate_nonce() -> CowStr<'static> {
31
+
URL_SAFE_NO_PAD
32
+
.encode(get_random_values::<_, 16>(&mut ThreadRng::default()))
33
+
.into()
34
+
}
35
+
36
+
pub fn generate_verifier() -> CowStr<'static> {
37
+
URL_SAFE_NO_PAD
38
+
.encode(get_random_values::<_, 43>(&mut ThreadRng::default()))
39
+
.into()
40
+
}
41
+
42
+
pub fn get_random_values<R, const LEN: usize>(rng: &mut R) -> [u8; LEN]
43
+
where
44
+
R: RngCore + CryptoRng,
45
+
{
46
+
let mut bytes = [0u8; LEN];
47
+
rng.fill_bytes(&mut bytes);
48
+
bytes
49
+
}
50
+
51
+
// 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order)
52
+
pub fn compare_algos(a: &CowStr, b: &CowStr) -> Ordering {
53
+
if a.as_ref() == "ES256K" {
54
+
return Ordering::Less;
55
+
}
56
+
if b.as_ref() == "ES256K" {
57
+
return Ordering::Greater;
58
+
}
59
+
for prefix in ["ES", "PS", "RS"] {
60
+
if let Some(stripped_a) = a.strip_prefix(prefix) {
61
+
if let Some(stripped_b) = b.strip_prefix(prefix) {
62
+
if let (Ok(len_a), Ok(len_b)) =
63
+
(stripped_a.parse::<u32>(), stripped_b.parse::<u32>())
64
+
{
65
+
return len_a.cmp(&len_b);
66
+
}
67
+
} else {
68
+
return Ordering::Less;
69
+
}
70
+
} else if b.starts_with(prefix) {
71
+
return Ordering::Greater;
72
+
}
73
+
}
74
+
Ordering::Equal
75
+
}
76
+
77
+
pub fn generate_pkce() -> (CowStr<'static>, CowStr<'static>) {
78
+
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
79
+
let verifier = generate_verifier();
80
+
(
81
+
URL_SAFE_NO_PAD
82
+
.encode(Sha256::digest(&verifier.as_str()))
83
+
.into(),
84
+
verifier,
85
+
)
86
+
}
87
+
88
+
pub fn generate_dpop_key(metadata: &OAuthAuthorizationServerMetadata) -> Option<Key> {
89
+
let mut algs = metadata
90
+
.dpop_signing_alg_values_supported
91
+
.clone()
92
+
.unwrap_or(vec![FALLBACK_ALG.into()]);
93
+
algs.sort_by(compare_algos);
94
+
generate_key(&algs)
95
+
}
+1
crates/jacquard/src/client/at_client.rs
+1
crates/jacquard/src/client/at_client.rs
···
124
124
pub async fn set_session(&self, session: AuthSession) -> Result<(), SessionStoreError> {
125
125
let s = session.clone();
126
126
let did = s.did().clone().into_static();
127
+
self.refresh_lock.lock().await.replace(did.clone());
127
128
self.tokens.set(did, session).await
128
129
}
129
130