A better Rust ATProto crate

bunch of progress, different approach

Orual 36ef115b a0fe35e3

+34 -4
Cargo.lock
···
[[package]]
name = "bon"
-
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c2529c31017402be841eb45892278a6c21a000c0a17643af326c73a73f83f0fb"
dependencies = [
"bon-macros",
"rustversion",
···
[[package]]
name = "bon-macros"
-
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d82020dadcb845a345591863adb65d74fa8dc5c18a0b6d408470e13b7adc7005"
dependencies = [
"darling",
"ident_case",
···
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
···
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"elliptic-curve",
"http",
"jacquard-common",
···
"p256",
"rand 0.8.5",
"rand_core 0.6.4",
"serde",
"serde_html_form",
"serde_json",
···
"signature",
"smol_str",
"thiserror 2.0.17",
"url",
"uuid",
]
···
[[package]]
name = "bon"
+
version = "3.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f44aa969f86ffb99e5c2d51f393ec9ed6e9fe2f47b609c917b0071f129854d29"
dependencies = [
"bon-macros",
"rustversion",
···
[[package]]
name = "bon-macros"
+
version = "3.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e1e78cd86b6a6515d87392332fd63c4950ed3e50eab54275259a5f59f3666f90"
dependencies = [
"darling",
"ident_case",
···
]
[[package]]
+
name = "crossbeam-utils"
+
version = "0.8.21"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "dashmap"
+
version = "6.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+
dependencies = [
+
"cfg-if",
+
"crossbeam-utils",
+
"hashbrown 0.14.5",
+
"lock_api",
+
"once_cell",
+
"parking_lot_core",
+
]
+
+
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+
[[package]]
+
name = "hashbrown"
+
version = "0.14.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
···
dependencies = [
"async-trait",
"base64 0.22.1",
+
"bon",
"chrono",
+
"dashmap",
"elliptic-curve",
"http",
"jacquard-common",
···
"p256",
"rand 0.8.5",
"rand_core 0.6.4",
+
"reqwest",
"serde",
"serde_html_form",
"serde_json",
···
"signature",
"smol_str",
"thiserror 2.0.17",
+
"tokio",
"url",
"uuid",
]
+84 -3
crates/jacquard-common/src/cowstr.rs
···
-
use serde::{Deserialize, Serialize};
-
use smol_str::SmolStr;
use std::{
borrow::Cow,
fmt,
···
#[inline]
pub unsafe fn from_utf8_unchecked(s: &'s [u8]) -> Self {
unsafe { Self::Owned(SmolStr::new(std::str::from_utf8_unchecked(s))) }
}
}
···
}
}
impl From<CowStr<'_>> for Box<str> {
#[inline]
fn from(s: CowStr<'_>) -> Self {
···
}
}
-
impl<'de: 'a, 'a> Deserialize<'de> for CowStr<'a> {
#[inline]
fn deserialize<D>(deserializer: D) -> Result<CowStr<'a>, D::Error>
where
···
}
deserializer.deserialize_str(CowStrVisitor)
}
}
···
+
use serde::{Deserialize, Serialize, de::DeserializeOwned};
+
use smol_str::{SmolStr, ToSmolStr};
use std::{
borrow::Cow,
fmt,
···
#[inline]
pub unsafe fn from_utf8_unchecked(s: &'s [u8]) -> Self {
unsafe { Self::Owned(SmolStr::new(std::str::from_utf8_unchecked(s))) }
+
}
+
+
/// Returns a reference to the underlying string slice.
+
#[inline]
+
pub fn as_str(&self) -> &str {
+
match self {
+
CowStr::Borrowed(s) => s,
+
CowStr::Owned(s) => s.as_str(),
+
}
}
}
···
}
}
+
impl From<CowStr<'_>> for SmolStr {
+
#[inline]
+
fn from(s: CowStr<'_>) -> Self {
+
match s {
+
CowStr::Borrowed(s) => SmolStr::new(s),
+
CowStr::Owned(s) => SmolStr::new(s),
+
}
+
}
+
}
+
+
impl From<SmolStr> for CowStr<'_> {
+
#[inline]
+
fn from(s: SmolStr) -> Self {
+
CowStr::Owned(s)
+
}
+
}
+
impl From<CowStr<'_>> for Box<str> {
#[inline]
fn from(s: CowStr<'_>) -> Self {
···
}
}
+
// impl<'de> Deserialize<'de> for CowStr<'_> {
+
// #[inline]
+
// fn deserialize<D>(deserializer: D) -> Result<CowStr<'static>, D::Error>
+
// where
+
// D: serde::Deserializer<'de>,
+
// {
+
// struct CowStrVisitor;
+
+
// impl<'de> serde::de::Visitor<'de> for CowStrVisitor {
+
// type Value = CowStr<'static>;
+
+
// #[inline]
+
// fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+
// write!(formatter, "a string")
+
// }
+
+
// #[inline]
+
// fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+
// where
+
// E: serde::de::Error,
+
// {
+
// Ok(CowStr::copy_from_str(v))
+
// }
+
+
// #[inline]
+
// fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
+
// where
+
// E: serde::de::Error,
+
// {
+
// Ok(v.into())
+
// }
+
// }
+
+
// deserializer.deserialize_str(CowStrVisitor)
+
// }
+
// }
+
+
impl<'de, 'a, 'b> Deserialize<'de> for CowStr<'a>
+
where
+
'de: 'a,
+
{
#[inline]
fn deserialize<D>(deserializer: D) -> Result<CowStr<'a>, D::Error>
where
···
}
deserializer.deserialize_str(CowStrVisitor)
+
}
+
}
+
+
/// Convert to a CowStr.
+
pub trait ToCowStr {
+
/// Convert to a CowStr.
+
fn to_cowstr(&self) -> CowStr<'_>;
+
}
+
+
impl<T> ToCowStr for T
+
where
+
T: fmt::Display + ?Sized,
+
{
+
fn to_cowstr(&self) -> CowStr<'_> {
+
CowStr::Owned(smol_str::format_smolstr!("{}", self))
}
}
+18 -1
crates/jacquard-common/src/ident_resolver.rs
···
/// - PLC directory or Slingshot for `did:plc`
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
-
#[async_trait::async_trait]
pub trait IdentityResolver {
/// Access options for validation decisions in default methods
fn options(&self) -> &ResolverOptions;
···
let did = self.resolve_handle(handle).await?;
let pds = self.pds_for_did(&did).await?;
Ok((did, pds))
}
}
···
/// - PLC directory or Slingshot for `did:plc`
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
+
#[async_trait::async_trait()]
pub trait IdentityResolver {
/// Access options for validation decisions in default methods
fn options(&self) -> &ResolverOptions;
···
let did = self.resolve_handle(handle).await?;
let pds = self.pds_for_did(&did).await?;
Ok((did, pds))
+
}
+
}
+
+
#[async_trait::async_trait]
+
impl<T: IdentityResolver + Sync + Send> IdentityResolver for std::sync::Arc<T> {
+
fn options(&self) -> &ResolverOptions {
+
self.as_ref().options()
+
}
+
+
/// Resolve handle
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
+
self.as_ref().resolve_handle(handle).await
+
}
+
+
/// Resolve DID document
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
+
self.as_ref().resolve_did_doc(did).await
}
}
-6
crates/jacquard-common/src/types/datetime.rs
···
}
}
-
impl AsRef<str> for Datetime {
-
fn as_ref(&self) -> &str {
-
self.as_str()
-
}
-
}
-
impl fmt::Display for Datetime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
···
}
}
impl fmt::Display for Datetime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
+4
crates/jacquard-oauth/Cargo.toml
···
http.workspace = true
rand = { version = "0.8.5", features = ["small_rng"] }
async-trait = "0.1.89"
···
http.workspace = true
rand = { version = "0.8.5", features = ["small_rng"] }
async-trait = "0.1.89"
+
dashmap = "6.1.0"
+
tokio = { version = "1.47.1", features = ["sync"] }
+
bon = "3.8.0"
+
reqwest.workspace = true
+103 -100
crates/jacquard-oauth/src/atproto.rs
···
}
}
-
pub fn localhost_client_metadata<'s>(
-
redirect_uris: Option<Vec<Url>>,
-
scopes: Option<&'s [Scope<'s>]>,
-
) -> Result<OAuthClientMetadata<'s>> {
-
// validate redirect_uris
-
if let Some(redirect_uris) = &redirect_uris {
-
for redirect_uri in redirect_uris {
-
if redirect_uri.scheme() != "http" {
-
return Err(Error::LocalhostClient(LocalhostClientError::NotHttpScheme));
-
}
-
if redirect_uri.host().map(|h| h.to_owned()) == Some(Host::parse("localhost").unwrap())
-
{
-
return Err(Error::LocalhostClient(LocalhostClientError::Localhost));
-
}
-
if redirect_uri
-
.host()
-
.map(|h| h.to_owned())
-
.map_or(true, |host| {
-
host != Host::parse("127.0.0.1").unwrap()
-
&& host != Host::parse("[::1]").unwrap()
-
})
-
{
-
return Err(Error::LocalhostClient(
-
LocalhostClientError::NotLoopbackHost,
-
));
-
}
-
}
-
}
-
// determine client_id
-
#[derive(serde::Serialize)]
-
struct Parameters<'a> {
-
#[serde(skip_serializing_if = "Option::is_none")]
-
redirect_uri: Option<Vec<Url>>,
-
#[serde(skip_serializing_if = "Option::is_none")]
-
scope: Option<CowStr<'a>>,
-
}
-
let query = serde_html_form::to_string(Parameters {
-
redirect_uri: redirect_uris.clone(),
-
scope: scopes.map(|s| Scope::serialize_multiple(s)),
-
})?;
-
let mut client_id = String::from("http://localhost");
-
if !query.is_empty() {
-
client_id.push_str(&format!("?{query}"));
-
}
-
Ok(OAuthClientMetadata {
-
client_id: Url::parse(&client_id).unwrap(),
-
client_uri: None,
-
redirect_uris: redirect_uris.unwrap_or(vec![
-
Url::from_str("http://127.0.0.1/").unwrap(),
-
Url::from_str("http://[::1]/").unwrap(),
-
]),
-
scope: None,
-
grant_types: None, // will be set to `authorization_code` and `refresh_token`
-
token_endpoint_auth_method: Some(CowStr::new_static("none")),
-
dpop_bound_access_tokens: None, // will be set to `true`
-
jwks_uri: None,
-
jwks: None,
-
token_endpoint_auth_signing_alg: None,
-
})
-
}
-
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AtprotoClientMetadata<'m> {
pub client_id: Url,
pub client_uri: Option<Url>,
pub redirect_uris: Vec<Url>,
-
pub token_endpoint_auth_method: AuthMethod,
pub grant_types: Vec<GrantType>,
pub scopes: Vec<Scope<'m>>,
pub jwks_uri: Option<Url>,
-
pub token_endpoint_auth_signing_alg: Option<CowStr<'m>>,
}
pub fn atproto_client_metadata<'m>(
···
if !metadata.scopes.contains(&Scope::Atproto) {
return Err(Error::InvalidScope);
}
-
let (jwks_uri, mut jwks) = (metadata.jwks_uri, None);
-
match metadata.token_endpoint_auth_method {
-
AuthMethod::None => {
-
if metadata.token_endpoint_auth_signing_alg.is_some() {
-
return Err(Error::AuthSigningAlg);
-
}
-
}
-
AuthMethod::PrivateKeyJwt => {
-
if let Some(keyset) = keyset {
-
if metadata.token_endpoint_auth_signing_alg.is_none() {
-
return Err(Error::AuthSigningAlg);
-
}
-
if jwks_uri.is_none() {
-
jwks = Some(keyset.public_jwks());
-
}
-
} else {
-
return Err(Error::EmptyJwks);
-
}
-
}
-
}
Ok(OAuthClientMetadata {
client_id: metadata.client_id,
client_uri: metadata.client_uri,
redirect_uris: metadata.redirect_uris,
-
token_endpoint_auth_method: Some(metadata.token_endpoint_auth_method.into()),
grant_types: Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()),
scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())),
dpop_bound_access_tokens: Some(true),
jwks_uri,
jwks,
-
token_endpoint_auth_signing_alg: metadata.token_endpoint_auth_signing_alg,
})
}
···
#[test]
fn test_localhost_client_metadata_default() {
assert_eq!(
-
localhost_client_metadata(None, None).expect("failed to convert metadata"),
OAuthClientMetadata {
client_id: Url::from_str("http://localhost").unwrap(),
client_uri: None,
···
#[test]
fn test_localhost_client_metadata_custom() {
assert_eq!(
-
localhost_client_metadata(
Some(vec![
Url::from_str("http://127.0.0.1/callback").unwrap(),
Url::from_str("http://[::1]/callback").unwrap(),
···
Scope::Transition(TransitionScope::Generic),
Scope::parse("account:email").unwrap()
]
-
.as_slice()
)
-
)
.expect("failed to convert metadata"),
OAuthClientMetadata {
client_id: Url::from_str(
···
#[test]
fn test_localhost_client_metadata_invalid() {
{
-
let err = localhost_client_metadata(
-
Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]),
-
None,
)
.expect_err("expected to fail");
assert!(matches!(
···
));
}
{
-
let err = localhost_client_metadata(
-
Some(vec![Url::from_str("http://localhost:8000/").unwrap()]),
-
None,
)
.expect_err("expected to fail");
assert!(matches!(
···
));
}
{
-
let err = localhost_client_metadata(
-
Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]),
-
None,
)
.expect_err("expected to fail");
assert!(matches!(
···
client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(),
client_uri: Some(Url::from_str("https://example.com").unwrap()),
redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()],
-
token_endpoint_auth_method: AuthMethod::PrivateKeyJwt,
grant_types: vec![GrantType::AuthorizationCode],
scopes: vec![Scope::Atproto],
jwks_uri: None,
-
token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")),
};
{
let metadata = metadata.clone();
···
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AtprotoClientMetadata<'m> {
pub client_id: Url,
pub client_uri: Option<Url>,
pub redirect_uris: Vec<Url>,
pub grant_types: Vec<GrantType>,
pub scopes: Vec<Scope<'m>>,
pub jwks_uri: Option<Url>,
+
}
+
+
impl<'m> AtprotoClientMetadata<'m> {
+
pub fn new(
+
client_id: Url,
+
client_uri: Option<Url>,
+
redirect_uris: Vec<Url>,
+
grant_types: Vec<GrantType>,
+
scopes: Vec<Scope<'m>>,
+
jwks_uri: Option<Url>,
+
) -> Self {
+
Self {
+
client_id,
+
client_uri,
+
redirect_uris,
+
grant_types,
+
scopes,
+
jwks_uri,
+
}
+
}
+
+
pub fn new_localhost(
+
mut redirect_uris: Option<Vec<Url>>,
+
scopes: Option<Vec<Scope<'m>>>,
+
) -> Self {
+
// coerce redirect uris to localhost
+
if let Some(redirect_uris) = &mut redirect_uris {
+
for redirect_uri in redirect_uris {
+
redirect_uri.set_host(Some("http://localhost")).unwrap();
+
}
+
}
+
// determine client_id
+
#[derive(serde::Serialize)]
+
struct Parameters<'a> {
+
#[serde(skip_serializing_if = "Option::is_none")]
+
redirect_uri: Option<Vec<Url>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
scope: Option<CowStr<'a>>,
+
}
+
let query = serde_html_form::to_string(Parameters {
+
redirect_uri: redirect_uris.clone(),
+
scope: scopes
+
.as_ref()
+
.map(|s| Scope::serialize_multiple(s.as_slice())),
+
})
+
.ok();
+
let mut client_id = String::from("http://localhost");
+
if let Some(query) = query
+
&& !query.is_empty()
+
{
+
client_id.push_str(&format!("?{query}"));
+
}
+
Self {
+
client_id: Url::parse(&client_id).unwrap(),
+
client_uri: None,
+
redirect_uris: redirect_uris.unwrap_or(vec![
+
Url::from_str("http://127.0.0.1/").unwrap(),
+
Url::from_str("http://[::1]/").unwrap(),
+
]),
+
grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
+
scopes: scopes.unwrap_or(vec![Scope::Atproto]),
+
jwks_uri: None,
+
}
+
}
}
pub fn atproto_client_metadata<'m>(
···
if !metadata.scopes.contains(&Scope::Atproto) {
return Err(Error::InvalidScope);
}
+
let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset {
+
let jwks = if metadata.jwks_uri.is_none() {
+
Some(keyset.public_jwks())
+
} else {
+
None
+
};
+
(AuthMethod::PrivateKeyJwt, metadata.jwks_uri, jwks)
+
} else {
+
(AuthMethod::None, None, None)
+
};
+
Ok(OAuthClientMetadata {
client_id: metadata.client_id,
client_uri: metadata.client_uri,
redirect_uris: metadata.redirect_uris,
+
token_endpoint_auth_method: Some(auth_method.into()),
grant_types: Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()),
scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())),
dpop_bound_access_tokens: Some(true),
jwks_uri,
jwks,
+
token_endpoint_auth_signing_alg: if keyset.is_some() {
+
Some(CowStr::new_static("ES256"))
+
} else {
+
None
+
},
})
}
···
#[test]
fn test_localhost_client_metadata_default() {
assert_eq!(
+
atproto_client_metadata(AtprotoClientMetadata::new_localhost(None, None), &None)
+
.unwrap(),
OAuthClientMetadata {
client_id: Url::from_str("http://localhost").unwrap(),
client_uri: None,
···
#[test]
fn test_localhost_client_metadata_custom() {
assert_eq!(
+
atproto_client_metadata(AtprotoClientMetadata::new_localhost(
Some(vec![
Url::from_str("http://127.0.0.1/callback").unwrap(),
Url::from_str("http://[::1]/callback").unwrap(),
···
Scope::Transition(TransitionScope::Generic),
Scope::parse("account:email").unwrap()
]
)
+
), &None)
.expect("failed to convert metadata"),
OAuthClientMetadata {
client_id: Url::from_str(
···
#[test]
fn test_localhost_client_metadata_invalid() {
{
+
let err = atproto_client_metadata(
+
AtprotoClientMetadata::new_localhost(
+
Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]),
+
None,
+
),
+
&None,
)
.expect_err("expected to fail");
assert!(matches!(
···
));
}
{
+
let err = atproto_client_metadata(
+
AtprotoClientMetadata::new_localhost(
+
Some(vec![Url::from_str("http://localhost:8000/").unwrap()]),
+
None,
+
),
+
&None,
)
.expect_err("expected to fail");
assert!(matches!(
···
));
}
{
+
let err = atproto_client_metadata(
+
AtprotoClientMetadata::new_localhost(
+
Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]),
+
None,
+
),
+
&None,
)
.expect_err("expected to fail");
assert!(matches!(
···
client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(),
client_uri: Some(Url::from_str("https://example.com").unwrap()),
redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()],
grant_types: vec![GrantType::AuthorizationCode],
scopes: vec![Scope::Atproto],
jwks_uri: None,
};
{
let metadata = metadata.clone();
+33
crates/jacquard-oauth/src/authstore.rs
···
···
+
use jacquard_common::{session::SessionStoreError, types::did::Did};
+
+
use crate::session::{AuthRequestData, ClientSessionData};
+
+
#[async_trait::async_trait]
+
pub trait ClientAuthStore {
+
async fn get_session(
+
&self,
+
did: &Did<'_>,
+
session_id: &str,
+
) -> Result<Option<ClientSessionData<'_>>, SessionStoreError>;
+
+
async fn upsert_session(&self, session: ClientSessionData<'_>)
+
-> Result<(), SessionStoreError>;
+
+
async fn delete_session(
+
&self,
+
did: &Did<'_>,
+
session_id: &str,
+
) -> Result<(), SessionStoreError>;
+
+
async fn get_auth_req_info(
+
&self,
+
state: &str,
+
) -> Result<Option<AuthRequestData<'_>>, SessionStoreError>;
+
+
async fn save_auth_req_info(
+
&self,
+
auth_req_info: &AuthRequestData<'_>,
+
) -> Result<(), SessionStoreError>;
+
+
async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>;
+
}
+186
crates/jacquard-oauth/src/client.rs
···
···
+
use std::sync::Arc;
+
+
use jacquard_common::{CowStr, IntoStatic, types::did::Did};
+
use jose_jwk::JwkSet;
+
use url::Url;
+
+
use crate::{
+
atproto::atproto_client_metadata,
+
authstore::ClientAuthStore,
+
dpop::DpopExt,
+
error::{OAuthError, Result},
+
request::{OAuthMetadata, exchange_code, par},
+
resolver::OAuthResolver,
+
scopes::Scope,
+
session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry},
+
types::{AuthorizeOptions, CallbackParams},
+
};
+
+
pub struct OAuthClient<T, S>
+
where
+
T: OAuthResolver,
+
S: ClientAuthStore,
+
{
+
pub registry: Arc<SessionRegistry<T, S>>,
+
pub client: Arc<T>,
+
}
+
+
impl<T, S> OAuthClient<T, S>
+
where
+
T: OAuthResolver,
+
S: ClientAuthStore,
+
{
+
pub fn new_from_resolver(store: S, client: T, client_data: ClientData<'static>) -> Self {
+
let client = Arc::new(client);
+
let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data));
+
Self { registry, client }
+
}
+
}
+
+
impl<T, S> OAuthClient<T, S>
+
where
+
S: ClientAuthStore + Send + Sync + 'static,
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
+
{
+
pub fn jwks(&self) -> JwkSet {
+
self.registry
+
.client_data
+
.keyset
+
.as_ref()
+
.map(|keyset| keyset.public_jwks())
+
.unwrap_or_default()
+
}
+
pub async fn start_auth(
+
&self,
+
input: impl AsRef<str>,
+
options: AuthorizeOptions<'_>,
+
) -> Result<String> {
+
let client_metadata = atproto_client_metadata(
+
self.registry.client_data.config.clone(),
+
&self.registry.client_data.keyset,
+
)?;
+
+
let (server_metadata, identity) = self.client.resolve_oauth(input.as_ref()).await?;
+
let login_hint = if identity.is_some() {
+
Some(input.as_ref().into())
+
} else {
+
None
+
};
+
let metadata = OAuthMetadata {
+
server_metadata,
+
client_metadata,
+
keyset: self.registry.client_data.keyset.clone(),
+
};
+
let auth_req_info =
+
par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?;
+
+
#[derive(serde::Serialize)]
+
struct Parameters<'s> {
+
client_id: Url,
+
request_uri: CowStr<'s>,
+
}
+
Ok(metadata.server_metadata.authorization_endpoint.to_string()
+
+ "?"
+
+ &serde_html_form::to_string(Parameters {
+
client_id: metadata.client_metadata.client_id.clone(),
+
request_uri: auth_req_info.request_uri,
+
})
+
.unwrap())
+
}
+
+
pub async fn callback(&self, params: CallbackParams<'_>) -> Result<ClientSessionData<'static>> {
+
let Some(state_key) = params.state else {
+
return Err(OAuthError::Callback("missing state parameter".into()));
+
};
+
+
let Some(auth_req_info) = self.registry.store.get_auth_req_info(&state_key).await? else {
+
return Err(OAuthError::Callback(format!(
+
"unknown authorization state: {state_key}"
+
)));
+
};
+
+
self.registry.store.delete_auth_req_info(&state_key).await?;
+
+
let metadata = self
+
.client
+
.get_authorization_server_metadata(&auth_req_info.authserver_url)
+
.await?;
+
+
if let Some(iss) = params.iss {
+
if iss != metadata.issuer {
+
return Err(OAuthError::Callback(format!(
+
"issuer mismatch: expected {}, got {iss}",
+
metadata.issuer
+
)));
+
}
+
} else if metadata.authorization_response_iss_parameter_supported == Some(true) {
+
return Err(OAuthError::Callback("missing `iss` parameter".into()));
+
}
+
let metadata = OAuthMetadata {
+
server_metadata: metadata,
+
client_metadata: atproto_client_metadata(
+
self.registry.client_data.config.clone(),
+
&self.registry.client_data.keyset,
+
)?,
+
keyset: self.registry.client_data.keyset.clone(),
+
};
+
let authserver_nonce = auth_req_info.dpop_data.dpop_authserver_nonce.clone();
+
+
match exchange_code(
+
self.client.as_ref(),
+
&mut auth_req_info.dpop_data.clone(),
+
&params.code,
+
&auth_req_info.pkce_verifier,
+
&metadata,
+
)
+
.await
+
{
+
Ok(token_set) => {
+
let scopes = if let Some(scope) = &token_set.scope {
+
Scope::parse_multiple_reduced(&scope)
+
.expect("Failed to parse scopes")
+
.into_static()
+
} else {
+
vec![]
+
};
+
let client_data = ClientSessionData {
+
account_did: token_set.sub.clone(),
+
session_id: auth_req_info.state,
+
host_url: Url::parse(&token_set.iss).expect("Failed to parse host URL"),
+
authserver_url: auth_req_info.authserver_url,
+
authserver_token_endpoint: auth_req_info.authserver_token_endpoint,
+
authserver_revocation_endpoint: auth_req_info.authserver_revocation_endpoint,
+
scopes,
+
dpop_data: DpopClientData {
+
dpop_key: auth_req_info.dpop_data.dpop_key.clone(),
+
dpop_authserver_nonce: authserver_nonce.unwrap_or(CowStr::default()),
+
dpop_host_nonce: auth_req_info
+
.dpop_data
+
.dpop_authserver_nonce
+
.unwrap_or(CowStr::default()),
+
},
+
token_set,
+
};
+
+
Ok(client_data.into_static())
+
}
+
Err(e) => Err(e.into()),
+
}
+
}
+
+
pub async fn restore(
+
&self,
+
did: &Did<'_>,
+
session_id: &str,
+
) -> Result<ClientSessionData<'static>> {
+
Ok(self
+
.registry
+
.get(did, session_id, false)
+
.await?
+
.into_static())
+
}
+
+
pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> {
+
Ok(self.registry.del(did, session_id).await?)
+
}
+
}
+179 -179
crates/jacquard-oauth/src/dpop.rs
···
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use http::{Request, Response, header::InvalidHeaderValue};
-
use jacquard_common::{
-
CowStr,
-
http_client::HttpClient,
-
session::{MemorySessionStore, SessionStore, SessionStoreError},
-
};
use jose_jwa::{Algorithm, Signing};
use jose_jwk::{Jwk, Key, crypto};
use p256::ecdsa::SigningKey;
use rand::{RngCore, SeedableRng};
use sha2::Digest;
-
use smol_str::{SmolStr, ToSmolStr};
-
use crate::jose::{
-
create_signed_jwt,
-
jws::RegisteredHeader,
-
jwt::{Claims, PublicClaims, RegisteredClaims},
};
pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt";
···
pub enum Error {
#[error(transparent)]
InvalidHeaderValue(#[from] InvalidHeaderValue),
-
#[error(transparent)]
-
SessionStore(#[from] SessionStoreError),
#[error("crypto error: {0:?}")]
JwkCrypto(crypto::Error),
#[error("key does not match any alg supported by the server")]
···
type Result<T> = core::result::Result<T, Error>;
#[inline]
pub(crate) fn generate_jti() -> CowStr<'static> {
let mut rng = rand::rngs::SmallRng::from_entropy();
···
crypto::Key::P256(crypto::Kind::Secret(sk)) => sk,
_ => return Err(Error::UnsupportedKey),
};
-
build_dpop_proof_with_secret(&secret, method, url, nonce, ath)
-
}
-
-
/// Same as build_dpop_proof but takes a parsed secret key to avoid JSON roundtrips.
-
#[inline]
-
pub fn build_dpop_proof_with_secret<'s>(
-
secret: &p256::SecretKey,
-
method: CowStr<'s>,
-
url: CowStr<'s>,
-
nonce: Option<CowStr<'s>>,
-
ath: Option<CowStr<'s>>,
-
) -> Result<CowStr<'s>> {
let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
header.typ = Some(JWT_HEADER_TYP_DPOP.into());
header.jwk = Some(Jwk {
···
claims,
)?)
}
-
-
pub struct DpopClient<T, S = MemorySessionStore<CowStr<'static>, CowStr<'static>>>
-
where
-
S: SessionStore<CowStr<'static>, CowStr<'static>>,
-
{
-
inner: T,
-
pub(crate) key: Key,
-
nonces: S,
-
is_auth_server: bool,
-
}
-
-
impl<T> DpopClient<T> {
-
pub fn new(
-
key: Key,
-
http_client: T,
-
is_auth_server: bool,
-
supported_algs: &Option<Vec<CowStr<'static>>>,
-
) -> Result<Self> {
-
if let Some(algs) = supported_algs {
-
let alg = CowStr::from(match &key {
-
Key::Ec(ec) => match &ec.crv {
-
jose_jwk::EcCurves::P256 => "ES256",
-
_ => unimplemented!(),
-
},
-
_ => unimplemented!(),
-
});
-
if !algs.contains(&alg) {
-
return Err(Error::UnsupportedKey);
-
}
-
}
-
let nonces = MemorySessionStore::<CowStr<'static>, CowStr<'static>>::default();
-
Ok(Self {
-
inner: http_client,
-
key,
-
nonces,
-
is_auth_server,
-
})
-
}
-
}
-
-
impl<T, S> DpopClient<T, S>
-
where
-
S: SessionStore<CowStr<'static>, CowStr<'static>>,
-
{
-
fn build_proof<'s>(
-
&self,
-
method: CowStr<'s>,
-
url: CowStr<'s>,
-
ath: Option<CowStr<'s>>,
-
nonce: Option<CowStr<'s>>,
-
) -> Result<CowStr<'s>> {
-
build_dpop_proof(&self.key, method, url, nonce, ath)
-
}
-
fn is_use_dpop_nonce_error(&self, response: &http::Response<Vec<u8>>) -> bool {
-
// https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
-
if self.is_auth_server {
-
if response.status() == 400 {
-
if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) {
-
return res.error == "use_dpop_nonce";
-
};
-
}
-
}
-
// https://datatracker.ietf.org/doc/html/rfc6750#section-3
-
// https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
-
else if response.status() == 401 {
-
if let Some(www_auth) = response
-
.headers()
-
.get("WWW-Authenticate")
-
.and_then(|v| v.to_str().ok())
-
{
-
return www_auth.starts_with("DPoP")
-
&& www_auth.contains(r#"error="use_dpop_nonce""#);
-
}
-
}
-
false
-
}
-
}
-
-
impl<T, S> HttpClient for DpopClient<T, S>
-
where
-
T: HttpClient + Send + Sync + 'static,
-
S: SessionStore<CowStr<'static>, CowStr<'static>> + Send + Sync + 'static,
-
{
-
type Error = Error;
-
-
async fn send_http(
-
&self,
-
mut request: Request<Vec<u8>>,
-
) -> core::result::Result<Response<Vec<u8>>, Self::Error> {
-
let uri = request.uri();
-
let nonce_key = CowStr::Owned(uri.authority().unwrap().to_smolstr());
-
let method = CowStr::Owned(request.method().to_smolstr());
-
let uri = CowStr::Owned(uri.to_smolstr());
-
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
-
let ath = request
-
.headers()
-
.get("Authorization")
-
.filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP ")))
-
.map(|auth| {
-
URL_SAFE_NO_PAD
-
.encode(sha2::Sha256::digest(&auth.as_bytes()[5..]))
-
.into()
-
});
-
-
let init_nonce = self.nonces.get(&nonce_key).await;
-
let init_proof =
-
self.build_proof(method.clone(), uri.clone(), ath.clone(), init_nonce.clone())?;
-
request.headers_mut().insert("DPoP", init_proof.parse()?);
-
let response = self
-
.inner
-
.send_http(request.clone())
-
.await
-
.map_err(|e| Error::Inner(e.into()))?;
-
-
let next_nonce = response
-
.headers()
-
.get("DPoP-Nonce")
-
.and_then(|v| v.to_str().ok())
-
.map(|c| CowStr::Owned(SmolStr::new(c)));
-
match &next_nonce {
-
Some(s) if next_nonce != init_nonce => {
-
// Store the fresh nonce for future requests
-
self.nonces.set(nonce_key, s.clone()).await?;
-
}
-
_ => {
-
// No nonce was returned or it is the same as the one we sent. No need to
-
// update the nonce store, or retry the request.
-
return Ok(response);
-
}
-
}
-
-
if !self.is_use_dpop_nonce_error(&response) {
-
return Ok(response);
-
}
-
let next_proof = self.build_proof(method, uri, ath, next_nonce)?;
-
request.headers_mut().insert("DPoP", next_proof.parse()?);
-
let response = self
-
.inner
-
.send_http(request)
-
.await
-
.map_err(|e| Error::Inner(e.into()))?;
-
Ok(response)
-
}
-
}
-
-
impl<T: Clone> Clone for DpopClient<T> {
-
fn clone(&self) -> Self {
-
Self {
-
inner: self.inner.clone(),
-
key: self.key.clone(),
-
nonces: self.nonces.clone(),
-
is_auth_server: self.is_auth_server,
-
}
-
}
-
}
···
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use http::{Request, Response, header::InvalidHeaderValue};
+
use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient};
use jose_jwa::{Algorithm, Signing};
use jose_jwk::{Jwk, Key, crypto};
use p256::ecdsa::SigningKey;
use rand::{RngCore, SeedableRng};
use sha2::Digest;
+
use crate::{
+
jose::{
+
create_signed_jwt,
+
jws::RegisteredHeader,
+
jwt::{Claims, PublicClaims, RegisteredClaims},
+
},
+
session::DpopDataSource,
};
pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt";
···
pub enum Error {
#[error(transparent)]
InvalidHeaderValue(#[from] InvalidHeaderValue),
#[error("crypto error: {0:?}")]
JwkCrypto(crypto::Error),
#[error("key does not match any alg supported by the server")]
···
type Result<T> = core::result::Result<T, Error>;
+
#[async_trait::async_trait]
+
pub trait DpopClient: HttpClient {
+
async fn dpop_server(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
+
async fn dpop_client(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
+
async fn wrap_request(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
+
}
+
+
pub trait DpopExt: HttpClient {
+
fn dpop_server_call<'r, D>(&'r self, data_source: &'r mut D) -> DpopCall<'r, Self, D>
+
where
+
Self: Sized,
+
D: DpopDataSource,
+
{
+
DpopCall::server(self, data_source)
+
}
+
+
fn dpop_call<'r, N>(&'r self, data_source: &'r mut N) -> DpopCall<'r, Self, N>
+
where
+
Self: Sized,
+
N: DpopDataSource,
+
{
+
DpopCall::client(self, data_source)
+
}
+
+
async fn wrap_with_dpop<'r, D>(
+
&'r self,
+
is_to_auth_server: bool,
+
data_source: &'r mut D,
+
request: Request<Vec<u8>>,
+
) -> Result<Response<Vec<u8>>>
+
where
+
Self: Sized,
+
D: DpopDataSource,
+
{
+
wrap_request_with_dpop(self, data_source, is_to_auth_server, request).await
+
}
+
}
+
+
pub struct DpopCall<'r, C: HttpClient, D: DpopDataSource> {
+
pub client: &'r C,
+
pub is_to_auth_server: bool,
+
pub data_source: &'r mut D,
+
}
+
+
impl<'r, C: HttpClient, N: DpopDataSource> DpopCall<'r, C, N> {
+
pub fn server(client: &'r C, data_source: &'r mut N) -> Self {
+
Self {
+
client,
+
is_to_auth_server: true,
+
data_source,
+
}
+
}
+
+
pub fn client(client: &'r C, data_source: &'r mut N) -> Self {
+
Self {
+
client,
+
is_to_auth_server: false,
+
data_source,
+
}
+
}
+
+
pub async fn send(self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>> {
+
wrap_request_with_dpop(
+
self.client,
+
self.data_source,
+
self.is_to_auth_server,
+
request,
+
)
+
.await
+
}
+
}
+
+
pub async fn wrap_request_with_dpop<T, N>(
+
client: &T,
+
data_source: &mut N,
+
is_to_auth_server: bool,
+
mut request: Request<Vec<u8>>,
+
) -> Result<Response<Vec<u8>>>
+
where
+
T: HttpClient,
+
N: DpopDataSource,
+
{
+
let uri = request.uri().clone();
+
let method = request.method().to_cowstr().into_static();
+
let uri = uri.to_cowstr();
+
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
+
let ath = request
+
.headers()
+
.get("Authorization")
+
.filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP ")))
+
.map(|auth| {
+
URL_SAFE_NO_PAD
+
.encode(sha2::Sha256::digest(&auth.as_bytes()[5..]))
+
.into()
+
});
+
+
let init_nonce = if is_to_auth_server {
+
data_source.authserver_nonce()
+
} else {
+
data_source.host_nonce()
+
};
+
let init_proof = build_dpop_proof(
+
data_source.key(),
+
method.clone(),
+
uri.clone(),
+
init_nonce.clone(),
+
ath.clone(),
+
)?;
+
request.headers_mut().insert("DPoP", init_proof.parse()?);
+
let response = client
+
.send_http(request.clone())
+
.await
+
.map_err(|e| Error::Inner(e.into()))?;
+
+
let next_nonce = response
+
.headers()
+
.get("DPoP-Nonce")
+
.and_then(|v| v.to_str().ok())
+
.map(|c| c.to_cowstr());
+
match &next_nonce {
+
Some(s) if next_nonce != init_nonce => {
+
// Store the fresh nonce for future requests
+
if is_to_auth_server {
+
data_source.set_authserver_nonce(s.clone());
+
} else {
+
data_source.set_host_nonce(s.clone());
+
}
+
}
+
_ => {
+
// No nonce was returned or it is the same as the one we sent. No need to
+
// update the nonce store, or retry the request.
+
return Ok(response);
+
}
+
}
+
+
if !is_use_dpop_nonce_error(is_to_auth_server, &response) {
+
return Ok(response);
+
}
+
let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?;
+
request.headers_mut().insert("DPoP", next_proof.parse()?);
+
let response = client
+
.send_http(request)
+
.await
+
.map_err(|e| Error::Inner(e.into()))?;
+
Ok(response)
+
}
+
+
#[inline]
+
fn is_use_dpop_nonce_error(is_to_auth_server: bool, response: &Response<Vec<u8>>) -> bool {
+
// https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
+
if is_to_auth_server {
+
if response.status() == 400 {
+
if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) {
+
return res.error == "use_dpop_nonce";
+
};
+
}
+
}
+
// https://datatracker.ietf.org/doc/html/rfc6750#section-3
+
// https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
+
else if response.status() == 401 {
+
if let Some(www_auth) = response
+
.headers()
+
.get("WWW-Authenticate")
+
.and_then(|v| v.to_str().ok())
+
{
+
return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#);
+
}
+
}
+
false
+
}
+
#[inline]
pub(crate) fn generate_jti() -> CowStr<'static> {
let mut rng = rand::rngs::SmallRng::from_entropy();
···
crypto::Key::P256(crypto::Kind::Secret(sk)) => sk,
_ => return Err(Error::UnsupportedKey),
};
let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
header.typ = Some(JWT_HEADER_TYP_DPOP.into());
header.jwk = Some(Jwk {
···
claims,
)?)
}
+18 -2
crates/jacquard-oauth/src/error.rs
···
use miette::Diagnostic;
-
use thiserror::Error;
/// Errors emitted by OAuth helpers.
-
#[derive(Debug, Error, Diagnostic)]
pub enum OAuthError {
/// Invalid or unsupported JWK
#[error("invalid JWK: {0}")]
···
help("PKCE must use S256; ensure verifier/challenge generated")
)]
Pkce(String),
}
pub type Result<T> = core::result::Result<T, OAuthError>;
···
+
use jacquard_common::session::SessionStoreError;
use miette::Diagnostic;
+
+
use crate::resolver::ResolverError;
/// Errors emitted by OAuth helpers.
+
#[derive(Debug, thiserror::Error, Diagnostic)]
pub enum OAuthError {
/// Invalid or unsupported JWK
#[error("invalid JWK: {0}")]
···
help("PKCE must use S256; ensure verifier/challenge generated")
)]
Pkce(String),
+
#[error("authorize error: {0}")]
+
Authorize(String),
+
#[error(transparent)]
+
Atproto(#[from] crate::atproto::Error),
+
#[error("callback error: {0}")]
+
Callback(String),
+
#[error(transparent)]
+
Storage(#[from] SessionStoreError),
+
#[error(transparent)]
+
Session(#[from] crate::session::Error),
+
#[error(transparent)]
+
Request(#[from] crate::request::Error),
+
#[error(transparent)]
+
Client(#[from] ResolverError),
}
pub type Result<T> = core::result::Result<T, OAuthError>;
+6 -7
crates/jacquard-oauth/src/keyset.rs
···
use crate::jose::create_signed_jwt;
use crate::jose::jws::RegisteredHeader;
use crate::jose::jwt::Claims;
-
use jacquard_common::CowStr;
use jose_jwa::{Algorithm, Signing};
use jose_jwk::{Class, EcCurves, crypto};
use jose_jwk::{Jwk, JwkSet, Key};
-
use smol_str::{SmolStr, ToSmolStr};
use std::collections::HashSet;
use thiserror::Error;
···
#[error("key must have a `kid`")]
EmptyKid,
#[error("no signing key found for algorithms: {0:?}")]
-
NotFound(Vec<SmolStr>),
#[error("key for signing must be a secret key")]
PublicKey,
#[error("crypto error: {0:?}")]
···
}
JwkSet { keys }
}
-
pub fn create_jwt(&self, algs: &[SmolStr], claims: Claims) -> Result<CowStr<'static>> {
let Some(jwk) = self.find_key(algs, Class::Signing) else {
-
return Err(Error::NotFound(algs.to_vec()));
};
self.create_jwt_with_key(jwk, claims)
}
-
fn find_key(&self, algs: &[SmolStr], cls: Class) -> Option<&Jwk> {
let candidates = self
.0
.iter()
···
},
_ => unimplemented!(),
};
-
Some((alg, key)).filter(|(alg, _)| algs.contains(&alg.to_smolstr()))
})
.collect::<Vec<_>>();
for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS {
···
use crate::jose::create_signed_jwt;
use crate::jose::jws::RegisteredHeader;
use crate::jose::jwt::Claims;
+
use jacquard_common::{CowStr, IntoStatic};
use jose_jwa::{Algorithm, Signing};
use jose_jwk::{Class, EcCurves, crypto};
use jose_jwk::{Jwk, JwkSet, Key};
use std::collections::HashSet;
use thiserror::Error;
···
#[error("key must have a `kid`")]
EmptyKid,
#[error("no signing key found for algorithms: {0:?}")]
+
NotFound(Vec<CowStr<'static>>),
#[error("key for signing must be a secret key")]
PublicKey,
#[error("crypto error: {0:?}")]
···
}
JwkSet { keys }
}
+
pub fn create_jwt(&self, algs: &[CowStr], claims: Claims) -> Result<CowStr<'static>> {
let Some(jwk) = self.find_key(algs, Class::Signing) else {
+
return Err(Error::NotFound(algs.to_vec().into_static()));
};
self.create_jwt_with_key(jwk, claims)
}
+
fn find_key(&self, algs: &[CowStr], cls: Class) -> Option<&Jwk> {
let candidates = self
.0
.iter()
···
},
_ => unimplemented!(),
};
+
Some((alg, key)).filter(|(alg, _)| algs.contains(&CowStr::Borrowed(&alg)))
})
.collect::<Vec<_>>();
for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS {
+4
crates/jacquard-oauth/src/lib.rs
···
//! Transport, discovery, and orchestration live in `jacquard`.
pub mod atproto;
pub mod dpop;
pub mod error;
pub mod jose;
pub mod keyset;
pub mod resolver;
pub mod scopes;
pub mod session;
pub mod types;
pub const FALLBACK_ALG: &str = "ES256";
···
//! Transport, discovery, and orchestration live in `jacquard`.
pub mod atproto;
+
pub mod authstore;
+
pub mod client;
pub mod dpop;
pub mod error;
pub mod jose;
pub mod keyset;
+
pub mod request;
pub mod resolver;
pub mod scopes;
pub mod session;
pub mod types;
+
pub mod utils;
pub const FALLBACK_ALG: &str = "ES256";
+536
crates/jacquard-oauth/src/request.rs
···
···
+
use chrono::{DateTime, FixedOffset, TimeDelta, Utc};
+
use http::{Method, Request, StatusCode};
+
use jacquard_common::{
+
CowStr, IntoStatic,
+
cowstr::ToCowStr,
+
http_client::HttpClient,
+
ident_resolver::{IdentityError, IdentityResolver},
+
session::SessionStoreError,
+
types::{
+
did::Did,
+
string::{AtStrError, Datetime},
+
},
+
};
+
use jose_jwk::Key;
+
use serde::{Serialize, de::DeserializeOwned};
+
use serde_json::Value;
+
use smol_str::ToSmolStr;
+
use std::sync::Arc;
+
use thiserror::Error;
+
use url::Url;
+
+
use crate::{
+
FALLBACK_ALG,
+
atproto::{AtprotoClientMetadata, atproto_client_metadata},
+
dpop::{DpopClient, DpopExt},
+
jose::jwt::{RegisteredClaims, RegisteredClaimsAud},
+
keyset::Keyset,
+
resolver::OAuthResolver,
+
scopes::Scope,
+
session::{
+
AuthRequestData, ClientData, ClientSessionData, DpopClientData, DpopDataSource, DpopReqData,
+
},
+
types::{
+
AuthorizationCodeChallengeMethod, AuthorizationResponseType, AuthorizeOptionPrompt,
+
OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthParResponse,
+
OAuthTokenResponse, ParParameters, RefreshRequestParameters, RevocationRequestParameters,
+
TokenGrantType, TokenRequestParameters, TokenSet,
+
},
+
utils::{compare_algos, generate_dpop_key, generate_nonce, generate_pkce},
+
};
+
+
// https://datatracker.ietf.org/doc/html/rfc7523#section-2.2
+
const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
+
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
+
+
#[derive(Error, Debug)]
+
pub enum Error {
+
#[error("no {0} endpoint available")]
+
NoEndpoint(CowStr<'static>),
+
#[error("token response verification failed")]
+
Token(CowStr<'static>),
+
#[error("unsupported authentication method")]
+
UnsupportedAuthMethod,
+
#[error("no refresh token available")]
+
TokenRefresh,
+
#[error("failed to parse DID: {0}")]
+
InvalidDid(#[from] AtStrError),
+
#[error(transparent)]
+
DpopClient(#[from] crate::dpop::Error),
+
#[error(transparent)]
+
Storage(#[from] SessionStoreError),
+
+
#[error(transparent)]
+
ResolverError(#[from] crate::resolver::ResolverError),
+
// #[error(transparent)]
+
// OAuthSession(#[from] crate::oauth_session::Error),
+
#[error(transparent)]
+
Http(#[from] http::Error),
+
#[error("http client error: {0}")]
+
HttpClient(Box<dyn std::error::Error + Send + Sync + 'static>),
+
#[error("http status: {0}")]
+
HttpStatus(StatusCode),
+
#[error("http status: {0}, body: {1:?}")]
+
HttpStatusWithBody(StatusCode, Value),
+
#[error(transparent)]
+
Identity(#[from] IdentityError),
+
#[error(transparent)]
+
Keyset(#[from] crate::keyset::Error),
+
#[error(transparent)]
+
SerdeHtmlForm(#[from] serde_html_form::ser::Error),
+
#[error(transparent)]
+
SerdeJson(#[from] serde_json::Error),
+
#[error(transparent)]
+
Atproto(#[from] crate::atproto::Error),
+
}
+
+
pub type Result<T> = core::result::Result<T, Error>;
+
+
#[allow(dead_code)]
+
pub enum OAuthRequest<'a> {
+
Token(TokenRequestParameters<'a>),
+
Refresh(RefreshRequestParameters<'a>),
+
Revocation(RevocationRequestParameters<'a>),
+
Introspection,
+
PushedAuthorizationRequest(ParParameters<'a>),
+
}
+
+
impl OAuthRequest<'_> {
+
pub fn name(&self) -> CowStr<'static> {
+
CowStr::new_static(match self {
+
Self::Token(_) => "token",
+
Self::Refresh(_) => "refresh",
+
Self::Revocation(_) => "revocation",
+
Self::Introspection => "introspection",
+
Self::PushedAuthorizationRequest(_) => "pushed_authorization_request",
+
})
+
}
+
pub fn expected_status(&self) -> StatusCode {
+
match self {
+
Self::Token(_) | Self::Refresh(_) => StatusCode::OK,
+
Self::PushedAuthorizationRequest(_) => StatusCode::CREATED,
+
// Unlike https://datatracker.ietf.org/doc/html/rfc7009#section-2.2, oauth-provider seems to return `204`.
+
Self::Revocation(_) => StatusCode::NO_CONTENT,
+
_ => unimplemented!(),
+
}
+
}
+
}
+
+
#[derive(Debug, Serialize)]
+
pub struct RequestPayload<'a, T>
+
where
+
T: Serialize,
+
{
+
client_id: CowStr<'a>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
client_assertion_type: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
client_assertion: Option<CowStr<'a>>,
+
#[serde(flatten)]
+
parameters: T,
+
}
+
+
#[derive(Debug, Clone)]
+
pub struct OAuthMetadata {
+
pub server_metadata: OAuthAuthorizationServerMetadata<'static>,
+
pub client_metadata: OAuthClientMetadata<'static>,
+
pub keyset: Option<Keyset>,
+
}
+
+
impl OAuthMetadata {
+
pub async fn new<'r, T: HttpClient + OAuthResolver + Send + Sync>(
+
client: &T,
+
ClientData { keyset, config }: &ClientData<'r>,
+
session_data: &ClientSessionData<'r>,
+
) -> Result<Self> {
+
Ok(OAuthMetadata {
+
server_metadata: client
+
.get_authorization_server_metadata(&session_data.authserver_url)
+
.await?,
+
client_metadata: atproto_client_metadata(config.clone(), &keyset)
+
.unwrap()
+
.into_static(),
+
keyset: keyset.clone(),
+
})
+
}
+
}
+
+
pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>(
+
client: &T,
+
login_hint: Option<CowStr<'r>>,
+
prompt: Option<AuthorizeOptionPrompt>,
+
metadata: &OAuthMetadata,
+
) -> crate::request::Result<AuthRequestData<'r>> {
+
let state = generate_nonce();
+
let (code_challenge, verifier) = generate_pkce();
+
+
let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else {
+
return Err(Error::Token("none of the algorithms worked".into()));
+
};
+
let mut dpop_data = DpopReqData {
+
dpop_key,
+
dpop_authserver_nonce: None,
+
};
+
let parameters = ParParameters {
+
response_type: AuthorizationResponseType::Code,
+
redirect_uri: metadata.client_metadata.redirect_uris[0].to_cowstr(),
+
state: state.clone(),
+
scope: metadata.client_metadata.scope.clone(),
+
response_mode: None,
+
code_challenge,
+
code_challenge_method: AuthorizationCodeChallengeMethod::S256,
+
login_hint: login_hint,
+
prompt: prompt.map(CowStr::from),
+
};
+
if metadata
+
.server_metadata
+
.pushed_authorization_request_endpoint
+
.is_some()
+
{
+
let par_response = oauth_request::<OAuthParResponse, T, DpopReqData>(
+
&client,
+
&mut dpop_data,
+
OAuthRequest::PushedAuthorizationRequest(parameters),
+
metadata,
+
)
+
.await?;
+
+
let scopes = if let Some(scope) = &metadata.client_metadata.scope {
+
Scope::parse_multiple_reduced(&scope)
+
.expect("Failed to parse scopes")
+
.into_static()
+
} else {
+
vec![]
+
};
+
let auth_req_data = AuthRequestData {
+
state,
+
authserver_url: url::Url::parse(&metadata.server_metadata.issuer)
+
.expect("Failed to parse issuer URL"),
+
account_did: None,
+
scopes,
+
request_uri: par_response.request_uri.to_cowstr().into_static(),
+
authserver_token_endpoint: metadata.server_metadata.token_endpoint.clone(),
+
authserver_revocation_endpoint: metadata.server_metadata.revocation_endpoint.clone(),
+
pkce_verifier: verifier,
+
dpop_data,
+
};
+
+
Ok(auth_req_data)
+
} else if metadata
+
.server_metadata
+
.require_pushed_authorization_requests
+
== Some(true)
+
{
+
Err(Error::NoEndpoint(CowStr::new_static(
+
"server requires PAR but no endpoint is available",
+
)))
+
} else {
+
todo!("use of PAR is mandatory")
+
}
+
}
+
+
pub async fn refresh<'r, T>(
+
client: &T,
+
mut session_data: ClientSessionData<'r>,
+
metadata: &OAuthMetadata,
+
) -> Result<ClientSessionData<'r>>
+
where
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
+
{
+
let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else {
+
return Err(Error::TokenRefresh);
+
};
+
+
// /!\ IMPORTANT /!\
+
//
+
// The "sub" MUST be a DID, whose issuer authority is indeed the server we
+
// are trying to obtain credentials from. Note that we are doing this
+
// *before* we actually try to refresh the token:
+
// 1) To avoid unnecessary refresh
+
// 2) So that the refresh is the last async operation, ensuring as few
+
// async operations happen before the result gets a chance to be stored.
+
let aud = client
+
.verify_issuer(&metadata.server_metadata, &session_data.token_set.sub)
+
.await?;
+
let iss = metadata.server_metadata.issuer.clone();
+
+
let response = oauth_request::<OAuthTokenResponse, T, DpopClientData>(
+
client,
+
&mut session_data.dpop_data,
+
OAuthRequest::Refresh(RefreshRequestParameters {
+
grant_type: TokenGrantType::RefreshToken,
+
refresh_token: refresh_token.clone(),
+
scope: None,
+
}),
+
metadata,
+
)
+
.await?;
+
+
let expires_at = response.expires_in.and_then(|expires_in| {
+
let now = Datetime::now();
+
now.as_ref()
+
.checked_add_signed(TimeDelta::seconds(expires_in))
+
.map(Datetime::new)
+
});
+
+
session_data.update_with_tokens(TokenSet {
+
iss,
+
sub: session_data.token_set.sub.clone(),
+
aud: CowStr::Owned(aud.to_smolstr()),
+
scope: response.scope.map(CowStr::Owned),
+
access_token: CowStr::Owned(response.access_token),
+
refresh_token: response.refresh_token.map(CowStr::Owned),
+
token_type: response.token_type,
+
expires_at,
+
});
+
+
Ok(session_data)
+
}
+
+
pub async fn exchange_code<'r, T, D>(
+
client: &T,
+
data_source: &'r mut D,
+
code: &str,
+
verifier: &str,
+
metadata: &OAuthMetadata,
+
) -> Result<TokenSet<'r>>
+
where
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
+
D: DpopDataSource,
+
{
+
let token_response = oauth_request::<OAuthTokenResponse, T, D>(
+
client,
+
data_source,
+
OAuthRequest::Token(TokenRequestParameters {
+
grant_type: TokenGrantType::AuthorizationCode,
+
code: code.into(),
+
redirect_uri: CowStr::Owned(
+
metadata.client_metadata.redirect_uris[0]
+
.clone()
+
.to_smolstr(),
+
), // ?
+
code_verifier: verifier.into(),
+
}),
+
metadata,
+
)
+
.await?;
+
let Some(sub) = token_response.sub else {
+
return Err(Error::Token("missing `sub` in token response".into()));
+
};
+
let sub = Did::new_owned(sub)?;
+
let iss = metadata.server_metadata.issuer.clone();
+
// /!\ IMPORTANT /!\
+
//
+
// The token_response MUST always be valid before the "sub" it contains
+
// can be trusted (see Atproto's OAuth spec for details).
+
let aud = client
+
.verify_issuer(&metadata.server_metadata, &sub)
+
.await?;
+
+
let expires_at = token_response.expires_in.and_then(|expires_in| {
+
Datetime::now()
+
.as_ref()
+
.checked_add_signed(TimeDelta::seconds(expires_in))
+
.map(Datetime::new)
+
});
+
Ok(TokenSet {
+
iss,
+
sub,
+
aud: CowStr::Owned(aud.to_smolstr()),
+
scope: token_response.scope.map(CowStr::Owned),
+
access_token: CowStr::Owned(token_response.access_token),
+
refresh_token: token_response.refresh_token.map(CowStr::Owned),
+
token_type: token_response.token_type,
+
expires_at,
+
})
+
}
+
+
pub async fn revoke<'r, T, D>(
+
client: &T,
+
data_source: &'r mut D,
+
token: &str,
+
metadata: &OAuthMetadata,
+
) -> Result<()>
+
where
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
+
D: DpopDataSource,
+
{
+
oauth_request::<(), T, D>(
+
client,
+
data_source,
+
OAuthRequest::Revocation(RevocationRequestParameters {
+
token: token.into(),
+
}),
+
metadata,
+
)
+
.await?;
+
Ok(())
+
}
+
+
pub async fn oauth_request<'de: 'r, 'r, O, T, D>(
+
client: &T,
+
data_source: &'r mut D,
+
request: OAuthRequest<'r>,
+
metadata: &OAuthMetadata,
+
) -> Result<O>
+
where
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
+
O: serde::de::DeserializeOwned,
+
D: DpopDataSource,
+
{
+
let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else {
+
return Err(Error::NoEndpoint(request.name()));
+
};
+
let client_assertions = build_auth(
+
metadata.keyset.as_ref(),
+
&metadata.server_metadata,
+
&metadata.client_metadata,
+
)?;
+
let body = match &request {
+
OAuthRequest::Token(params) => build_oauth_req_body(client_assertions, params)?,
+
OAuthRequest::Refresh(params) => build_oauth_req_body(client_assertions, params)?,
+
OAuthRequest::Revocation(params) => build_oauth_req_body(client_assertions, params)?,
+
OAuthRequest::PushedAuthorizationRequest(params) => {
+
build_oauth_req_body(client_assertions, params)?
+
}
+
_ => unimplemented!(),
+
};
+
let req = Request::builder()
+
.uri(url.to_string())
+
.method(Method::POST)
+
.header("Content-Type", "application/x-www-form-urlencoded")
+
.body(body.into_bytes())?;
+
let res = client
+
.dpop_server_call(data_source)
+
.send(req)
+
.await
+
.map_err(Error::DpopClient)?;
+
if res.status() == request.expected_status() {
+
let body = res.body();
+
if body.is_empty() {
+
// since an empty body cannot be deserialized, use “null” temporarily to allow deserialization to `()`.
+
Ok(serde_json::from_slice(b"null")?)
+
} else {
+
let output: O = serde_json::from_slice(body)?;
+
Ok(output)
+
}
+
} else if res.status().is_client_error() {
+
Err(Error::HttpStatusWithBody(
+
res.status(),
+
serde_json::from_slice(res.body())?,
+
))
+
} else {
+
Err(Error::HttpStatus(res.status()))
+
}
+
}
+
+
fn endpoint_for_req<'a, 'r>(
+
server_metadata: &'r OAuthAuthorizationServerMetadata<'a>,
+
request: &'r OAuthRequest,
+
) -> Option<&'r CowStr<'a>> {
+
match request {
+
OAuthRequest::Token(_) | OAuthRequest::Refresh(_) => Some(&server_metadata.token_endpoint),
+
OAuthRequest::Revocation(_) => server_metadata.revocation_endpoint.as_ref(),
+
OAuthRequest::Introspection => server_metadata.introspection_endpoint.as_ref(),
+
OAuthRequest::PushedAuthorizationRequest(_) => server_metadata
+
.pushed_authorization_request_endpoint
+
.as_ref(),
+
}
+
}
+
+
fn build_oauth_req_body<'a, S>(
+
client_assertions: ClientAssertions<'a>,
+
parameters: S,
+
) -> Result<String>
+
where
+
S: Serialize,
+
{
+
Ok(serde_html_form::to_string(RequestPayload {
+
client_id: client_assertions.client_id,
+
client_assertion_type: client_assertions.assertion_type,
+
client_assertion: client_assertions.assertion,
+
parameters,
+
})?)
+
}
+
+
#[derive(Debug, Clone, Default)]
+
pub struct ClientAssertions<'a> {
+
client_id: CowStr<'a>,
+
assertion_type: Option<CowStr<'a>>, // either none or `CLIENT_ASSERTION_TYPE_JWT_BEARER`
+
assertion: Option<CowStr<'a>>,
+
}
+
+
impl<'s> ClientAssertions<'s> {
+
pub fn new_id(client_id: CowStr<'s>) -> Self {
+
Self {
+
client_id,
+
assertion_type: None,
+
assertion: None,
+
}
+
}
+
}
+
+
fn build_auth<'a>(
+
keyset: Option<&Keyset>,
+
server_metadata: &OAuthAuthorizationServerMetadata<'a>,
+
client_metadata: &OAuthClientMetadata<'a>,
+
) -> Result<ClientAssertions<'a>> {
+
let method_supported = server_metadata
+
.token_endpoint_auth_methods_supported
+
.as_ref();
+
+
let client_id = client_metadata.client_id.to_cowstr().into_static();
+
if let Some(method) = client_metadata.token_endpoint_auth_method.as_ref() {
+
match (*method).as_ref() {
+
"private_key_jwt"
+
if method_supported
+
.as_ref()
+
.is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) =>
+
{
+
if let Some(keyset) = &keyset {
+
let mut algs = server_metadata
+
.token_endpoint_auth_signing_alg_values_supported
+
.clone()
+
.unwrap_or(vec![FALLBACK_ALG.into()]);
+
algs.sort_by(compare_algos);
+
let iat = Utc::now().timestamp();
+
return Ok(ClientAssertions {
+
client_id: client_id.clone(),
+
assertion_type: Some(CowStr::new_static(CLIENT_ASSERTION_TYPE_JWT_BEARER)),
+
assertion: Some(
+
keyset.create_jwt(
+
&algs,
+
// https://datatracker.ietf.org/doc/html/rfc7523#section-3
+
RegisteredClaims {
+
iss: Some(client_id.clone()),
+
sub: Some(client_id),
+
aud: Some(RegisteredClaimsAud::Single(
+
server_metadata.issuer.clone(),
+
)),
+
exp: Some(iat + 60),
+
// "iat" is required and **MUST** be less than one minute
+
// https://datatracker.ietf.org/doc/html/rfc9101
+
iat: Some(iat),
+
// atproto oauth-provider requires "jti" to be present
+
jti: Some(generate_nonce()),
+
..Default::default()
+
}
+
.into(),
+
)?,
+
),
+
});
+
}
+
}
+
"none"
+
if method_supported
+
.as_ref()
+
.is_some_and(|v| v.contains(&CowStr::new_static("none"))) =>
+
{
+
return Ok(ClientAssertions::new_id(client_id));
+
}
+
_ => {}
+
}
+
}
+
+
Err(Error::UnsupportedAuthMethod)
+
}
+17
crates/jacquard-oauth/src/resolver.rs
···
use jacquard_common::types::did_doc::DidDocument;
use jacquard_common::types::ident::AtIdentifier;
use jacquard_common::{http_client::HttpClient, types::did::Did};
use url::Url;
#[derive(thiserror::Error, Debug, miette::Diagnostic)]
···
#[async_trait::async_trait]
pub trait OAuthResolver: IdentityResolver + HttpClient {
async fn resolve_oauth(
&self,
input: &str,
···
Ok(as_metadata)
}
}
pub async fn resolve_authorization_server<T: HttpClient + ?Sized>(
client: &T,
···
use jacquard_common::types::did_doc::DidDocument;
use jacquard_common::types::ident::AtIdentifier;
use jacquard_common::{http_client::HttpClient, types::did::Did};
+
use sha2::digest::const_oid::Arc;
use url::Url;
#[derive(thiserror::Error, Debug, miette::Diagnostic)]
···
#[async_trait::async_trait]
pub trait OAuthResolver: IdentityResolver + HttpClient {
+
async fn verify_issuer(
+
&self,
+
server_metadata: &OAuthAuthorizationServerMetadata<'_>,
+
sub: &Did<'_>,
+
) -> Result<Url, ResolverError> {
+
let (metadata, identity) = self.resolve_from_identity(sub).await?;
+
if metadata.issuer != server_metadata.issuer {
+
return Err(ResolverError::Did(format!("DIDs did not match")));
+
}
+
Ok(identity
+
.pds_endpoint()
+
.ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?)
+
}
async fn resolve_oauth(
&self,
input: &str,
···
Ok(as_metadata)
}
}
+
+
#[async_trait::async_trait]
+
impl<T: OAuthResolver + Sync + Send> OAuthResolver for std::sync::Arc<T> {}
pub async fn resolve_authorization_server<T: HttpClient + ?Sized>(
client: &T,
+41 -1
crates/jacquard-oauth/src/scopes.rs
···
use jacquard_common::types::nsid::Nsid;
use jacquard_common::types::string::AtStrError;
use jacquard_common::{CowStr, IntoStatic};
use smol_str::{SmolStr, ToSmolStr};
/// Represents an AT Protocol OAuth scope
···
Profile,
/// Email scope - access to user email address
Email,
}
impl IntoStatic for Scope<'_> {
···
return CowStr::default();
}
-
let mut serialized: Vec<String> = scopes.iter().map(|scope| scope.to_string()).collect();
serialized.sort();
serialized.join(" ").into()
···
use jacquard_common::types::nsid::Nsid;
use jacquard_common::types::string::AtStrError;
use jacquard_common::{CowStr, IntoStatic};
+
use serde::de::Visitor;
+
use serde::{Deserialize, Serialize};
use smol_str::{SmolStr, ToSmolStr};
/// Represents an AT Protocol OAuth scope
···
Profile,
/// Email scope - access to user email address
Email,
+
}
+
+
impl Serialize for Scope<'_> {
+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
where
+
S: serde::Serializer,
+
{
+
serializer.serialize_str(&self.to_string_normalized())
+
}
+
}
+
+
impl<'de> Deserialize<'de> for Scope<'_> {
+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+
where
+
D: serde::Deserializer<'de>,
+
{
+
struct ScopeVisitor;
+
+
impl Visitor<'_> for ScopeVisitor {
+
type Value = Scope<'static>;
+
+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+
write!(formatter, "a scope string")
+
}
+
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+
where
+
E: serde::de::Error,
+
{
+
Scope::parse(v)
+
.map(|s| s.into_static())
+
.map_err(|e| serde::de::Error::custom(format!("{:?}", e)))
+
}
+
}
+
deserializer.deserialize_str(ScopeVisitor)
+
}
}
impl IntoStatic for Scope<'_> {
···
return CowStr::default();
}
+
let mut serialized: Vec<String> = scopes
+
.iter()
+
.map(|scope| scope.to_string_normalized())
+
.collect();
serialized.sort();
serialized.join(" ").into()
+326 -8
crates/jacquard-oauth/src/session.rs
···
-
use crate::types::TokenSet;
-
use jacquard_common::IntoStatic;
use jose_jwk::Key;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-
pub struct OauthSession<'s> {
pub dpop_key: Key,
#[serde(borrow)]
-
pub token_set: TokenSet<'s>,
}
-
impl IntoStatic for OauthSession<'_> {
-
type Output = OauthSession<'static>;
fn into_static(self) -> Self::Output {
-
OauthSession {
dpop_key: self.dpop_key,
-
token_set: self.token_set.into_static(),
}
}
}
···
+
use std::sync::Arc;
+
+
use crate::{
+
atproto::{AtprotoClientMetadata, atproto_client_metadata},
+
authstore::ClientAuthStore,
+
dpop::DpopExt,
+
keyset::Keyset,
+
request::{OAuthMetadata, refresh},
+
resolver::OAuthResolver,
+
scopes::Scope,
+
types::TokenSet,
+
};
+
use dashmap::DashMap;
+
use jacquard_common::{
+
CowStr, IntoStatic,
+
http_client::HttpClient,
+
session::SessionStoreError,
+
types::{did::Did, string::Datetime},
+
};
use jose_jwk::Key;
use serde::{Deserialize, Serialize};
+
use smol_str::{SmolStr, format_smolstr};
+
use tokio::sync::Mutex;
+
use url::Url;
+
+
pub trait DpopDataSource {
+
fn key(&self) -> &Key;
+
fn authserver_nonce(&self) -> Option<CowStr<'_>>;
+
fn set_authserver_nonce(&mut self, nonce: CowStr<'_>);
+
fn host_nonce(&self) -> Option<CowStr<'_>>;
+
fn set_host_nonce(&mut self, nonce: CowStr<'_>);
+
}
+
/// Persisted information about an OAuth session. Used to resume an active session.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+
pub struct ClientSessionData<'s> {
+
// 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.
+
#[serde(borrow)]
+
pub account_did: Did<'s>,
+
+
// 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.
+
pub session_id: CowStr<'s>,
+
+
// Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info.
+
pub host_url: Url,
+
+
// Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info.
+
pub authserver_url: Url,
+
+
// Full token endpoint
+
pub authserver_token_endpoint: CowStr<'s>,
+
+
// Full revocation endpoint, if it exists
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub authserver_revocation_endpoint: Option<CowStr<'s>>,
+
+
// The set of scopes approved for this session (returned in the initial token request)
+
pub scopes: Vec<Scope<'s>>,
+
+
#[serde(flatten)]
+
pub dpop_data: DpopClientData<'s>,
+
+
#[serde(flatten)]
+
pub token_set: TokenSet<'s>,
+
}
+
+
impl IntoStatic for ClientSessionData<'_> {
+
type Output = ClientSessionData<'static>;
+
+
fn into_static(self) -> Self::Output {
+
ClientSessionData {
+
authserver_url: self.authserver_url,
+
authserver_token_endpoint: self.authserver_token_endpoint.into_static(),
+
authserver_revocation_endpoint: self
+
.authserver_revocation_endpoint
+
.map(IntoStatic::into_static),
+
scopes: self.scopes.into_static(),
+
dpop_data: self.dpop_data.into_static(),
+
token_set: self.token_set.into_static(),
+
account_did: self.account_did.into_static(),
+
session_id: self.session_id.into_static(),
+
host_url: self.host_url,
+
}
+
}
+
}
+
+
impl ClientSessionData<'_> {
+
pub fn update_with_tokens(&mut self, token_set: TokenSet<'_>) {
+
if let Some(Ok(scopes)) = token_set
+
.scope
+
.as_ref()
+
.map(|scope| Scope::parse_multiple_reduced(&scope).map(IntoStatic::into_static))
+
{
+
self.scopes = scopes;
+
}
+
self.token_set = token_set.into_static();
+
}
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+
pub struct DpopClientData<'s> {
pub dpop_key: Key,
+
// Current auth server DPoP nonce
#[serde(borrow)]
+
pub dpop_authserver_nonce: CowStr<'s>,
+
// Current host ("resource server", eg PDS) DPoP nonce
+
pub dpop_host_nonce: CowStr<'s>,
}
+
impl IntoStatic for DpopClientData<'_> {
+
type Output = DpopClientData<'static>;
fn into_static(self) -> Self::Output {
+
DpopClientData {
dpop_key: self.dpop_key,
+
dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(),
+
dpop_host_nonce: self.dpop_host_nonce.into_static(),
}
}
}
+
+
impl DpopDataSource for DpopClientData<'_> {
+
fn key(&self) -> &Key {
+
&self.dpop_key
+
}
+
fn authserver_nonce(&self) -> Option<CowStr<'_>> {
+
Some(self.dpop_authserver_nonce.clone())
+
}
+
+
fn host_nonce(&self) -> Option<CowStr<'_>> {
+
Some(self.dpop_host_nonce.clone())
+
}
+
+
fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) {
+
self.dpop_authserver_nonce = nonce.into_static();
+
}
+
+
fn set_host_nonce(&mut self, nonce: CowStr<'_>) {
+
self.dpop_host_nonce = nonce.into_static();
+
}
+
}
+
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+
pub struct AuthRequestData<'s> {
+
// The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information.
+
#[serde(borrow)]
+
pub state: CowStr<'s>,
+
+
// URL of the auth server (eg, PDS or entryway)
+
pub authserver_url: Url,
+
+
// If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response.
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub account_did: Option<Did<'s>>,
+
+
// OAuth scope strings
+
pub scopes: Vec<Scope<'s>>,
+
+
// unique token in URI format, which will be used by the client in the auth flow redirect
+
pub request_uri: CowStr<'s>,
+
+
// Full token endpoint URL
+
pub authserver_token_endpoint: CowStr<'s>,
+
+
// Full revocation endpoint, if it exists
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub authserver_revocation_endpoint: Option<CowStr<'s>>,
+
+
// The secret token/nonce which a code challenge was generated from
+
pub pkce_verifier: CowStr<'s>,
+
+
#[serde(flatten)]
+
pub dpop_data: DpopReqData<'s>,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+
pub struct DpopReqData<'s> {
+
// The secret cryptographic key generated by the client for this specific OAuth session
+
pub dpop_key: Key,
+
// Server-provided DPoP nonce from auth request (PAR)
+
#[serde(borrow)]
+
pub dpop_authserver_nonce: Option<CowStr<'s>>,
+
}
+
+
impl DpopDataSource for DpopReqData<'_> {
+
fn key(&self) -> &Key {
+
&self.dpop_key
+
}
+
fn authserver_nonce(&self) -> Option<CowStr<'_>> {
+
self.dpop_authserver_nonce.clone()
+
}
+
+
fn host_nonce(&self) -> Option<CowStr<'_>> {
+
None
+
}
+
+
fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) {
+
self.dpop_authserver_nonce = Some(nonce.into_static());
+
}
+
+
fn set_host_nonce(&mut self, _nonce: CowStr<'_>) {}
+
}
+
+
#[derive(Clone, Debug)]
+
pub struct ClientData<'s> {
+
pub keyset: Option<Keyset>,
+
pub config: AtprotoClientMetadata<'s>,
+
}
+
+
pub struct ClientSession<'s> {
+
pub keyset: Option<Keyset>,
+
pub config: AtprotoClientMetadata<'s>,
+
pub session_data: ClientSessionData<'s>,
+
}
+
+
impl<'s> ClientSession<'s> {
+
pub fn new(
+
ClientData { keyset, config }: ClientData<'s>,
+
session_data: ClientSessionData<'s>,
+
) -> Self {
+
Self {
+
keyset,
+
config,
+
session_data,
+
}
+
}
+
+
pub async fn metadata<T: HttpClient + OAuthResolver + Send + Sync>(
+
&self,
+
client: &T,
+
) -> Result<OAuthMetadata, Error> {
+
Ok(OAuthMetadata {
+
server_metadata: client
+
.get_authorization_server_metadata(&self.session_data.authserver_url)
+
.await
+
.map_err(|e| Error::ServerAgent(crate::request::Error::ResolverError(e)))?,
+
client_metadata: atproto_client_metadata(self.config.clone(), &self.keyset)
+
.unwrap()
+
.into_static(),
+
keyset: self.keyset.clone(),
+
})
+
}
+
}
+
+
#[derive(thiserror::Error, Debug)]
+
pub enum Error {
+
#[error(transparent)]
+
ServerAgent(#[from] crate::request::Error),
+
#[error(transparent)]
+
Store(#[from] SessionStoreError),
+
#[error("session does not exist")]
+
SessionNotFound,
+
}
+
+
pub struct SessionRegistry<T, S>
+
where
+
T: OAuthResolver,
+
S: ClientAuthStore,
+
{
+
pub store: Arc<S>,
+
pub client: Arc<T>,
+
pub client_data: ClientData<'static>,
+
pending: DashMap<SmolStr, Arc<Mutex<()>>>,
+
}
+
+
impl<T, S> SessionRegistry<T, S>
+
where
+
S: ClientAuthStore,
+
T: OAuthResolver,
+
{
+
pub fn new(store: S, client: Arc<T>, client_data: ClientData<'static>) -> Self {
+
let store = Arc::new(store);
+
Self {
+
store: Arc::clone(&store),
+
client,
+
client_data,
+
pending: DashMap::new(),
+
}
+
}
+
}
+
+
impl<T, S> SessionRegistry<T, S>
+
where
+
S: ClientAuthStore + Send + Sync + 'static,
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
+
{
+
async fn get_refreshed(
+
&self,
+
did: &Did<'_>,
+
session_id: &str,
+
) -> Result<ClientSessionData<'_>, Error> {
+
let key = format_smolstr!("{}_{}", did, session_id);
+
let lock = self
+
.pending
+
.entry(key)
+
.or_insert_with(|| Arc::new(Mutex::new(())))
+
.clone();
+
let _guard = lock.lock().await;
+
+
let mut session = self
+
.store
+
.get_session(did, session_id)
+
.await?
+
.ok_or(Error::SessionNotFound)?;
+
if let Some(expires_at) = &session.token_set.expires_at {
+
if expires_at > &Datetime::now() {
+
return Ok(session);
+
}
+
}
+
let metadata = OAuthMetadata::new(&self.client, &self.client_data, &session).await?;
+
session = refresh(self.client.as_ref(), session, &metadata).await?;
+
self.store.upsert_session(session.clone()).await?;
+
+
Ok(session)
+
}
+
pub async fn get(
+
&self,
+
did: &Did<'_>,
+
session_id: &str,
+
refresh: bool,
+
) -> Result<ClientSessionData<'_>, Error> {
+
if refresh {
+
self.get_refreshed(did, session_id).await
+
} else {
+
// TODO: cached?
+
self.store
+
.get_session(did, session_id)
+
.await?
+
.ok_or(Error::SessionNotFound)
+
}
+
}
+
pub async fn set(&self, value: ClientSessionData<'_>) -> Result<(), Error> {
+
self.store.upsert_session(value).await?;
+
Ok(())
+
}
+
pub async fn del(&self, did: &Did<'_>, session_id: &str) -> Result<(), Error> {
+
self.store.delete_session(did, session_id).await?;
+
Ok(())
+
}
+
}
+3 -2
crates/jacquard-oauth/src/types.rs
···
pub use self::token::*;
use jacquard_common::CowStr;
use serde::Deserialize;
-
#[derive(Debug, Deserialize)]
pub enum AuthorizeOptionPrompt {
Login,
None,
···
#[derive(Debug)]
pub struct AuthorizeOptions<'s> {
-
pub redirect_uri: Option<CowStr<'s>>,
pub scopes: Vec<Scope<'s>>,
pub prompt: Option<AuthorizeOptionPrompt>,
pub state: Option<CowStr<'s>>,
···
pub use self::token::*;
use jacquard_common::CowStr;
use serde::Deserialize;
+
use url::Url;
+
#[derive(Debug, Deserialize, Clone, Copy)]
pub enum AuthorizeOptionPrompt {
Login,
None,
···
#[derive(Debug)]
pub struct AuthorizeOptions<'s> {
+
pub redirect_uri: Option<Url>,
pub scopes: Vec<Scope<'s>>,
pub prompt: Option<AuthorizeOptionPrompt>,
pub state: Option<CowStr<'s>>,
+3 -3
crates/jacquard-oauth/src/types/request.rs
···
}
#[derive(Serialize, Deserialize)]
-
pub struct PushedAuthorizationRequestParameters<'a> {
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
pub response_type: AuthorizationResponseType,
#[serde(borrow)]
···
}
}
-
impl IntoStatic for PushedAuthorizationRequestParameters<'_> {
-
type Output = PushedAuthorizationRequestParameters<'static>;
fn into_static(self) -> Self::Output {
Self::Output {
···
}
#[derive(Serialize, Deserialize)]
+
pub struct ParParameters<'a> {
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
pub response_type: AuthorizationResponseType,
#[serde(borrow)]
···
}
}
+
impl IntoStatic for ParParameters<'_> {
+
type Output = ParParameters<'static>;
fn into_static(self) -> Self::Output {
Self::Output {
+8 -36
crates/jacquard-oauth/src/types/response.rs
···
-
use jacquard_common::{CowStr, IntoStatic};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
-
pub struct OAuthParResponse<'r> {
-
#[serde(borrow)]
-
pub request_uri: CowStr<'r>,
pub expires_in: Option<u32>,
}
···
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
-
pub struct OAuthTokenResponse<'r> {
-
#[serde(borrow)]
-
pub access_token: CowStr<'r>,
pub token_type: OAuthTokenType,
pub expires_in: Option<i64>,
-
pub refresh_token: Option<CowStr<'r>>,
-
pub scope: Option<CowStr<'r>>,
// ATPROTO extension: add the sub claim to the token response to allow
// clients to resolve the PDS url (audience) using the did resolution
// mechanism.
-
pub sub: Option<CowStr<'r>>,
-
}
-
-
impl IntoStatic for OAuthTokenResponse<'_> {
-
type Output = OAuthTokenResponse<'static>;
-
-
fn into_static(self) -> Self::Output {
-
OAuthTokenResponse {
-
access_token: self.access_token.into_static(),
-
token_type: self.token_type,
-
expires_in: self.expires_in,
-
refresh_token: self.refresh_token.map(|s| s.into_static()),
-
scope: self.scope.map(|s| s.into_static()),
-
sub: self.sub.map(|s| s.into_static()),
-
}
-
}
-
}
-
-
impl IntoStatic for OAuthParResponse<'_> {
-
type Output = OAuthParResponse<'static>;
-
-
fn into_static(self) -> Self::Output {
-
OAuthParResponse {
-
request_uri: self.request_uri.into_static(),
-
expires_in: self.expires_in,
-
}
-
}
}
···
use serde::{Deserialize, Serialize};
+
use smol_str::SmolStr;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+
pub struct OAuthParResponse {
+
pub request_uri: SmolStr,
pub expires_in: Option<u32>,
}
···
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+
pub struct OAuthTokenResponse {
+
pub access_token: SmolStr,
pub token_type: OAuthTokenType,
pub expires_in: Option<i64>,
+
pub refresh_token: Option<SmolStr>,
+
pub scope: Option<SmolStr>,
// ATPROTO extension: add the sub claim to the token response to allow
// clients to resolve the PDS url (audience) using the did resolution
// mechanism.
+
pub sub: Option<SmolStr>,
}
+95
crates/jacquard-oauth/src/utils.rs
···
···
+
use base64::Engine;
+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+
use elliptic_curve::SecretKey;
+
use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr};
+
use jose_jwk::{Key, crypto};
+
use rand::{CryptoRng, RngCore, rngs::ThreadRng};
+
use sha2::{Digest, Sha256};
+
use smol_str::ToSmolStr;
+
use std::cmp::Ordering;
+
+
use crate::{FALLBACK_ALG, types::OAuthAuthorizationServerMetadata};
+
+
pub fn generate_key(allowed_algos: &[CowStr]) -> Option<Key> {
+
for alg in allowed_algos {
+
#[allow(clippy::single_match)]
+
match alg.as_ref() {
+
"ES256" => {
+
return Some(Key::from(&crypto::Key::from(
+
SecretKey::<p256::NistP256>::random(&mut ThreadRng::default()),
+
)));
+
}
+
_ => {
+
// TODO: Implement other algorithms?
+
}
+
}
+
}
+
None
+
}
+
+
pub fn generate_nonce() -> CowStr<'static> {
+
URL_SAFE_NO_PAD
+
.encode(get_random_values::<_, 16>(&mut ThreadRng::default()))
+
.into()
+
}
+
+
pub fn generate_verifier() -> CowStr<'static> {
+
URL_SAFE_NO_PAD
+
.encode(get_random_values::<_, 43>(&mut ThreadRng::default()))
+
.into()
+
}
+
+
pub fn get_random_values<R, const LEN: usize>(rng: &mut R) -> [u8; LEN]
+
where
+
R: RngCore + CryptoRng,
+
{
+
let mut bytes = [0u8; LEN];
+
rng.fill_bytes(&mut bytes);
+
bytes
+
}
+
+
// 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order)
+
pub fn compare_algos(a: &CowStr, b: &CowStr) -> Ordering {
+
if a.as_ref() == "ES256K" {
+
return Ordering::Less;
+
}
+
if b.as_ref() == "ES256K" {
+
return Ordering::Greater;
+
}
+
for prefix in ["ES", "PS", "RS"] {
+
if let Some(stripped_a) = a.strip_prefix(prefix) {
+
if let Some(stripped_b) = b.strip_prefix(prefix) {
+
if let (Ok(len_a), Ok(len_b)) =
+
(stripped_a.parse::<u32>(), stripped_b.parse::<u32>())
+
{
+
return len_a.cmp(&len_b);
+
}
+
} else {
+
return Ordering::Less;
+
}
+
} else if b.starts_with(prefix) {
+
return Ordering::Greater;
+
}
+
}
+
Ordering::Equal
+
}
+
+
pub fn generate_pkce() -> (CowStr<'static>, CowStr<'static>) {
+
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
+
let verifier = generate_verifier();
+
(
+
URL_SAFE_NO_PAD
+
.encode(Sha256::digest(&verifier.as_str()))
+
.into(),
+
verifier,
+
)
+
}
+
+
pub fn generate_dpop_key(metadata: &OAuthAuthorizationServerMetadata) -> Option<Key> {
+
let mut algs = metadata
+
.dpop_signing_alg_values_supported
+
.clone()
+
.unwrap_or(vec![FALLBACK_ALG.into()]);
+
algs.sort_by(compare_algos);
+
generate_key(&algs)
+
}
+1
crates/jacquard/src/client/at_client.rs
···
pub async fn set_session(&self, session: AuthSession) -> Result<(), SessionStoreError> {
let s = session.clone();
let did = s.did().clone().into_static();
self.tokens.set(did, session).await
}
···
pub async fn set_session(&self, session: AuthSession) -> Result<(), SessionStoreError> {
let s = session.clone();
let did = s.did().clone().into_static();
+
self.refresh_lock.lock().await.replace(did.clone());
self.tokens.set(did, session).await
}