···190190//! It's also not too bad to write, once you're aware of the pattern and why it works. If you run
191191//! into a lifetime/borrowing inference issue in jacquard, please contact the crate author. She'd
192192//! be happy to debug, and if it's using a method from one of the jacquard crates and seems like
193193-//! it *should* just work, that is a bug in jacquard, and you should [file an issue](https://tangled.org/@nonbinary.computer/jacquard/).
193193+//! it *should* just work, that is a bug in jacquard, and you should [file an issue](https://tangled.org/nonbinary.computer/jacquard/).
194194195195#![no_std]
196196#![warn(missing_docs)]
+69-11
crates/jacquard-identity/src/lexicon_resolver.rs
···6767}
68686969impl LexiconResolutionError {
7070+ /// Create a new error with the given kind and optional source.
7071 pub fn new(
7172 kind: LexiconResolutionErrorKind,
7273 source: Option<Box<dyn std::error::Error + Send + Sync>>,
···7879 }
7980 }
80818282+ /// Return the error kind.
8183 pub fn kind(&self) -> &LexiconResolutionErrorKind {
8284 &self.kind
8385 }
···9395 self.context.as_deref()
9496 }
95979898+ /// Create an error for a failed DNS TXT lookup while resolving a lexicon authority.
9699 pub fn dns_lookup_failed(
97100 authority: impl Into<SmolStr>,
98101 source: impl std::error::Error + Send + Sync + 'static,
···105108 )
106109 }
107110111111+ /// Create an error for when DNS records exist but contain no `did=...` entry.
108112 pub fn no_did_found(authority: impl Into<SmolStr>) -> Self {
109113 Self::new(
110114 LexiconResolutionErrorKind::NoDIDFound {
···114118 )
115119 }
116120121121+ /// Create an error for a syntactically invalid DID found in DNS for the given authority.
117122 pub fn invalid_did(authority: impl Into<SmolStr>, value: impl Into<SmolStr>) -> Self {
118123 Self::new(
119124 LexiconResolutionErrorKind::InvalidDID {
···124129 )
125130 }
126131132132+ /// Create an error for when DNS is not available (feature disabled or WASM target).
127133 pub fn dns_not_configured() -> Self {
128134 Self::new(LexiconResolutionErrorKind::DnsNotConfigured, None)
129135 }
130136137137+ /// Create an error for a failure to fetch the lexicon record for an NSID.
131138 pub fn fetch_failed(
132139 nsid: impl Into<SmolStr>,
133140 source: impl std::error::Error + Send + Sync + 'static,
···138145 )
139146 }
140147148148+ /// Create an error for a failure to parse a fetched lexicon schema document.
141149 pub fn parse_failed(
142150 nsid: impl Into<SmolStr>,
143151 source: impl std::error::Error + Send + Sync + 'static,
···148156 )
149157 }
150158159159+ /// Create a generic resolution failure error with a descriptive message.
151160 pub fn resolution_failed(nsid: impl Into<SmolStr>, message: impl Into<SmolStr>) -> Self {
152161 Self::new(
153162 LexiconResolutionErrorKind::ResolutionFailed {
···158167 )
159168 }
160169170170+ /// Create an error for a non-success HTTP status received while fetching a lexicon.
161171 pub fn http_error(nsid: impl Into<SmolStr>, status: u16) -> Self {
162172 Self::new(
163173 LexiconResolutionErrorKind::HttpError {
···168178 )
169179 }
170180181181+ /// Create an error for a required field missing from the XRPC response.
171182 pub fn missing_response_field(nsid: impl Into<SmolStr>, field: &'static str) -> Self {
172183 Self::new(
173184 LexiconResolutionErrorKind::MissingResponseField {
···178189 )
179190 }
180191192192+ /// Create an error for an invalid lexicon collection NSID.
181193 pub fn invalid_collection() -> Self {
182194 Self::new(LexiconResolutionErrorKind::InvalidCollection, None)
183195 }
184196197197+ /// Create an error for a lexicon record response that is missing its CID.
185198 pub fn missing_cid(nsid: impl Into<SmolStr>) -> Self {
186199 Self::new(
187200 LexiconResolutionErrorKind::MissingCID { nsid: nsid.into() },
···200213#[derive(Debug, thiserror::Error, miette::Diagnostic)]
201214#[non_exhaustive]
202215pub enum LexiconResolutionErrorKind {
216216+ /// DNS TXT lookup for the lexicon authority failed.
203217 #[error("DNS lookup failed for authority {authority}")]
204218 #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))]
205205- DnsLookupFailed { authority: SmolStr },
219219+ DnsLookupFailed {
220220+ /// The NSID authority segment that was being looked up.
221221+ authority: SmolStr,
222222+ },
206223224224+ /// DNS records were reachable but contained no `did=...` entry.
207225 #[error("no DID found in DNS for authority {authority}")]
208226 #[diagnostic(
209227 code(jacquard::lexicon::no_did_found),
210228 help("ensure _lexicon.{{reversed-authority}} TXT record exists with did=...")
211229 )]
212212- NoDIDFound { authority: SmolStr },
230230+ NoDIDFound {
231231+ /// The NSID authority segment that was being looked up.
232232+ authority: SmolStr,
233233+ },
213234235235+ /// DNS returned a `did=...` entry but its value is not a valid DID.
214236 #[error("invalid DID in DNS for authority {authority}: {value}")]
215237 #[diagnostic(code(jacquard::lexicon::invalid_did))]
216216- InvalidDID { authority: SmolStr, value: SmolStr },
238238+ InvalidDID {
239239+ /// The NSID authority segment.
240240+ authority: SmolStr,
241241+ /// The raw invalid DID string found in DNS.
242242+ value: SmolStr,
243243+ },
217244245245+ /// DNS is not available on this build (the `dns` feature is disabled or target is WASM).
218246 #[error("DNS not configured (dns feature disabled or WASM target)")]
219247 #[diagnostic(
220248 code(jacquard::lexicon::dns_not_configured),
···222250 )]
223251 DnsNotConfigured,
224252253253+ /// XRPC or HTTP request to fetch the lexicon record failed.
225254 #[error("failed to fetch lexicon record for {nsid}")]
226255 #[diagnostic(code(jacquard::lexicon::fetch_failed))]
227227- FetchFailed { nsid: SmolStr },
256256+ FetchFailed {
257257+ /// The NSID of the lexicon that could not be fetched.
258258+ nsid: SmolStr,
259259+ },
228260261261+ /// The fetched lexicon record could not be deserialized as a `LexiconDoc`.
229262 #[error("failed to parse lexicon schema for {nsid}")]
230263 #[diagnostic(code(jacquard::lexicon::parse_failed))]
231231- ParseFailed { nsid: SmolStr },
264264+ ParseFailed {
265265+ /// The NSID of the lexicon that could not be parsed.
266266+ nsid: SmolStr,
267267+ },
232268269269+ /// Generic resolution failure with a descriptive message.
233270 #[error("failed to parse lexicon schema for {nsid}")]
234271 #[diagnostic(code(jacquard::lexicon::resolution_failed))]
235235- ResolutionFailed { nsid: SmolStr, message: SmolStr },
272272+ ResolutionFailed {
273273+ /// The NSID of the lexicon being resolved.
274274+ nsid: SmolStr,
275275+ /// Human-readable description of what went wrong.
276276+ message: SmolStr,
277277+ },
236278237237- /// HTTP non-success status from lexicon fetch
279279+ /// HTTP non-success status from lexicon fetch.
238280 #[error("HTTP {status} fetching lexicon {nsid}")]
239281 #[diagnostic(code(jacquard::lexicon::http_error))]
240240- HttpError { nsid: SmolStr, status: u16 },
282282+ HttpError {
283283+ /// The NSID of the lexicon being fetched.
284284+ nsid: SmolStr,
285285+ /// The HTTP status code received.
286286+ status: u16,
287287+ },
241288242242- /// Required field missing in XRPC response
289289+ /// Required field missing in XRPC response.
243290 #[error("missing '{field}' field in response for {nsid}")]
244291 #[diagnostic(
245292 code(jacquard::lexicon::missing_response_field),
246293 help("the XRPC response is missing a required field")
247294 )]
248248- MissingResponseField { nsid: SmolStr, field: &'static str },
295295+ MissingResponseField {
296296+ /// The NSID of the lexicon being fetched.
297297+ nsid: SmolStr,
298298+ /// Name of the missing field.
299299+ field: &'static str,
300300+ },
249301302302+ /// The lexicon collection NSID was not valid.
250303 #[error("invalid collection NSID")]
251304 #[diagnostic(code(jacquard::lexicon::invalid_collection))]
252305 InvalidCollection,
253306307307+ /// The `getRecord` response did not include a CID for the lexicon record.
254308 #[error("record missing CID for {nsid}")]
255309 #[diagnostic(code(jacquard::lexicon::missing_cid))]
256256- MissingCID { nsid: SmolStr },
310310+ MissingCID {
311311+ /// The NSID of the lexicon whose record was missing a CID.
312312+ nsid: SmolStr,
313313+ },
257314315315+ /// Identity resolution failed while locating the PDS that hosts the lexicon.
258316 #[error(transparent)]
259317 #[diagnostic(code(jacquard::lexicon::identity_resolution_failed))]
260318 IdentityResolution(#[from] crate::resolver::IdentityError),
+10-6
crates/jacquard-identity/src/lib.rs
···6565//!
6666//! Both support `.parse()` for borrowing and validation.
67676868-// use crate::CowStr; // not currently needed directly here
6969-6868+#![warn(missing_docs)]
7069#![cfg_attr(target_arch = "wasm32", allow(unused))]
7170pub mod lexicon_resolver;
7271pub mod resolver;
···284283#[cfg(feature = "cache")]
285284#[derive(Clone)]
286285pub struct ResolverCaches {
286286+ /// Cache mapping handles to their resolved DIDs.
287287 pub handle_to_did: cache_impl::Cache<Handle<'static>, Did<'static>>,
288288+ /// Cache mapping DIDs to their full DID documents.
288289 pub did_to_doc: cache_impl::Cache<Did<'static>, Arc<DidDocResponse>>,
290290+ /// Cache mapping authority strings (e.g., PDS hosts) to DIDs.
289291 pub authority_to_did: cache_impl::Cache<SmolStr, Did<'static>>,
292292+ /// Cache mapping NSIDs to their resolved lexicon schemas.
290293 pub nsid_to_schema: cache_impl::Cache<Nsid<'static>, Arc<ResolvedLexiconSchema<'static>>>,
291294}
292295293296#[cfg(feature = "cache")]
294297impl ResolverCaches {
298298+ /// Creates a new set of resolver caches from the given configuration.
295299 pub fn new(config: &CacheConfig) -> Self {
296300 Self {
297301 handle_to_did: cache_impl::new_cache(
···11821186/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
11831187pub type PublicResolver = JacquardResolver;
1184118811851185-impl Default for PublicResolver {
11891189+impl Default for JacquardResolver {
11861190 /// Build a resolver with:
11871191 /// - reqwest HTTP client
11881192 /// - Public fallbacks enabled for handle resolution
···11901194 ///
11911195 /// Example
11921196 /// ```ignore
11931193- /// use jacquard::identity::resolver::PublicResolver;
11941194- /// let resolver = PublicResolver::default();
11971197+ /// use jacquard::identity::resolver::JacquardResolver;
11981198+ /// let resolver = JacquardResolver::default();
11951199 /// ```
11961200 fn default() -> Self {
11971201 let http = reqwest::Client::new();
···1207121112081212/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
12091213/// mini-doc fallbacks, unauthenticated by default.
12101210-pub fn slingshot_resolver_default() -> PublicResolver {
12141214+pub fn slingshot_resolver_default() -> JacquardResolver {
12111215 let http = reqwest::Client::new();
12121216 let mut opts = ResolverOptions::default();
12131217 opts.plc_source = PlcSource::slingshot_default();
+2
crates/jacquard-identity/src/resolver.rs
···661661 help("document id differs from requested DID; do not trust this document")
662662 )]
663663 DocIdMismatch {
664664+ /// The DID that was requested and expected to appear as the document `id`.
664665 expected: Did<'static>,
666666+ /// The DID document we *actually* got
665667 doc: DidDocument<'static>,
666668 },
667669}
+66-5
crates/jacquard-oauth/src/atproto.rs
···77use smol_str::{SmolStr, ToSmolStr};
88use thiserror::Error;
991010+/// Errors that can occur when building AT Protocol OAuth client metadata.
1011#[derive(Error, Debug)]
1112#[non_exhaustive]
1213pub enum Error {
1414+ /// The `client_id` is not a valid URL.
1315 #[error("`client_id` must be a valid URL")]
1416 InvalidClientId,
1717+ /// The `grant_types` list does not include `authorization_code`, which is required by atproto.
1518 #[error("`grant_types` must include `authorization_code`")]
1619 InvalidGrantTypes,
2020+ /// The `scope` list does not include `atproto`, which is required for all atproto clients.
1721 #[error("`scope` must not include `atproto`")]
1822 InvalidScope,
2323+ /// No redirect URIs were provided; at least one is required.
1924 #[error("`redirect_uris` must not be empty")]
2025 EmptyRedirectUris,
2626+ /// The `private_key_jwt` auth method was requested but no JWK keys were provided.
2127 #[error("`private_key_jwt` auth method requires `jwks` keys")]
2228 EmptyJwks,
2929+ /// Signing algorithm mismatch: `private_key_jwt` requires `token_endpoint_auth_signing_alg`,
3030+ /// and non-`private_key_jwt` methods must not provide it.
2331 #[error(
2432 "`private_key_jwt` auth method requires `token_endpoint_auth_signing_alg`, otherwise must not be provided"
2533 )]
2634 AuthSigningAlg,
3535+ /// HTML form serialization of the loopback `client_id` query string failed.
2736 #[error(transparent)]
2837 SerdeHtmlForm(#[from] serde_html_form::ser::Error),
3838+ /// A localhost-specific validation error occurred.
2939 #[error(transparent)]
3040 LocalhostClient(#[from] LocalhostClientError),
3141}
32424343+/// Errors specific to validating a loopback (localhost) OAuth client's redirect URIs.
4444+///
4545+/// The AT Protocol spec has specific requirements for loopback clients: redirect URIs must
4646+/// use the `http` scheme and must point to actual loopback addresses (not the hostname `localhost`).
3347#[derive(Error, Debug)]
3448#[non_exhaustive]
3549pub enum LocalhostClientError {
5050+ /// The redirect URI could not be parsed.
3651 #[error("invalid redirect_uri: {0}")]
3752 Invalid(#[from] jacquard_common::deps::fluent_uri::ParseError),
5353+ /// Loopback redirect URIs must use `http:`, not `https:` or any other scheme.
3854 #[error("loopback client_id must use `http:` redirect_uri")]
3955 NotHttpScheme,
5656+ /// The hostname `localhost` is not allowed; use a numeric loopback address instead.
4057 #[error("loopback client_id must not use `localhost` as redirect_uri hostname")]
4158 Localhost,
5959+ /// The redirect URI host is not a loopback address (127.x.x.x or ::1).
4260 #[error("loopback client_id must not use loopback addresses as redirect_uri")]
4361 NotLoopbackHost,
4462}
45636464+/// Convenience result type for AT Protocol client metadata operations.
4665pub type Result<T> = core::result::Result<T, Error>;
47666767+/// The token endpoint authentication method for an OAuth client.
6868+///
6969+/// AT Protocol clients either authenticate with no client secret (public/loopback clients)
7070+/// or with a private key JWT signed by a key from the client's JWK set.
4871#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
4972#[serde(rename_all = "snake_case")]
5073pub enum AuthMethod {
7474+ /// No client authentication; used for public and loopback clients.
5175 None,
5252- // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
7676+ /// Authenticate using a JWT signed with a private key from the client's JWK set.
7777+ /// <https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication>
5378 PrivateKeyJwt,
5479}
5580···6287 }
6388}
64899090+/// OAuth 2.0 grant types supported by AT Protocol clients.
6591#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
6692#[serde(rename_all = "snake_case")]
6793pub enum GrantType {
9494+ /// Standard authorization code grant, required by atproto.
6895 AuthorizationCode,
9696+ /// Refresh token grant, used to obtain new access tokens without re-authorization.
6997 RefreshToken,
7098}
7199···78106 }
79107}
80108109109+/// AT Protocol-specific OAuth client metadata, used to describe a client before converting to
110110+/// the generic [`OAuthClientMetadata`] format for server registration.
111111+///
112112+/// This type provides a validated, atproto-aware view of client registration data, with
113113+/// typed fields for URIs and scopes rather than raw strings. Use [`atproto_client_metadata`]
114114+/// to convert this into the wire format expected by OAuth servers.
81115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82116pub struct AtprotoClientMetadata<'m> {
117117+ /// The unique identifier for this client, typically the URL of its metadata document.
83118 pub client_id: Uri<String>,
119119+ /// The URI of the client's homepage or information page.
84120 pub client_uri: Option<Uri<String>>,
121121+ /// The list of allowed redirect URIs for the authorization code flow.
85122 pub redirect_uris: Vec<Uri<String>>,
123123+ /// The grant types this client will use.
86124 pub grant_types: Vec<GrantType>,
125125+ /// The OAuth scopes this client requests; must include `atproto`.
87126 #[serde(borrow)]
88127 pub scopes: Vec<Scope<'m>>,
128128+ /// URI pointing to the client's JWK Set; mutually exclusive with inline `jwks`.
89129 pub jwks_uri: Option<Uri<String>>,
130130+ /// Human-readable display name for the client.
90131 pub client_name: Option<SmolStr>,
132132+ /// URI of the client's logo image.
91133 pub logo_uri: Option<Uri<String>>,
134134+ /// URI of the client's terms of service document.
92135 pub tos_uri: Option<Uri<String>>,
136136+ /// URI of the client's privacy policy document.
93137 pub privacy_policy_uri: Option<Uri<String>>,
94138}
95139···112156}
113157114158impl<'m> AtprotoClientMetadata<'m> {
159159+ /// Attach optional production branding fields to the metadata.
160160+ ///
161161+ /// Chainable builder method for setting display name, logo, and policy URLs after
162162+ /// constructing the base metadata.
115163 pub fn with_prod_info(
116164 mut self,
117165 client_name: &str,
···126174 self
127175 }
128176177177+ /// Create a default loopback client metadata with the `atproto` and `transition:generic` scopes.
178178+ ///
179179+ /// This is a convenience constructor for local development and CLI tools. The resulting
180180+ /// metadata uses `http://localhost` as the `client_id` with both IPv4 and IPv6 loopback
181181+ /// redirect URIs.
129182 pub fn default_localhost() -> Self {
130183 Self::new_localhost(
131184 None,
···133186 )
134187 }
135188189189+ /// Create loopback client metadata with optional custom redirect URIs and scopes.
190190+ ///
191191+ /// Encodes non-default redirect URIs and scopes into the `client_id` query string as
192192+ /// required by the AT Protocol loopback client specification. When `redirect_uris` or
193193+ /// `scopes` are `None`, sensible defaults (IPv4 + IPv6 loopback addresses, `atproto` scope)
194194+ /// are used.
136195 pub fn new_localhost(
137196 redirect_uris: Option<Vec<Uri<String>>>,
138197 scopes: Option<Vec<Scope<'static>>>,
···181240 }
182241}
183242243243+/// Convert [`AtprotoClientMetadata`] into the [`OAuthClientMetadata`] wire format.
244244+///
245245+/// Validates all atproto-specific constraints (required scopes, grant types, redirect URIs),
246246+/// selects the appropriate `token_endpoint_auth_method` based on whether a keyset is provided,
247247+/// and serializes scopes and grant types into their string representations. Returns an error
248248+/// if any required field is missing or invalid.
184249pub fn atproto_client_metadata<'m>(
185250 metadata: AtprotoClientMetadata<'m>,
186251 keyset: &Option<Keyset>,
187252) -> Result<OAuthClientMetadata<'static>> {
188188- // For non-loopback clients, require a keyset/JWKs.
189253 let is_loopback = metadata.client_id.scheme().as_str() == "http"
190254 && metadata.client_id.authority().map(|a| a.host()) == Some("localhost");
191255 let application_type = if is_loopback {
···193257 } else {
194258 Some(CowStr::new_static("web"))
195259 };
196196- // if !is_loopback && keyset.is_none() {
197197- // return Err(Error::EmptyJwks);
198198- // }
199260 if metadata.redirect_uris.is_empty() {
200261 return Err(Error::EmptyRedirectUris);
201262 }
+15
crates/jacquard-oauth/src/authstore.rs
···11111212use crate::session::{AuthRequestData, ClientSessionData};
13131414+/// Persistent storage backend for OAuth client sessions and in-flight authorization requests.
1515+///
1616+/// Implementors are responsible for durably storing two categories of data:
1717+/// - Active client sessions (access tokens, refresh tokens, nonces) keyed by DID + session ID.
1818+/// - Pending authorization request state, keyed by the OAuth `state` parameter, which must
1919+/// survive the round-trip to the authorization server and be cleaned up after use.
1420#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
1521pub trait ClientAuthStore {
2222+ /// Retrieve an active session for the given DID and session identifier, if one exists.
1623 fn get_session(
1724 &self,
1825 did: &Did<'_>,
1926 session_id: &str,
2027 ) -> impl Future<Output = Result<Option<ClientSessionData<'_>>, SessionStoreError>>;
21282929+ /// Insert or update a session, replacing any existing entry for the same DID and session ID.
2230 fn upsert_session(
2331 &self,
2432 session: ClientSessionData<'_>,
2533 ) -> impl Future<Output = Result<(), SessionStoreError>>;
26343535+ /// Delete the session for the given DID and session identifier.
2736 fn delete_session(
2837 &self,
2938 did: &Did<'_>,
3039 session_id: &str,
3140 ) -> impl Future<Output = Result<(), SessionStoreError>>;
32414242+ /// Retrieve the authorization request data associated with the given OAuth `state` value.
3343 fn get_auth_req_info(
3444 &self,
3545 state: &str,
3646 ) -> impl Future<Output = Result<Option<AuthRequestData<'_>>, SessionStoreError>>;
37474848+ /// Persist authorization request data so it can be retrieved after the OAuth redirect.
3849 fn save_auth_req_info(
3950 &self,
4051 auth_req_info: &AuthRequestData<'_>,
4152 ) -> impl Future<Output = Result<(), SessionStoreError>>;
42535454+ /// Remove authorization request data after the callback has been handled.
4355 fn delete_auth_req_info(
4456 &self,
4557 state: &str,
4658 ) -> impl Future<Output = Result<(), SessionStoreError>>;
4759}
48606161+/// An in-memory implementation of [`ClientAuthStore`], suitable for testing and single-process
6262+/// deployments where session persistence across restarts is not required.
4963pub struct MemoryAuthStore {
5064 sessions: DashMap<SmolStr, ClientSessionData<'static>>,
5165 auth_reqs: DashMap<SmolStr, AuthRequestData<'static>>,
5266}
53675468impl MemoryAuthStore {
6969+ /// Create a new, empty in-memory auth store.
5570 pub fn new() -> Self {
5671 Self {
5772 sessions: DashMap::new(),
+88
crates/jacquard-oauth/src/client.rs
···3434use std::{future::Future, sync::Arc};
3535use tokio::sync::RwLock;
36363737+/// The top-level OAuth client responsible for driving the authorization flow.
3738pub struct OAuthClient<T, S>
3839where
3940 T: OAuthResolver,
4041 S: ClientAuthStore,
4142{
4343+ /// Shared session registry that mediates access to the backing auth store.
4244 pub registry: Arc<SessionRegistry<T, S>>,
4545+ /// Default call options applied to every outgoing XRPC request.
4346 pub options: RwLock<CallOptions<'static>>,
4747+ /// Override for the XRPC base URI; falls back to the public Bluesky AppView when `None`.
4448 pub endpoint: RwLock<Option<Uri<String>>>,
4949+ /// Underlying HTTP/identity/OAuth resolver used for all network operations.
4550 pub client: Arc<T>,
4651}
47524853impl<S: ClientAuthStore> OAuthClient<JacquardResolver, S> {
5454+ /// Create an `OAuthClient` using the default [`JacquardResolver`] for identity and metadata resolution.
4955 pub fn new(store: S, client_data: ClientData<'static>) -> Self {
5056 let client = JacquardResolver::default();
5157 Self::new_from_resolver(store, client, client_data)
···103109 T: OAuthResolver,
104110 S: ClientAuthStore,
105111{
112112+ /// Create an OAuth client from an explicit resolver instance, taking ownership of both.
106113 pub fn new_from_resolver(store: S, client: T, client_data: ClientData<'static>) -> Self {
107114 // #[cfg(feature = "tracing")]
108115 // tracing::info!(
···122129 }
123130 }
124131132132+ /// Create an OAuth client from already-`Arc`-wrapped store and resolver.
125133 pub fn new_with_shared(
126134 store: Arc<S>,
127135 client: Arc<T>,
···146154 S: ClientAuthStore + Send + Sync + 'static,
147155 T: OAuthResolver + DpopExt + Send + Sync + 'static,
148156{
157157+ /// Return the public JWK set for this client's keyset, or an empty set if no keyset is configured.
149158 pub fn jwks(&self) -> JwkSet {
150159 self.registry
151160 .client_data
···154163 .map(|keyset| keyset.public_jwks())
155164 .unwrap_or_default()
156165 }
166166+ /// Begin an OAuth authorization flow and return the URL to which the user should be redirected.
167167+ ///
168168+ /// This resolves OAuth metadata for the given `input` (a handle, DID, or PDS/entryway URL),
169169+ /// performs a Pushed Authorization Request (PAR) to the authorization server, persists the
170170+ /// resulting state for later callback verification, and returns a fully-constructed
171171+ /// authorization endpoint URL.
172172+ ///
173173+ /// The caller is responsible for redirecting the user's browser to the returned URL.
157174 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self, input), fields(input = input.as_ref())))]
158175 pub async fn start_auth(
159176 &self,
···205222 .unwrap())
206223 }
207224225225+ /// Complete the OAuth authorization flow after the authorization server redirects back to the client.
226226+ ///
227227+ /// Validates the `state` and optional `iss` parameters, exchanges the authorization code for
228228+ /// tokens via the token endpoint, verifies the `sub` claim against the expected issuer, and
229229+ /// persists the resulting session. On success returns an [`OAuthSession`] ready for API calls.
208230 #[cfg_attr(feature = "tracing", tracing::instrument(level = "info", skip_all, fields(state = params.state.as_ref().map(|s| s.as_ref()))))]
209231 pub async fn callback(&self, params: CallbackParams<'_>) -> Result<OAuthSession<T, S>> {
210232 let Some(state_key) = params.state else {
···294316 ))
295317 }
296318319319+ /// Restore a previously created session from the backing store, refreshing tokens if needed.
297320 pub async fn restore(&self, did: &Did<'_>, session_id: &str) -> Result<OAuthSession<T, S>> {
298321 self.create_session(self.registry.get(did, session_id, true).await?)
299322 .await
300323 }
301324325325+ /// Revoke a session by deleting it from the backing store.
326326+ ///
327327+ /// Note: this removes the session from local storage but does **not** call the authorization
328328+ /// server's revocation endpoint. To also invalidate the token server-side, prefer
329329+ /// [`OAuthSession::logout`], which calls `revoke` on the token before deleting the session.
302330 pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> {
303331 Ok(self.registry.del(did, session_id).await?)
304332 }
···398426 }
399427}
400428429429+/// An active OAuth session for a specific account, used to make authenticated API requests.
430430+///
431431+/// `OAuthSession` holds the DPoP-bound token set for one account and handles transparent
432432+/// token refresh on `401 invalid_token` responses. The optional `W` type parameter allows
433433+/// attaching a WebSocket client (defaults to `()` when WebSocket support is not needed).
434434+///
435435+/// Obtain an `OAuthSession` from [`OAuthClient::callback`] or [`OAuthClient::restore`].
401436pub struct OAuthSession<T, S, W = ()>
402437where
403438 T: OAuthResolver,
404439 S: ClientAuthStore,
405440{
441441+ /// Shared registry used to persist and retrieve session data across refresh operations.
406442 pub registry: Arc<SessionRegistry<T, S>>,
443443+ /// Underlying HTTP/identity/OAuth resolver shared with the parent `OAuthClient`.
407444 pub client: Arc<T>,
445445+ /// Optional WebSocket client; `()` when WebSocket support is not required.
408446 pub ws_client: W,
447447+ /// Mutable session data including DPoP key, nonces, and token set.
409448 pub data: RwLock<ClientSessionData<'static>>,
449449+ /// Default call options applied to every outgoing XRPC request from this session.
410450 pub options: RwLock<CallOptions<'static>>,
411451}
412452···415455 T: OAuthResolver,
416456 S: ClientAuthStore,
417457{
458458+ /// Create a new session without a WebSocket client.
459459+ ///
460460+ /// This is the standard constructor used by [`OAuthClient::callback`] and
461461+ /// [`OAuthClient::restore`]. For WebSocket support use [`OAuthSession::new_with_ws`].
418462 pub fn new(
419463 registry: Arc<SessionRegistry<T, S>>,
420464 client: Arc<T>,
···435479 T: OAuthResolver,
436480 S: ClientAuthStore,
437481{
482482+ /// Create a new session with an attached WebSocket client.
483483+ ///
484484+ /// Use this variant when the session needs to support WebSocket subscriptions in addition
485485+ /// to standard XRPC calls. The `ws_client` is exposed via [`OAuthSession::ws_client`] and
486486+ /// is used by the `WebSocketClient` impl when the `websocket` feature is enabled.
438487 pub fn new_with_ws(
439488 registry: Arc<SessionRegistry<T, S>>,
440489 client: Arc<T>,
···450499 }
451500 }
452501502502+ /// Consume this session and return a new one with the given call options pre-applied.
503503+ ///
504504+ /// Useful for setting request-level defaults (e.g., `atproto-proxy` or custom headers) once
505505+ /// at construction time rather than passing them to every individual XRPC call.
453506 pub fn with_options(self, options: CallOptions<'_>) -> Self {
454507 Self {
455508 registry: self.registry,
···465518 &self.ws_client
466519 }
467520521521+ /// Replace the default call options for this session without consuming it.
468522 pub async fn set_options(&self, options: CallOptions<'_>) {
469523 *self.options.write().await = options.into_static();
470524 }
471525526526+ /// Return the DID and session ID for this session.
527527+ ///
528528+ /// The session ID is the random `state` token generated during the PAR flow and can
529529+ /// be used together with the DID to restore the session via [`OAuthClient::restore`].
472530 pub async fn session_info(&self) -> (Did<'_>, CowStr<'_>) {
473531 let data = self.data.read().await;
474532 (data.account_did.clone(), data.session_id.clone())
475533 }
476534535535+ /// Return the resource server (PDS) base URI for this session.
477536 pub async fn endpoint(&self) -> Uri<String> {
478537 self.data.read().await.host_url.clone()
479538 }
480539540540+ /// Return the current DPoP-bound access token for this session.
541541+ ///
542542+ /// The token may be stale if it has expired; use [`OAuthSession::refresh`] or
543543+ /// rely on the automatic refresh performed by `send_with_opts` to obtain a fresh one.
481544 pub async fn access_token(&self) -> AuthorizationToken<'_> {
482545 AuthorizationToken::Dpop(self.data.read().await.token_set.access_token.clone())
483546 }
484547548548+ /// Return the current refresh token for this session, if one is present.
549549+ ///
550550+ /// Not all authorization servers issue refresh tokens. When `None` is returned,
551551+ /// the session cannot be silently renewed and the user must re-authenticate.
485552 pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> {
486553 self.data
487554 .read()
···492559 .map(|t| AuthorizationToken::Dpop(t.clone()))
493560 }
494561562562+ /// Derive an unauthenticated [`OAuthClient`] that shares the same registry and resolver.
563563+ ///
564564+ /// Useful when you need to initiate a new authorization flow from within an existing
565565+ /// session context (e.g., to add a second account) without constructing a fresh client.
495566 pub fn to_client(&self) -> OAuthClient<T, S> {
496567 OAuthClient::from_session(self)
497568 }
···501572 S: ClientAuthStore + Send + Sync + 'static,
502573 T: OAuthResolver + DpopExt + Send + Sync + 'static,
503574{
575575+ /// Revoke the access token at the authorization server and delete the session from the store.
576576+ ///
577577+ /// Revocation is best-effort: if the server does not advertise a revocation endpoint, or if
578578+ /// the revocation call fails, the session is still deleted locally. This prevents a dangling
579579+ /// session record from blocking future logins for the same account.
504580 pub async fn logout(&self) -> Result<()> {
505581 use crate::request::{OAuthMetadata, revoke};
506582 let mut data = self.data.write().await;
···525601 T: OAuthResolver,
526602 S: ClientAuthStore,
527603{
604604+ /// Construct an `OAuthClient` that shares the registry and resolver of an existing session.
605605+ ///
606606+ /// Equivalent to [`OAuthSession::to_client`]; provided on `OAuthClient` for symmetry so
607607+ /// callers can obtain an unauthenticated client without holding a session reference.
528608 pub fn from_session<W>(session: &OAuthSession<T, S, W>) -> Self {
529609 Self {
530610 registry: session.registry.clone(),
···539619 S: ClientAuthStore + Send + Sync + 'static,
540620 T: OAuthResolver + DpopExt + Send + Sync + 'static,
541621{
622622+ /// Explicitly refresh the access token using the stored refresh token.
623623+ ///
624624+ /// On success the new token set is written back into both the in-memory session data and
625625+ /// the backing store. The returned `AuthorizationToken` is the new access token, which
626626+ /// callers can immediately use to retry a failed request.
627627+ ///
628628+ /// The actual token exchange is serialized per `(DID, session_id)` pair via a `Mutex` inside
629629+ /// the registry, so concurrent refresh attempts will not result in duplicate token exchanges.
542630 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))]
543631 pub async fn refresh(&self) -> Result<AuthorizationToken<'_>> {
544632 // Read identifiers without holding the lock across await
+39
crates/jacquard-oauth/src/dpop.rs
···2323 session::DpopDataSource,
2424};
25252626+/// The `typ` header value required in all DPoP proof JWTs, per RFC 9449.
2627pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt";
27282829#[derive(serde::Deserialize)]
···332333333334type Result<T> = core::result::Result<T, DpopError>;
334335336336+/// An HTTP client capable of making DPoP-protected requests to both auth servers and resource servers.
337337+///
338338+/// Implementors must be able to attach a DPoP proof header, handle nonce challenges, and
339339+/// retry transparently on `use_dpop_nonce` errors.
335340#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
336341pub trait DpopClient: HttpClient {
342342+ /// Send a DPoP-protected request to an authorization server (token endpoint, PAR, etc.).
337343 fn dpop_server(
338344 &self,
339345 request: Request<Vec<u8>>,
340346 ) -> impl Future<Output = Result<Response<Vec<u8>>>>;
347347+ /// Send a DPoP-protected request to a resource server (PDS, AppView, etc.).
341348 fn dpop_client(
342349 &self,
343350 request: Request<Vec<u8>>,
344351 ) -> impl Future<Output = Result<Response<Vec<u8>>>>;
352352+ /// Send a DPoP-protected request, inferring the target type from the request context.
345353 fn wrap_request(
346354 &self,
347355 request: Request<Vec<u8>>,
348356 ) -> impl Future<Output = Result<Response<Vec<u8>>>>;
349357}
350358359359+/// Extension trait for any [`HttpClient`] that adds builder methods for constructing
360360+/// DPoP-protected request calls without requiring a full [`DpopClient`] implementation.
351361pub trait DpopExt: HttpClient {
362362+ /// Begin building a DPoP-protected request targeting an authorization server.
352363 fn dpop_server_call<'r, D>(&'r self, data_source: &'r mut D) -> DpopCall<'r, Self, D>
353364 where
354365 Self: Sized,
···357368 DpopCall::server(self, data_source)
358369 }
359370371371+ /// Begin building a DPoP-protected request targeting a resource server.
360372 fn dpop_call<'r, N>(&'r self, data_source: &'r mut N) -> DpopCall<'r, Self, N>
361373 where
362374 Self: Sized,
···366378 }
367379}
368380381381+/// A builder for a single DPoP-protected HTTP request, holding references to the underlying
382382+/// client and the session data source that supplies nonces and the DPoP signing key.
369383pub struct DpopCall<'r, C: HttpClient, D: DpopDataSource> {
384384+ /// The HTTP client that will send the request.
370385 pub client: &'r C,
386386+ /// Whether the request targets an authorization server rather than a resource server.
387387+ ///
388388+ /// This controls which nonce slot is read from and written to, and how `use_dpop_nonce`
389389+ /// errors are detected in the response.
371390 pub is_to_auth_server: bool,
391391+ /// The session data source providing the DPoP key and current nonces.
372392 pub data_source: &'r mut D,
373393}
374394375395impl<'r, C: HttpClient, N: DpopDataSource> DpopCall<'r, C, N> {
396396+ /// Create a call builder targeting an authorization server.
376397 pub fn server(client: &'r C, data_source: &'r mut N) -> Self {
377398 Self {
378399 client,
···381402 }
382403 }
383404405405+ /// Create a call builder targeting a resource server.
384406 pub fn client(client: &'r C, data_source: &'r mut N) -> Self {
385407 Self {
386408 client,
···389411 }
390412 }
391413414414+ /// Send the request with a DPoP proof, retrying once if the server provides a new nonce.
392415 pub async fn send(self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>> {
393416 wrap_request_with_dpop(
394417 self.client,
···399422 .await
400423 }
401424425425+ /// Sends the request with DPoP proof and returns a streaming response.
402426 #[cfg(feature = "streaming")]
403427 pub async fn send_streaming(
404428 self,
···416440 .await
417441 }
418442443443+ /// Sends the request with DPoP proof using bidirectional streaming.
419444 #[cfg(feature = "streaming")]
420445 pub async fn send_bidirectional(
421446 self,
···470495 }
471496}
472497498498+/// Attach a DPoP proof to `request`, send it, and transparently retry once if the server
499499+/// responds with a `use_dpop_nonce` error and a fresh nonce.
500500+///
501501+/// The nonce is read from and written back to `data_source` based on `is_to_auth_server`,
502502+/// keeping the two nonce slots (auth server vs. resource server) independent.
473503pub async fn wrap_request_with_dpop<T, N>(
474504 client: &T,
475505 data_source: &mut N,
···531561 Ok(response)
532562}
533563564564+/// Wraps an HTTP request with a DPoP proof and returns a streaming response.
565565+///
566566+/// Like [`wrap_request_with_dpop`], but returns a [`StreamingResponse`](jacquard_common::xrpc::StreamingResponse)
567567+/// instead of buffering the body. Nonce retry is limited to status/header inspection
568568+/// since the body stream cannot be rewound.
534569#[cfg(feature = "streaming")]
535570pub async fn wrap_request_with_dpop_streaming<T, N>(
536571 client: &T,
···600635 Ok(StreamingResponse::new(parts, body))
601636}
602637638638+/// Wraps an HTTP request with a DPoP proof using bidirectional streaming.
639639+///
640640+/// Similar to [`wrap_request_with_dpop_streaming`] but accepts a [`ByteStream`](jacquard_common::stream::ByteStream)
641641+/// request body for upload streaming scenarios.
603642#[cfg(feature = "streaming")]
604643pub async fn wrap_request_with_dpop_bidirectional<T, N>(
605644 client: &T,
+27-1
crates/jacquard-oauth/src/error.rs
···88#[derive(Debug, thiserror::Error, Diagnostic)]
99#[non_exhaustive]
1010pub enum OAuthError {
1111+ /// An error occurred during identity or metadata resolution.
1112 #[error(transparent)]
1213 #[diagnostic(code(jacquard_oauth::resolver))]
1314 Resolver(#[from] ResolverError),
14151616+ /// An error occurred while making an OAuth HTTP request.
1517 #[error(transparent)]
1618 #[diagnostic(code(jacquard_oauth::request))]
1719 Request(#[from] RequestError),
18202121+ /// An error occurred reading or writing session state.
1922 #[error(transparent)]
2023 #[diagnostic(code(jacquard_oauth::storage))]
2124 Storage(#[from] SessionStoreError),
22252626+ /// An error occurred during DPoP proof generation or validation.
2327 #[error(transparent)]
2428 #[diagnostic(code(jacquard_oauth::dpop))]
2529 Dpop(#[from] crate::dpop::DpopError),
26303131+ /// An error occurred with the client's key set.
2732 #[error(transparent)]
2833 #[diagnostic(code(jacquard_oauth::keyset))]
2934 Keyset(#[from] crate::keyset::Error),
30353636+ /// An ATProto-specific OAuth error (e.g. scope validation, client ID).
3137 #[error(transparent)]
3238 #[diagnostic(code(jacquard_oauth::atproto))]
3339 Atproto(#[from] crate::atproto::Error),
34404141+ /// An error occurred managing or refreshing an OAuth session.
3542 #[error(transparent)]
3643 #[diagnostic(code(jacquard_oauth::session))]
3744 Session(#[from] crate::session::Error),
38454646+ /// A JSON serialization or deserialization error.
3947 #[error(transparent)]
4048 #[diagnostic(code(jacquard_oauth::serde_json))]
4149 SerdeJson(#[from] serde_json::Error),
42505151+ /// A URI parse error.
4352 #[error(transparent)]
4453 #[diagnostic(code(jacquard_oauth::url))]
4554 Url(#[from] jacquard_common::deps::fluent_uri::ParseError),
46555656+ /// A form (URL-encoded) serialization error.
4757 #[error(transparent)]
4858 #[diagnostic(code(jacquard_oauth::form))]
4959 Form(#[from] serde_html_form::ser::Error),
50606161+ /// An error validating an authorization callback.
5162 #[error(transparent)]
5263 #[diagnostic(code(jacquard_oauth::callback))]
5364 Callback(#[from] CallbackError),
···5768#[derive(Debug, thiserror::Error, Diagnostic)]
5869#[non_exhaustive]
5970pub enum CallbackError {
7171+ /// The `state` parameter was absent from the authorization callback.
7272+ ///
7373+ /// State is required to prevent CSRF attacks per RFC 6749 §10.12.
6074 #[error("missing state parameter in callback")]
6175 #[diagnostic(code(jacquard_oauth::callback::missing_state))]
6276 MissingState,
7777+ /// The `iss` (issuer) parameter was absent from the authorization callback.
7878+ ///
7979+ /// RFC 9207 requires `iss` to be present so that clients can reject
8080+ /// mix-up attacks from malicious authorization servers.
6381 #[error("missing `iss` parameter")]
6482 #[diagnostic(code(jacquard_oauth::callback::missing_iss))]
6583 MissingIssuer,
8484+ /// The issuer in the callback did not match the expected authorization server.
6685 #[error("issuer mismatch: expected {expected}, got {got}")]
6786 #[diagnostic(code(jacquard_oauth::callback::issuer_mismatch))]
6868- IssuerMismatch { expected: String, got: String },
8787+ IssuerMismatch {
8888+ /// The issuer that was expected.
8989+ expected: String,
9090+ /// The issuer that was actually present in the callback.
9191+ got: String,
9292+ },
9393+ /// The authorization request timed out before a callback was received.
6994 #[error("timeout")]
7095 #[diagnostic(code(jacquard_oauth::callback::timeout))]
7196 Timeout,
7297}
73989999+/// Convenience alias for `Result<T, OAuthError>`.
74100pub type Result<T> = core::result::Result<T, OAuthError>;
+7
crates/jacquard-oauth/src/jose.rs
···11+/// JWS (JSON Web Signature) header types.
12pub mod jws;
33+/// JWT (JSON Web Token) claims types.
24pub mod jwt;
55+/// Signed JWT creation using ES256 keys.
36pub mod signing;
4758use serde::{Deserialize, Serialize};
691010+/// A JOSE header, covering the supported JWS formats.
1111+///
1212+/// Serialized as an untagged enum so the wire format matches the relevant JOSE spec directly.
713#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
814#[serde(untagged)]
915pub enum Header<'a> {
1616+ /// A JWS compact-serialization header.
1017 #[serde(borrow)]
1118 Jws(jws::Header<'a>),
1219}
+13-1
crates/jacquard-oauth/src/jose/jws.rs
···33use jose_jwk::Jwk;
44use serde::{Deserialize, Serialize};
5566+/// A JWS compact-serialization header, wrapping the registered header fields.
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Header<'a> {
99+ /// The registered header parameters defined by the JWS specification.
810 #[serde(flatten)]
911 #[serde(borrow)]
1012 pub registered: RegisteredHeader<'a>,
···1618 }
1719}
18202121+/// Registered JWS header parameters as defined in RFC 7515 §4.1.
1922#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20232124pub struct RegisteredHeader<'a> {
2525+ /// The cryptographic algorithm used to sign the JWS (e.g., `ES256`).
2226 pub alg: Algorithm,
2727+ /// JWK Set URL: a URI pointing to a resource containing the public key(s) used to sign the JWS.
2328 #[serde(borrow)]
2429 #[serde(skip_serializing_if = "Option::is_none")]
2530 pub jku: Option<CowStr<'a>>,
3131+ /// JSON Web Key: the public key used to verify the JWS, embedded directly in the header.
2632 #[serde(skip_serializing_if = "Option::is_none")]
2733 pub jwk: Option<Jwk>,
3434+ /// Key ID: a hint indicating which key was used to sign the JWS.
2835 #[serde(skip_serializing_if = "Option::is_none")]
2936 pub kid: Option<CowStr<'a>>,
3737+ /// X.509 URL: a URI pointing to a resource for the X.509 certificate used to sign the JWS.
3038 #[serde(skip_serializing_if = "Option::is_none")]
3139 pub x5u: Option<CowStr<'a>>,
4040+ /// X.509 certificate chain: the certificate (and chain) corresponding to the key used to sign the JWS.
3241 #[serde(skip_serializing_if = "Option::is_none")]
3342 pub x5c: Option<CowStr<'a>>,
4343+ /// X.509 certificate SHA-1 thumbprint: base64url-encoded SHA-1 digest of the DER-encoded certificate.
3444 #[serde(skip_serializing_if = "Option::is_none")]
3545 pub x5t: Option<CowStr<'a>>,
4646+ /// X.509 certificate SHA-256 thumbprint: base64url-encoded SHA-256 digest of the DER-encoded certificate.
3647 #[serde(skip_serializing_if = "Option::is_none")]
3748 #[serde(rename = "x5t#S256")]
3849 pub x5ts256: Option<CowStr<'a>>,
3939-5050+ /// Type: declares the media type of the complete JWS, used by applications to disambiguate among JOSe objects.
4051 #[serde(skip_serializing_if = "Option::is_none")]
4152 pub typ: Option<CowStr<'a>>,
5353+ /// Content type: declares the media type of the secured content (the payload).
4254 #[serde(skip_serializing_if = "Option::is_none")]
4355 pub cty: Option<CowStr<'a>>,
4456}
+22
crates/jacquard-oauth/src/jose/jwt.rs
···11use jacquard_common::{CowStr, IntoStatic};
22use serde::{Deserialize, Serialize};
3344+/// Full JWT claims payload, combining registered and public (DPoP-specific) claims.
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
56pub struct Claims<'a> {
77+ /// Standard registered JWT claims (iss, sub, aud, exp, etc.).
68 #[serde(flatten)]
79 pub registered: RegisteredClaims<'a>,
1010+ /// Public claims used in DPoP proofs (htm, htu, ath, nonce).
811 #[serde(flatten)]
912 #[serde(borrow)]
1013 pub public: PublicClaims<'a>,
1114}
12151616+/// Standard registered JWT claims as defined in RFC 7519 §4.1.
1317#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
14181519pub struct RegisteredClaims<'a> {
2020+ /// Issuer: identifies the principal that issued the JWT.
1621 #[serde(borrow)]
1722 #[serde(skip_serializing_if = "Option::is_none")]
1823 pub iss: Option<CowStr<'a>>,
2424+ /// Subject: identifies the principal that is the subject of the JWT.
1925 #[serde(skip_serializing_if = "Option::is_none")]
2026 pub sub: Option<CowStr<'a>>,
2727+ /// Audience: recipients that the JWT is intended for.
2128 #[serde(skip_serializing_if = "Option::is_none")]
2229 pub aud: Option<RegisteredClaimsAud<'a>>,
3030+ /// Expiration time (Unix timestamp): the JWT must not be accepted on or after this time.
2331 #[serde(skip_serializing_if = "Option::is_none")]
2432 pub exp: Option<i64>,
3333+ /// Not before (Unix timestamp): the JWT must not be accepted before this time.
2534 #[serde(skip_serializing_if = "Option::is_none")]
2635 pub nbf: Option<i64>,
3636+ /// Issued at (Unix timestamp): identifies when the JWT was created.
2737 #[serde(skip_serializing_if = "Option::is_none")]
2838 pub iat: Option<i64>,
3939+ /// JWT ID: unique identifier for the token, used to prevent replay attacks.
2940 #[serde(skip_serializing_if = "Option::is_none")]
3041 pub jti: Option<CowStr<'a>>,
3142}
32434444+/// Public claims used in DPoP proof JWTs (RFC 9449).
4545+///
4646+/// These claims bind the DPoP proof to a specific HTTP request, preventing
4747+/// the proof from being replayed against a different endpoint or method.
3348#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
34493550pub struct PublicClaims<'a> {
5151+ /// HTTP method of the request the DPoP proof is bound to (e.g., `"POST"`).
3652 #[serde(borrow)]
3753 #[serde(skip_serializing_if = "Option::is_none")]
3854 pub htm: Option<CowStr<'a>>,
5555+ /// HTTP target URI of the request the DPoP proof is bound to.
3956 #[serde(skip_serializing_if = "Option::is_none")]
4057 pub htu: Option<CowStr<'a>>,
5858+ /// Access token hash: base64url-encoded SHA-256 of the access token, binding the proof to a specific token.
4159 #[serde(skip_serializing_if = "Option::is_none")]
4260 pub ath: Option<CowStr<'a>>,
6161+ /// Server-provided nonce, included to prevent replay attacks when required by the authorization server.
4362 #[serde(skip_serializing_if = "Option::is_none")]
4463 pub nonce: Option<CowStr<'a>>,
4564}
···5372 }
5473}
55747575+/// The `aud` (audience) claim, which may be a single string or a list of strings per RFC 7519.
5676#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
5777#[serde(untagged)]
5878pub enum RegisteredClaimsAud<'a> {
7979+ /// A single audience identifier.
5980 #[serde(borrow)]
6081 Single(CowStr<'a>),
8282+ /// Multiple audience identifiers.
6183 Multiple(Vec<CowStr<'a>>),
6284}
6385
+1
crates/jacquard-oauth/src/jose/signing.rs
···5566use super::{Header, jwt::Claims};
7788+/// Creates a compact-serialized signed JWT using an ES256 (P-256 ECDSA) key.
89pub fn create_signed_jwt(
910 key: SigningKey,
1011 header: Header,
+17
crates/jacquard-oauth/src/keyset.rs
···88use std::collections::HashSet;
99use thiserror::Error;
10101111+/// Errors that can occur when constructing or using a [`Keyset`].
1112#[derive(Error, Debug)]
1213#[non_exhaustive]
1314pub enum Error {
1515+ /// Two keys in the set share the same `kid`, which would make key selection ambiguous.
1416 #[error("duplicate kid: {0}")]
1517 DuplicateKid(String),
1818+ /// A keyset with no keys cannot sign anything.
1619 #[error("keys must not be empty")]
1720 EmptyKeys,
2121+ /// Each key must carry a `kid` so it can be referenced in JWS headers.
1822 #[error("key at index {0} must have a `kid`")]
1923 EmptyKid(usize),
2424+ /// No key in the set matches any of the requested signing algorithms.
2025 #[error("no signing key found for algorithms: {0:?}")]
2126 NotFound(Vec<CowStr<'static>>),
2727+ /// Only secret (private) keys may be used for signing; a public key was provided.
2228 #[error("key for signing must be a secret key")]
2329 PublicKey,
3030+ /// An error from the underlying JWK cryptographic operation.
2431 #[error("crypto error: {0:?}")]
2532 JwkCrypto(crypto::Error),
3333+ /// JSON serialization of a JWT header or claims payload failed.
2634 #[error(transparent)]
2735 SerdeJson(#[from] serde_json::Error),
2836}
29373838+/// Convenience result type for keyset operations.
3039pub type Result<T> = core::result::Result<T, Error>;
31404141+/// A validated collection of JWK secret keys used for signing DPoP proofs and client assertions.
4242+///
4343+/// Key selection follows a preference order defined in [`PREFERRED_SIGNING_ALGORITHMS`](Self::PREFERRED_SIGNING_ALGORITHMS),
4444+/// though currently only P-256 (ES256) keys are supported.
3245#[derive(Clone, Debug, Default, PartialEq, Eq)]
3346pub struct Keyset(Vec<Jwk>);
3447···3649 const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = [
3750 "EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512",
3851 ];
5252+ /// Returns a [`JwkSet`] containing the public halves of all keys in this keyset.
3953 pub fn public_jwks(&self) -> JwkSet {
4054 let mut keys = Vec::with_capacity(self.0.len());
4155 for mut key in self.0.clone() {
···4963 }
5064 JwkSet { keys }
5165 }
6666+ /// Signs a JWT with the best available key that matches one of the requested algorithms.
6767+ ///
6868+ /// Returns [`Error::NotFound`] if no key in the keyset supports any of the given algorithms.
5269 pub fn create_jwt(&self, algs: &[CowStr], claims: Claims) -> Result<CowStr<'static>> {
5370 let Some(jwk) = self.find_key(algs, Class::Signing) else {
5471 return Err(Error::NotFound(algs.to_vec().into_static()));
+15
crates/jacquard-oauth/src/lib.rs
···4747//! See [`atproto`] module for AT Protocol-specific metadata helpers.
48484949#![warn(missing_docs)]
5050+/// AT Protocol-specific OAuth client metadata helpers and builder types.
5051pub mod atproto;
5252+/// Storage trait and in-memory implementation for OAuth client auth state.
5153pub mod authstore;
5454+/// High-level OAuth client for driving the full authorization code flow.
5255pub mod client;
5656+/// DPoP (Demonstrating Proof-of-Possession) key generation and request signing.
5357pub mod dpop;
5858+/// Top-level OAuth error types for the authorization flow.
5459pub mod error;
6060+/// JOSE primitives: JWS headers, JWT claims, and signing utilities.
5561pub mod jose;
6262+/// JWK keyset management for signing keys used in DPoP and client auth.
5663pub mod keyset;
6464+/// Low-level OAuth request helpers: PAR, token exchange, and refresh.
5765pub mod request;
6666+/// OAuth server metadata resolution: authorization server and protected resource discovery.
5867pub mod resolver;
6868+///
5969pub mod scopes;
7070+/// OAuth session types, token storage, and DPoP session state.
6071pub mod session;
7272+/// OAuth protocol types: client metadata, token sets, and server metadata.
6173pub mod types;
7474+/// Miscellaneous cryptographic utilities: key generation, PKCE, and hashing helpers.
6275pub mod utils;
63767777+/// Fallback signing algorithm used when no preferred algorithm is negotiated with the server.
6478pub const FALLBACK_ALG: &str = "ES256";
65798080+/// Loopback server helpers for the local redirect-based OAuth flow.
6681#[cfg(feature = "loopback")]
6782pub mod loopback;
+13
crates/jacquard-oauth/src/loopback.rs
···5959use std::net::SocketAddr;
6060use tokio::sync::mpsc;
61616262+/// Port selection strategy for the loopback OAuth callback server.
6263#[derive(Clone, Debug)]
6364pub enum LoopbackPort {
6565+ /// Bind to a specific port number.
6466 Fixed(u16),
6767+ /// Let the OS assign an available port.
6568 Ephemeral,
6669}
67707171+/// Configuration for the loopback OAuth callback server.
6872#[derive(Clone, Debug)]
6973pub struct LoopbackConfig {
7474+ /// The host address to bind to (e.g., `"127.0.0.1"`).
7075 pub host: String,
7676+ /// Port selection strategy.
7177 pub port: LoopbackPort,
7878+ /// Whether to attempt opening the authorization URL in the user's browser.
7279 pub open_browser: bool,
8080+ /// How long to wait for the callback before timing out, in milliseconds.
7381 pub timeout_ms: u64,
7482}
7583···8492 }
8593}
86949595+/// Attempts to open the given URL in the user's default browser.
9696+///
9797+/// Returns `true` if the browser was opened successfully, `false` otherwise.
8798#[cfg(feature = "browser-open")]
8899pub fn try_open_in_browser(url: &str) -> bool {
89100 webbrowser::open(url).is_ok()
90101}
102102+/// Stub for when the `browser-open` feature is disabled. Always returns `false`.
91103#[cfg(not(feature = "browser-open"))]
92104pub fn try_open_in_browser(_url: &str) -> bool {
93105 false
···114126 )
115127}
116128129129+/// Handle to a running loopback callback server, used to await the OAuth redirect.
117130pub struct CallbackHandle {
118131 #[allow(dead_code)]
119132 server_handle: std::thread::JoinHandle<()>,
+69-2
crates/jacquard-oauth/src/request.rs
···41414242use smol_str::SmolStr;
43434444+/// Convenience alias for a heap-allocated, thread-safe, `'static` error value.
4445pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
45464647/// OAuth request error for token operations and auth flows
···130131 code(jacquard_oauth::request::http_status_body),
131132 help("server returned error JSON; inspect fields like `error`, `error_description`")
132133 )]
133133- HttpStatusWithBody { status: StatusCode, body: Value },
134134+ HttpStatusWithBody {
135135+ /// HTTP status code returned by the server.
136136+ status: StatusCode,
137137+ /// Parsed JSON body containing OAuth error fields such as `error` and `error_description`.
138138+ body: Value,
139139+ },
134140135141 /// Identity resolution error
136142 #[error("identity error")]
···413419 }
414420}
415421422422+/// Convenience `Result` type for OAuth request operations, defaulting to [`RequestError`].
416423pub type Result<T> = core::result::Result<T, RequestError>;
417424425425+/// Represents the different OAuth token-endpoint request types sent by this crate.
418426#[allow(dead_code)]
419427pub enum OAuthRequest<'a> {
428428+ /// Standard authorization-code token exchange.
420429 Token(TokenRequestParameters<'a>),
430430+ /// Refresh-token grant to obtain a fresh access token.
421431 Refresh(RefreshRequestParameters<'a>),
432432+ /// Token revocation request (RFC 7009).
422433 Revocation(RevocationRequestParameters<'a>),
434434+ /// Token introspection request (RFC 7662).
423435 Introspection,
436436+ /// Pushed authorization request (RFC 9126) for pre-registering auth parameters.
424437 PushedAuthorizationRequest(ParParameters<'a>),
425438}
426439427440impl OAuthRequest<'_> {
441441+ /// Return a human-readable name for this request variant, used in error messages.
428442 pub fn name(&self) -> CowStr<'static> {
429443 CowStr::new_static(match self {
430444 Self::Token(_) => "token",
···434448 Self::PushedAuthorizationRequest(_) => "pushed_authorization_request",
435449 })
436450 }
451451+ /// Returns the HTTP status code that a successful response to this request should carry.
437452 pub fn expected_status(&self) -> StatusCode {
438453 match self {
439454 Self::Token(_) | Self::Refresh(_) => StatusCode::OK,
···445460 }
446461}
447462463463+/// The serialized body of an OAuth token-endpoint request.
448464#[derive(Debug, Serialize)]
449465pub struct RequestPayload<'a, T>
450466where
451467 T: Serialize,
452468{
469469+ /// The OAuth `client_id` advertised in the client metadata document.
453470 client_id: CowStr<'a>,
471471+ /// The assertion type URI; set to `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`
472472+ /// when using `private_key_jwt` client authentication.
454473 #[serde(skip_serializing_if = "Option::is_none")]
455474 client_assertion_type: Option<CowStr<'a>>,
475475+ /// A JWT signed with the client's private key, proving client identity to the server.
456476 #[serde(skip_serializing_if = "Option::is_none")]
457477 client_assertion: Option<CowStr<'a>>,
478478+ /// The grant-specific parameters (token request, refresh, PAR, etc.) flattened into the body.
458479 #[serde(flatten)]
459480 parameters: T,
460481}
461482483483+/// Bundled OAuth metadata needed to perform token-endpoint operations.
484484+///
485485+/// Aggregates the server's authorization server metadata, the client's own registered metadata,
486486+/// and the optional signing keyset into a single value that is passed to helper functions such
487487+/// as [`par`], [`exchange_code`], [`refresh`], and [`revoke`].
462488#[derive(Debug, Clone)]
463489pub struct OAuthMetadata {
490490+ /// Metadata fetched from the authorization server's `/.well-known/oauth-authorization-server` document.
464491 pub server_metadata: OAuthAuthorizationServerMetadata<'static>,
492492+ /// This client's registered metadata, derived from [`crate::atproto::AtprotoClientMetadata`].
465493 pub client_metadata: OAuthClientMetadata<'static>,
494494+ /// Optional signing keyset; required for `private_key_jwt` client authentication.
466495 pub keyset: Option<Keyset>,
467496}
468497469498impl OAuthMetadata {
499499+ /// Fetch server metadata and assemble an `OAuthMetadata` from an active session context.
500500+ ///
501501+ /// Contacts the authorization server recorded in `session_data` to retrieve its current
502502+ /// metadata, then combines it with the client configuration. This is the preferred way to
503503+ /// build an `OAuthMetadata` during token refresh or revocation.
470504 pub async fn new<'r, T: HttpClient + OAuthResolver + Send + Sync>(
471505 client: &T,
472506 ClientData { keyset, config }: &ClientData<'r>,
···484518 }
485519}
486520521521+/// Perform a Pushed Authorization Request (PAR) and return the resulting state for the auth flow.
522522+///
523523+/// Generates a PKCE code challenge, a fresh DPoP key, and a random `state` token, then POSTs
524524+/// them to the authorization server's PAR endpoint. The returned [`AuthRequestData`] must be
525525+/// persisted (e.g., in the auth store) so it can be retrieved and verified during
526526+/// [`crate::client::OAuthClient::callback`].
487527#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all, fields(login_hint = login_hint.as_ref().map(|h| h.as_ref()))))]
488528pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>(
489529 client: &T,
···562602 }
563603}
564604605605+/// Exchange a refresh token for a fresh token set and update the session data in place.
565606#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all, fields(did = %session_data.account_did)))]
566607pub async fn refresh<'r, T>(
567608 client: &T,
···621662 Ok(session_data)
622663}
623664665665+/// Exchange an authorization code for a token set and return a fully-verified [`TokenSet`].
666666+///
667667+/// Per the AT Protocol OAuth spec, the `sub` claim in the token response **must** be verified
668668+/// against the expected authorization server issuer before the token can be trusted. This
669669+/// function performs that verification as part of the exchange, so callers receive a token
670670+/// set that is safe to persist.
624671#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))]
625672pub async fn exchange_code<'r, T, D>(
626673 client: &T,
···680727 })
681728}
682729730730+/// Send a token revocation request (RFC 7009) to the authorization server.
731731+///
732732+/// This function is called by [`crate::client::OAuthSession::logout`] when a revocation endpoint is advertised
733733+/// by the server. The caller is responsible for deleting the session from local storage regardless
734734+/// of whether revocation succeeds.
683735#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))]
684736pub async fn revoke<'r, T, D>(
685737 client: &T,
···703755 Ok(())
704756}
705757758758+/// Low-level function for sending an OAuth token-endpoint request and deserializing the response.
759759+///
760760+/// Selects the correct server endpoint for `request`, builds the form-encoded body with
761761+/// client authentication, performs the DPoP-wrapped HTTP POST, and deserializes the response
762762+/// body into `O`. The type parameter `O` is inferred from the call site; use `()` for requests
763763+/// where the response body is empty (e.g., revocation).
706764pub async fn oauth_request<'de: 'r, 'r, O, T, D>(
707765 client: &T,
708766 data_source: &'r mut D,
···784842 })?)
785843}
786844845845+/// Client identity fields appended to every token-endpoint request body.
846846+///
847847+/// Encapsulates the result of choosing a client authentication method (`none` vs.
848848+/// `private_key_jwt`). The `build_auth` helper selects the appropriate variant based
849849+/// on server capabilities and client configuration.
787850#[derive(Debug, Clone, Default)]
788851pub struct ClientAuth<'a> {
852852+ /// The OAuth `client_id` for this client.
789853 client_id: CowStr<'a>,
790790- assertion_type: Option<CowStr<'a>>, // either none or `CLIENT_ASSERTION_TYPE_JWT_BEARER`
854854+ /// Either absent (for `none` auth) or `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
855855+ assertion_type: Option<CowStr<'a>>,
856856+ /// A signed JWT proving client identity; present only for `private_key_jwt` auth.
791857 assertion: Option<CowStr<'a>>,
792858}
793859794860impl<'s> ClientAuth<'s> {
861861+ /// Construct a `ClientAuth` with only a `client_id` and no assertion (the `none` method).
795862 pub fn new_id(client_id: CowStr<'s>) -> Self {
796863 Self {
797864 client_id,
+53-4
crates/jacquard-oauth/src/resolver.rs
···1414use jacquard_identity::resolver::{IdentityError, IdentityResolver};
1515use smol_str::SmolStr;
16161717+/// Convenience alias for a heap-allocated, thread-safe, `'static` error value.
1718pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
18191920/// OAuth resolver error for identity and metadata resolution
···613614 Ok(as_metadata)
614615}
615616617617+/// Resolver trait for the AT Protocol OAuth flow.
618618+///
619619+/// `OAuthResolver` extends [`IdentityResolver`] and [`HttpClient`] with the methods needed to
620620+/// drive the full OAuth flow: resolving an AT identifier (handle or DID) to the authorization
621621+/// server that protects its PDS, fetching server metadata, and verifying that a token's `sub`
622622+/// claim is authorized by the expected issuer.
623623+///
624624+/// A default implementation based on [`jacquard_identity::JacquardResolver`] is provided.
625625+/// Custom implementations are possible for testing or for environments that require
626626+/// non-standard identity resolution (e.g., federated or offline setups).
616627#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
617628pub trait OAuthResolver: IdentityResolver + HttpClient {
629629+ /// Verify that the authorization server in `server_metadata` is the correct issuer for `sub`.
618630 #[cfg(not(target_arch = "wasm32"))]
619631 fn verify_issuer(
620632 &self,
···627639 verify_issuer_impl(self, server_metadata, sub)
628640 }
629641642642+ /// Verify that the authorization server in `server_metadata` is the correct issuer for `sub`.
630643 #[cfg(target_arch = "wasm32")]
631644 fn verify_issuer(
632645 &self,
···636649 verify_issuer_impl(self, server_metadata, sub)
637650 }
638651652652+ /// Resolve `input` (a handle, DID, PDS URL, or entryway URL) to OAuth metadata.
653653+ ///
654654+ /// When `input` starts with `https://`, it is treated as a service URL and resolved
655655+ /// directly via [`OAuthResolver::resolve_from_service`]. Otherwise it is treated as an
656656+ /// AT identifier and resolved via [`OAuthResolver::resolve_from_identity`]. Returns the
657657+ /// authorization server metadata and, when `input` was an identity, the resolved DID document.
639658 #[cfg(not(target_arch = "wasm32"))]
640659 fn resolve_oauth(
641660 &self,
···652671 resolve_oauth_impl(self, input)
653672 }
654673674674+ /// Resolve `input` (a handle, DID, PDS URL, or entryway URL) to OAuth metadata.
675675+ ///
676676+ /// When `input` starts with `https://`, it is treated as a service URL and resolved
677677+ /// directly via [`OAuthResolver::resolve_from_service`]. Otherwise it is treated as an
678678+ /// AT identifier and resolved via [`OAuthResolver::resolve_from_identity`]. Returns the
679679+ /// authorization server metadata and, when `input` was an identity, the resolved DID document.
655680 #[cfg(target_arch = "wasm32")]
656681 fn resolve_oauth(
657682 &self,
···665690 resolve_oauth_impl(self, input)
666691 }
667692693693+ /// Resolve a service URL (PDS or entryway) to its authorization server metadata.
694694+ ///
695695+ /// First attempts to fetch the PDS's protected resource metadata; if that fails, falls back
696696+ /// to treating the URL as an entryway and fetching authorization server metadata directly.
668697 #[cfg(not(target_arch = "wasm32"))]
669698 fn resolve_from_service(
670699 &self,
···676705 resolve_from_service_impl(self, input)
677706 }
678707708708+ /// Resolve a service URL to its authorization server metadata.
709709+ ///
710710+ /// First attempts to fetch the PDS's protected resource metadata; if that fails, falls back
711711+ /// to treating the URL as an entryway and fetching authorization server metadata directly.
679712 #[cfg(target_arch = "wasm32")]
680713 fn resolve_from_service(
681714 &self,
···684717 resolve_from_service_impl(self, input)
685718 }
686719720720+ /// Resolve an AT identifier (handle or DID) to its authorization server metadata and DID document.
687721 #[cfg(not(target_arch = "wasm32"))]
688722 fn resolve_from_identity(
689723 &self,
···700734 resolve_from_identity_impl(self, input)
701735 }
702736737737+ /// Resolve an AT identifier to its authorization server metadata and DID document.
703738 #[cfg(target_arch = "wasm32")]
704739 fn resolve_from_identity(
705740 &self,
···713748 resolve_from_identity_impl(self, input)
714749 }
715750751751+ /// Fetch and validate the authorization server metadata for the given issuer URL.
752752+ ///
753753+ /// Retrieves the `/.well-known/oauth-authorization-server` document and confirms that
754754+ /// the `issuer` field in the response matches the requested URL, as required by RFC 8414 §3.3.
716755 #[cfg(not(target_arch = "wasm32"))]
717756 fn get_authorization_server_metadata(
718757 &self,
···724763 get_authorization_server_metadata_impl(self, issuer)
725764 }
726765766766+ /// Fetch and validate the authorization server metadata for the given issuer URL.
767767+ ///
768768+ /// Retrieves the `/.well-known/oauth-authorization-server` document and confirms that
769769+ /// the `issuer` field in the response matches the requested URL, as required by RFC 8414 §3.3.
727770 #[cfg(target_arch = "wasm32")]
728771 fn get_authorization_server_metadata(
729772 &self,
···732775 get_authorization_server_metadata_impl(self, issuer)
733776 }
734777778778+ /// Resolve a PDS base URL to its authorization server metadata.
735779 #[cfg(not(target_arch = "wasm32"))]
736780 fn get_resource_server_metadata(
737781 &self,
···743787 get_resource_server_metadata_impl(self, pds)
744788 }
745789790790+ /// Resolve a PDS base URL to its authorization server metadata.
746791 #[cfg(target_arch = "wasm32")]
747792 fn get_resource_server_metadata(
748793 &self,
···752797 }
753798}
754799800800+/// Fetch and validate the `/.well-known/oauth-authorization-server` document for `server`.
801801+///
802802+/// Per RFC 8414 §3.3 the `issuer` field in the response must equal the `server` URL exactly;
803803+/// this prevents a compromised server from claiming to be a different issuer.
755804pub async fn resolve_authorization_server<T: HttpClient + ?Sized>(
756805 client: &T,
757806 server: &CowStr<'_>,
···772821 if res.status() == StatusCode::OK {
773822 let metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body())?;
774823 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3
775775- // Accept semantically equivalent issuer (normalize to the requested URL form)
776824 if metadata.issuer == server.as_str() {
777777- // if equivalent, keep the canonical form
778825 Ok(metadata.into_static())
779826 } else {
780827 Err(ResolverError::authorization_server_metadata(
···786833 }
787834}
788835836836+/// Fetch the `/.well-known/oauth-protected-resource` document for `server`.
837837+///
838838+/// The `resource` field in the response must equal the requested `server` URL, ensuring
839839+/// that the metadata belongs to the PDS we queried and not a different resource.
789840pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>(
790841 client: &T,
791842 server: &CowStr<'_>,
···806857 if res.status() == StatusCode::OK {
807858 let metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body())?;
808859 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3
809809- // Accept semantically equivalent resource URL (normalize to the requested URL form)
810860 if metadata.resource == server.as_str() {
811811- // if equivalent, keep the canonical form
812861 Ok(metadata.into_static())
813862 } else {
814863 Err(ResolverError::authorization_server_metadata(
+3-1
crates/jacquard-oauth/src/scopes.rs
···11//! AT Protocol OAuth scopes
22-//! Derived from <https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs>
22+//!
33+//! Derived from <https://tangled.org/smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs>
34//!
45//! This module provides comprehensive support for AT Protocol OAuth scopes,
56//! including parsing, serialization, normalization, and permission checking.
···10351036 InvalidAction(String),
10361037 /// Invalid MIME type
10371038 InvalidMimeType(String),
10391039+ /// An AT Protocol string type (DID, NSID, etc.) failed validation during scope parsing.
10381040 ParseError(#[from] AtStrError),
10391041}
10401042
+108-19
crates/jacquard-oauth/src/session.rs
···2626use smol_str::{SmolStr, format_smolstr};
2727use tokio::sync::Mutex;
28282929+/// Provides DPoP key material and per-server nonces to the DPoP proof-building machinery.
3030+///
3131+/// This trait abstracts over two different holders of DPoP state: [`DpopReqData`] (used
3232+/// during the initial authorization request, where only an authserver nonce is tracked) and
3333+/// [`DpopClientData`] (used in active sessions, where both authserver and host nonces are
3434+/// maintained). Implementors must store nonces durably so that the next request to the same
3535+/// server includes the most recently observed nonce.
2936pub trait DpopDataSource {
3737+ /// Return the private JWK used to sign DPoP proofs.
3038 fn key(&self) -> &Key;
3939+ /// Return the most recently observed nonce from the authorization server, if any.
3140 fn authserver_nonce(&self) -> Option<CowStr<'_>>;
4141+ /// Persist a new nonce received from the authorization server.
3242 fn set_authserver_nonce(&mut self, nonce: CowStr<'_>);
4343+ /// Return the most recently observed nonce from the resource server (PDS), if any.
3344 fn host_nonce(&self) -> Option<CowStr<'_>>;
4545+ /// Persist a new nonce received from the resource server (PDS).
3446 fn set_host_nonce(&mut self, nonce: CowStr<'_>);
3547}
36483749/// Persisted information about an OAuth session. Used to resume an active session.
3850#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
3951pub struct ClientSessionData<'s> {
4040- // 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.
5252+ /// DID of the authenticated account; serves as the primary key for session storage
5353+ /// because only one active session per account is assumed.
4154 #[serde(borrow)]
4255 pub account_did: Did<'s>,
43564444- // 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.
5757+ /// Opaque identifier that distinguishes this session from other sessions for the same account.
5858+ ///
5959+ /// Reuses the random `state` token generated during the PAR flow.
4560 pub session_id: CowStr<'s>,
46614747- // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info.
6262+ /// Base URL of the resource server (PDS): scheme, host, and port only
4863 pub host_url: Uri<String>,
49645050- // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info.
6565+ /// Base URL of the authorization server (PDS or entryway): scheme, host, and port only
5166 pub authserver_url: CowStr<'s>,
52675353- // Full token endpoint
6868+ /// Full URL of the authorization server's token endpoint.
5469 pub authserver_token_endpoint: CowStr<'s>,
55705656- // Full revocation endpoint, if it exists
7171+ /// Full URL of the authorization server's revocation endpoint, if advertised.
5772 #[serde(skip_serializing_if = "std::option::Option::is_none")]
5873 pub authserver_revocation_endpoint: Option<CowStr<'s>>,
59746060- // The set of scopes approved for this session (returned in the initial token request)
7575+ /// The set of OAuth scopes approved for this session, as returned in the initial token response.
6176 pub scopes: Vec<Scope<'s>>,
62777878+ /// DPoP key and nonce state for ongoing requests in this session.
6379 #[serde(flatten)]
6480 pub dpop_data: DpopClientData<'s>,
65818282+ /// Current token set (access token, refresh token, expiry, etc.).
6683 #[serde(flatten)]
6784 pub token_set: TokenSet<'s>,
6885}
···88105}
8910690107impl ClientSessionData<'_> {
108108+ /// Update this session's token set and, if the new token set includes scopes, replace the scope list.
109109+ ///
110110+ /// Called after a successful token refresh so that any scope changes returned by the server
111111+ /// are reflected in the persisted session without requiring a full re-authentication.
91112 pub fn update_with_tokens(&mut self, token_set: TokenSet<'_>) {
92113 if let Some(Ok(scopes)) = token_set
93114 .scope
···100121 }
101122}
102123124124+/// DPoP state for an active OAuth session, persisted alongside the token set.
125125+///
126126+/// Both nonces must be written back to the store after each request so that the next
127127+/// request to the same server includes the correct replay-protection nonce.
103128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104129pub struct DpopClientData<'s> {
130130+ /// The private JWK bound to this session; used to sign all DPoP proofs.
105131 pub dpop_key: Key,
106106- // Current auth server DPoP nonce
132132+ /// Most recently observed DPoP nonce from the authorization server.
107133 #[serde(borrow)]
108134 pub dpop_authserver_nonce: CowStr<'s>,
109109- // Current host ("resource server", eg PDS) DPoP nonce
135135+ /// Most recently observed DPoP nonce from the resource server (PDS).
110136 pub dpop_host_nonce: CowStr<'s>,
111137}
112138···143169 }
144170}
145171172172+/// Transient state created during the PAR flow and consumed by the callback handler.
173173+///
174174+/// This struct is persisted to the auth store between [`crate::request::par`] and
175175+/// [`crate::client::OAuthClient::callback`] so that the callback can verify the
176176+/// `state`, reconstruct the token exchange, and create a full [`ClientSessionData`].
146177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
147178pub struct AuthRequestData<'s> {
148148- // The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information.
179179+ /// Random identifier generated for this authorization request; used as the primary key
180180+ /// for storing and looking up this record during the callback.
149181 #[serde(borrow)]
150182 pub state: CowStr<'s>,
151183152152- // URL of the auth server (eg, PDS or entryway)
184184+ /// Base URL of the authorization server that was selected for this flow.
153185 pub authserver_url: CowStr<'s>,
154186155155- // If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response.
187187+ /// If the flow was initiated with a DID or handle, the resolved DID is stored here
188188+ /// so it can be compared against the `sub` in the token response.
156189 #[serde(skip_serializing_if = "std::option::Option::is_none")]
157190 pub account_did: Option<Did<'s>>,
158191159159- // OAuth scope strings
192192+ /// OAuth scopes requested for this authorization.
160193 pub scopes: Vec<Scope<'s>>,
161194162162- // unique token in URI format, which will be used by the client in the auth flow redirect
195195+ /// The PAR `request_uri` returned by the authorization server; included in the redirect URL.
163196 pub request_uri: CowStr<'s>,
164197165165- // Full token endpoint URL
198198+ /// Full URL of the authorization server's token endpoint.
166199 pub authserver_token_endpoint: CowStr<'s>,
167200168168- // Full revocation endpoint, if it exists
201201+ /// Full URL of the authorization server's revocation endpoint, if advertised.
169202 #[serde(skip_serializing_if = "std::option::Option::is_none")]
170203 pub authserver_revocation_endpoint: Option<CowStr<'s>>,
171204172172- // The secret token/nonce which a code challenge was generated from
205205+ /// The PKCE code verifier whose SHA-256 hash was sent as the code challenge; required
206206+ /// at the token exchange step to prove the initiator of the auth request.
173207 pub pkce_verifier: CowStr<'s>,
174208209209+ /// DPoP key and any authserver nonce observed during the PAR request.
175210 #[serde(flatten)]
176211 pub dpop_data: DpopReqData<'s>,
177212}
···195230 }
196231}
197232233233+/// DPoP state for an in-progress authorization request (PAR through code exchange).
234234+///
235235+/// Unlike [`DpopClientData`], this struct only tracks the authserver nonce—no resource-server
236236+/// nonce is needed until a full session is established.
198237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199238pub struct DpopReqData<'s> {
200200- // The secret cryptographic key generated by the client for this specific OAuth session
239239+ /// The private JWK generated fresh for this authorization request and session.
201240 pub dpop_key: Key,
202202- // Server-provided DPoP nonce from auth request (PAR)
241241+ /// DPoP nonce received from the authorization server during the PAR exchange, if any.
203242 #[serde(borrow)]
204243 pub dpop_authserver_nonce: Option<CowStr<'s>>,
205244}
···233272 fn set_host_nonce(&mut self, _nonce: CowStr<'_>) {}
234273}
235274275275+/// Static configuration for an OAuth client: the signing keyset and registered client metadata.
276276+///
277277+/// `ClientData` is constructed once at startup and shared (via `Arc`) across all sessions
278278+/// managed by the same [`crate::client::OAuthClient`].
236279#[derive(Clone, Debug)]
237280pub struct ClientData<'s> {
281281+ /// Optional private key set used for `private_key_jwt` client authentication.
282282+ /// When `None`, the `none` authentication method is used instead.
238283 pub keyset: Option<Keyset>,
284284+ /// AT Protocol-specific client registration metadata (redirect URIs, scopes, etc.).
239285 pub config: AtprotoClientMetadata<'s>,
240286}
241287···250296}
251297252298impl<'s> ClientData<'s> {
299299+ /// Create `ClientData` with an optional signing keyset and the given client metadata.
253300 pub fn new(keyset: Option<Keyset>, config: AtprotoClientMetadata<'s>) -> Self {
254301 Self { keyset, config }
255302 }
256303304304+ /// Create `ClientData` without a signing keyset, relying on the `none` auth method.
305305+ ///
306306+ /// Suitable for public clients (e.g., single-page applications or native apps) that
307307+ /// cannot securely store a private key.
257308 pub fn new_public(config: AtprotoClientMetadata<'s>) -> Self {
258309 Self {
259310 keyset: None,
···262313 }
263314}
264315316316+/// A bundle of client configuration and an active session, used for operations that need both.
317317+///
318318+/// `ClientSession` is a convenience type that pairs a [`ClientData`] with a
319319+/// [`ClientSessionData`] so that methods like `metadata` can access both without requiring
320320+/// callers to pass them separately.
265321pub struct ClientSession<'s> {
322322+ /// Optional signing keyset, forwarded from [`ClientData`].
266323 pub keyset: Option<Keyset>,
324324+ /// Client registration metadata, forwarded from [`ClientData`].
267325 pub config: AtprotoClientMetadata<'s>,
326326+ /// The session state for the authenticated account.
268327 pub session_data: ClientSessionData<'s>,
269328}
270329271330impl<'s> ClientSession<'s> {
331331+ /// Construct a `ClientSession` from a [`ClientData`] and an active session.
272332 pub fn new(
273333 ClientData { keyset, config }: ClientData<'s>,
274334 session_data: ClientSessionData<'s>,
···280340 }
281341 }
282342343343+ /// Fetch and assemble an [`OAuthMetadata`] for the authorization server of this session.
283344 pub async fn metadata<T: HttpClient + OAuthResolver + Send + Sync>(
284345 &self,
285346 client: &T,
···297358 }
298359}
299360361361+/// Errors that can occur during OAuth session management.
300362#[derive(thiserror::Error, Debug, miette::Diagnostic)]
301363#[non_exhaustive]
302364pub enum Error {
365365+ /// A token-endpoint or metadata operation failed.
303366 #[error(transparent)]
304367 #[diagnostic(code(jacquard_oauth::session::request))]
305368 ServerAgent(#[from] crate::request::RequestError),
369369+ /// The backing session store returned an error.
306370 #[error(transparent)]
307371 #[diagnostic(code(jacquard_oauth::session::storage))]
308372 Store(#[from] SessionStoreError),
373373+ /// The requested session does not exist in the store.
309374 #[error("session does not exist")]
310375 #[diagnostic(code(jacquard_oauth::session::not_found))]
311376 SessionNotFound,
377377+ /// Token refresh failed with a permanent error (e.g., `invalid_grant`); the session
378378+ /// has already been removed from the store and the user must re-authenticate.
312379 #[error("session refresh failed permanently")]
313380 #[diagnostic(
314381 code(jacquard_oauth::session::refresh_failed),
···330397 }
331398}
332399400400+/// Central coordinator for OAuth session storage and token refresh.
401401+///
402402+/// `SessionRegistry` wraps the [`ClientAuthStore`] and provides serialized token refresh:
403403+/// concurrent refresh attempts for the same `(DID, session_id)` pair are coalesced behind
404404+/// a per-key `Mutex` stored in `pending`, so only one refresh request is issued to the
405405+/// authorization server even when many concurrent requests detect an expired token.
333406pub struct SessionRegistry<T, S>
334407where
335408 T: OAuthResolver,
336409 S: ClientAuthStore,
337410{
411411+ /// Backing store for persisting session data across process restarts.
338412 pub store: Arc<S>,
413413+ /// Shared resolver used to fetch authorization server metadata during refresh.
339414 pub client: Arc<T>,
415415+ /// Static client configuration (keyset and registration metadata).
340416 pub client_data: ClientData<'static>,
417417+ /// Per-`(DID, session_id)` mutex that serializes concurrent refresh attempts.
341418 pending: DashMap<SmolStr, Arc<Mutex<()>>>,
342419}
343420···346423 S: ClientAuthStore,
347424 T: OAuthResolver,
348425{
426426+ /// Create a new registry, taking ownership of the store.
349427 pub fn new(store: S, client: Arc<T>, client_data: ClientData<'static>) -> Self {
350428 let store = Arc::new(store);
351429 Self {
···356434 }
357435 }
358436437437+ /// Create a new registry from an already-`Arc`-wrapped store.
438438+ ///
439439+ /// Use this variant when the store needs to be accessed from outside the registry,
440440+ /// for example to expose session listing or administration functionality.
359441 pub fn new_shared(store: Arc<S>, client: Arc<T>, client_data: ClientData<'static>) -> Self {
360442 Self {
361443 store,
···419501 Err(e) => Err(Error::ServerAgent(e)),
420502 }
421503 }
504504+ /// Retrieve a session from the store, optionally refreshing it first.
505505+ ///
506506+ /// When `refresh` is `true`, proactively
507507+ /// renews the token if it is within 60 seconds of expiry. When `false`, returns the session
508508+ /// data as-is without contacting the authorization server.
422509 pub async fn get(
423510 &self,
424511 did: &Did<'_>,
···435522 .ok_or(Error::SessionNotFound)
436523 }
437524 }
525525+ /// Persist an updated session to the backing store.
438526 pub async fn set(&self, value: ClientSessionData<'_>) -> Result<(), Error> {
439527 self.store.upsert_session(value).await?;
440528 Ok(())
441529 }
530530+ /// Delete a session from the backing store.
442531 pub async fn del(&self, did: &Did<'_>, session_id: &str) -> Result<(), Error> {
443532 self.store.delete_session(did, session_id).await?;
444533 Ok(())
+22
crates/jacquard-oauth/src/types.rs
···1616use jacquard_common::deps::fluent_uri::Uri;
1717use serde::Deserialize;
18181919+/// The `prompt` parameter for an OAuth authorization request.
2020+///
2121+/// Controls whether the authorization server prompts the user for
2222+/// re-authentication or re-consent, as defined in OpenID Connect Core §3.1.2.1.
1923#[derive(Debug, Deserialize, Clone, Copy)]
2024pub enum AuthorizeOptionPrompt {
2525+ /// Prompt the user to re-authenticate.
2126 Login,
2727+ /// Do not display any authentication or consent UI; fail if interaction is required.
2228 None,
2929+ /// Prompt the user for explicit consent before issuing tokens.
2330 Consent,
3131+ /// Prompt the user to select an account when multiple sessions are active.
2432 SelectAccount,
2533}
2634···3543 }
3644}
37454646+/// Options for initiating an OAuth authorization request.
3847#[derive(Debug)]
3948pub struct AuthorizeOptions<'s> {
4949+ /// Override the redirect URI registered in the client metadata.
4050 pub redirect_uri: Option<Uri<String>>,
5151+ /// Scopes to request. Defaults to an empty list (server-defined defaults apply).
4152 pub scopes: Vec<Scope<'s>>,
5353+ /// Optional prompt hint for the authorization server's UI.
4254 pub prompt: Option<AuthorizeOptionPrompt>,
5555+ /// Opaque client-provided state value, echoed back in the callback for CSRF protection.
4356 pub state: Option<CowStr<'s>>,
4457}
4558···5568}
56695770impl<'s> AuthorizeOptions<'s> {
7171+ /// Set the `prompt` parameter sent to the authorization server.
5872 pub fn with_prompt(mut self, prompt: AuthorizeOptionPrompt) -> Self {
5973 self.prompt = Some(prompt);
6074 self
6175 }
62767777+ /// Set a CSRF-protection `state` value to be echoed in the callback.
6378 pub fn with_state(mut self, state: CowStr<'s>) -> Self {
6479 self.state = Some(state);
6580 self
6681 }
67828383+ /// Override the redirect URI for this specific authorization request.
6884 pub fn with_redirect_uri(mut self, redirect_uri: Uri<String>) -> Self {
6985 self.redirect_uri = Some(redirect_uri);
7086 self
7187 }
72888989+ /// Set the OAuth scopes to request.
7390 pub fn with_scopes(mut self, scopes: Vec<Scope<'s>>) -> Self {
7491 self.scopes = scopes;
7592 self
7693 }
7794}
78959696+/// Query parameters delivered to the OAuth redirect URI after user authorization.
7997#[derive(Debug, Deserialize)]
8098pub struct CallbackParams<'s> {
9999+ /// The authorization code issued by the authorization server.
81100 #[serde(borrow)]
82101 pub code: CowStr<'s>,
102102+ /// The `state` value originally sent in the authorization request, used to
103103+ /// verify the response belongs to this session.
83104 pub state: Option<CowStr<'s>>,
105105+ /// The `iss` (issuer) parameter, required by RFC 9207 to prevent mix-up attacks.
84106 pub iss: Option<CowStr<'s>>,
85107}
86108
···33use serde::{Deserialize, Serialize};
44use smol_str::SmolStr;
5566+/// OAuth 2.1 client metadata, used in the ATProto client ID metadata document.
77+///
88+/// In ATProto's OAuth profile, clients are identified by a URL that serves this
99+/// metadata document. Fields follow RFC 7591 (Dynamic Client Registration),
1010+/// RFC 9449 (DPoP), and OpenID Connect Registration.
1111+///
1212+/// <https://atproto.com/specs/oauth>
613#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
714pub struct OAuthClientMetadata<'c> {
1515+ /// The client identifier, typically a URL pointing to this metadata document.
816 pub client_id: CowStr<'c>,
1717+ /// URL of the client's home page, used for display purposes.
918 #[serde(skip_serializing_if = "Option::is_none")]
1019 pub client_uri: Option<CowStr<'c>>,
2020+ /// List of redirect URIs the authorization server may send callbacks to.
1121 pub redirect_uris: Vec<CowStr<'c>>,
2222+ /// Space-separated list of scopes the client is allowed to request.
1223 #[serde(skip_serializing_if = "Option::is_none")]
1324 #[serde(borrow)]
1425 pub scope: Option<CowStr<'c>>,
2626+ /// Application type (`web` or `native`), used to enforce redirect URI constraints.
1527 #[serde(skip_serializing_if = "Option::is_none")]
1628 pub application_type: Option<CowStr<'c>>,
2929+ /// OAuth 2.0 grant types the client will use.
1730 #[serde(skip_serializing_if = "Option::is_none")]
1831 pub grant_types: Option<Vec<CowStr<'c>>>,
3232+ /// Authentication method the client uses at the token endpoint.
1933 #[serde(skip_serializing_if = "Option::is_none")]
2034 pub token_endpoint_auth_method: Option<CowStr<'c>>,
3535+ /// Response types the client will use in authorization requests.
2136 pub response_types: Vec<CowStr<'c>>,
2222- // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2
3737+ /// If `true`, the client requires DPoP-bound access tokens (RFC 9449 §5.2).
3838+ ///
3939+ /// <https://datatracker.ietf.org/doc/html/rfc9449#section-5.2>
2340 #[serde(skip_serializing_if = "Option::is_none")]
2441 pub dpop_bound_access_tokens: Option<bool>,
2525- // https://datatracker.ietf.org/doc/html/rfc7591#section-2
4242+ /// URL of the client's JWK Set document for verifying signed requests (RFC 7591 §2).
4343+ ///
4444+ /// <https://datatracker.ietf.org/doc/html/rfc7591#section-2>
2645 #[serde(skip_serializing_if = "Option::is_none")]
2746 pub jwks_uri: Option<CowStr<'c>>,
4747+ /// Inline JWK Set for verifying signed requests, alternative to `jwks_uri`.
2848 #[serde(skip_serializing_if = "Option::is_none")]
2949 pub jwks: Option<JwkSet>,
3030- // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
5050+ /// JWS algorithm the client uses to sign token endpoint authentication assertions.
5151+ ///
5252+ /// <https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata>
3153 #[serde(skip_serializing_if = "Option::is_none")]
3254 pub token_endpoint_auth_signing_alg: Option<CowStr<'c>>,
5555+ /// Human-readable name of the client, shown to users during authorization.
3356 #[serde(skip_serializing_if = "Option::is_none")]
3457 pub client_name: Option<SmolStr>,
5858+ /// URL of the client's logo image.
3559 #[serde(skip_serializing_if = "Option::is_none")]
3660 pub logo_uri: Option<CowStr<'c>>,
6161+ /// URL of the client's terms of service.
3762 #[serde(skip_serializing_if = "Option::is_none")]
3863 pub tos_uri: Option<CowStr<'c>>,
6464+ /// URL of the client's privacy policy.
3965 #[serde(skip_serializing_if = "Option::is_none")]
4066 pub privacy_policy_uri: Option<CowStr<'c>>,
4167}
+65-10
crates/jacquard-oauth/src/types/metadata.rs
···11use jacquard_common::{CowStr, IntoStatic, types::string::Language};
22use serde::{Deserialize, Serialize};
3344+/// Authorization server metadata, as returned from the
55+/// `.well-known/oauth-authorization-server` discovery document.
66+///
77+/// Defined by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414#section-2)
88+/// with extensions from OpenID Connect Discovery, RFC 9126 (PAR), RFC 9207,
99+/// RFC 9449 (DPoP), and the ATProto client ID metadata document draft.
410#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
511pub struct OAuthAuthorizationServerMetadata<'s> {
66- // https://datatracker.ietf.org/doc/html/rfc8414#section-2
1212+ /// The issuer identifier URL of the authorization server.
1313+ ///
1414+ /// <https://datatracker.ietf.org/doc/html/rfc8414#section-2>
715 #[serde(borrow)]
816 pub issuer: CowStr<'s>,
1717+ /// The URL of the authorization endpoint.
918 pub authorization_endpoint: CowStr<'s>, // optional?
1010- pub token_endpoint: CowStr<'s>, // optional?
1919+ /// The URL of the token endpoint.
2020+ pub token_endpoint: CowStr<'s>, // optional?
2121+ /// URL of the authorization server's JWK Set document.
1122 pub jwks_uri: Option<CowStr<'s>>,
2323+ /// URL of the dynamic client registration endpoint, if supported.
1224 pub registration_endpoint: Option<CowStr<'s>>,
2525+ /// List of OAuth 2.0 scope values the server supports.
1326 pub scopes_supported: Vec<CowStr<'s>>,
2727+ /// List of OAuth 2.0 response type values the server supports.
1428 pub response_types_supported: Vec<CowStr<'s>>,
2929+ /// List of OAuth 2.0 response mode values the server supports.
1530 pub response_modes_supported: Option<Vec<CowStr<'s>>>,
3131+ /// List of OAuth 2.0 grant type values the server supports.
1632 pub grant_types_supported: Option<Vec<CowStr<'s>>>,
3333+ /// List of client authentication methods supported at the token endpoint.
1734 pub token_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>,
3535+ /// List of JWS signing algorithms supported for token endpoint auth.
1836 pub token_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
3737+ /// URL of a page with human-readable information about the server.
1938 pub service_documentation: Option<CowStr<'s>>,
3939+ /// BCP 47 language tags for UI locales the server supports.
2040 pub ui_locales_supported: Option<Vec<Language>>,
4141+ /// URL of the authorization server's privacy policy.
2142 pub op_policy_uri: Option<CowStr<'s>>,
4343+ /// URL of the authorization server's terms of service.
2244 pub op_tos_uri: Option<CowStr<'s>>,
4545+ /// URL of the token revocation endpoint (RFC 7009).
2346 pub revocation_endpoint: Option<CowStr<'s>>,
4747+ /// List of client authentication methods supported at the revocation endpoint.
2448 pub revocation_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>,
4949+ /// List of JWS signing algorithms supported for revocation endpoint auth.
2550 pub revocation_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
5151+ /// URL of the token introspection endpoint (RFC 7662).
2652 pub introspection_endpoint: Option<CowStr<'s>>,
5353+ /// List of client authentication methods supported at the introspection endpoint.
2754 pub introspection_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>,
5555+ /// List of JWS signing algorithms supported for introspection endpoint auth.
2856 pub introspection_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
5757+ /// PKCE code challenge methods supported by the server.
2958 pub code_challenge_methods_supported: Option<Vec<CowStr<'s>>>,
30593131- // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
6060+ /// Subject identifier types supported (`public` or `pairwise`).
6161+ ///
6262+ /// <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>
3263 pub subject_types_supported: Option<Vec<CowStr<'s>>>,
6464+ /// If `true`, clients must pre-register `request_uri` values.
3365 pub require_request_uri_registration: Option<bool>,
34663535- // https://datatracker.ietf.org/doc/html/rfc9126#section-5
6767+ /// URL of the Pushed Authorization Request (PAR) endpoint (RFC 9126).
6868+ ///
6969+ /// <https://datatracker.ietf.org/doc/html/rfc9126#section-5>
3670 pub pushed_authorization_request_endpoint: Option<CowStr<'s>>,
7171+ /// If `true`, all authorization requests must use PAR.
3772 pub require_pushed_authorization_requests: Option<bool>,
38733939- // https://datatracker.ietf.org/doc/html/rfc9207#section-3
7474+ /// If `true`, the server includes `iss` in authorization responses to prevent mix-up attacks.
7575+ ///
7676+ /// <https://datatracker.ietf.org/doc/html/rfc9207#section-3>
4077 pub authorization_response_iss_parameter_supported: Option<bool>,
41784242- // https://datatracker.ietf.org/doc/html/rfc9449#section-5.1
7979+ /// DPoP JWS signing algorithms supported by this server (RFC 9449).
8080+ ///
8181+ /// <https://datatracker.ietf.org/doc/html/rfc9449#section-5.1>
4382 pub dpop_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
44834545- // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-5
8484+ /// If `true`, the server supports the ATProto client ID metadata document extension.
8585+ ///
8686+ /// <https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-5>
4687 pub client_id_metadata_document_supported: Option<bool>,
47884848- // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
8989+ /// Protected resources associated with this authorization server.
9090+ ///
9191+ /// <https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada>
4992 pub protected_resources: Option<Vec<CowStr<'s>>>,
5093}
51945252-// https://datatracker.ietf.org/doc/draft-ietf-oauth-resource-metadata/
5353-// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#section-2
9595+/// Protected resource metadata, returned from `.well-known/oauth-protected-resource`.
9696+///
9797+/// Allows clients to discover which authorization servers protect a given resource
9898+/// and what scopes and bearer methods are accepted. Defined by
9999+/// [draft-ietf-oauth-resource-metadata](https://datatracker.ietf.org/doc/draft-ietf-oauth-resource-metadata/).
54100#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
55101pub struct OAuthProtectedResourceMetadata<'s> {
102102+ /// The URL of the protected resource itself.
56103 #[serde(borrow)]
57104 pub resource: CowStr<'s>,
105105+ /// URLs of authorization servers that can issue tokens for this resource.
58106 pub authorization_servers: Option<Vec<CowStr<'s>>>,
107107+ /// URL of the resource server's JWK Set document.
59108 pub jwks_uri: Option<CowStr<'s>>,
109109+ /// List of OAuth 2.0 scope values the resource server supports.
60110 pub scopes_supported: Vec<CowStr<'s>>,
111111+ /// Bearer token presentation methods supported (`header`, `body`, `query`).
61112 pub bearer_methods_supported: Option<Vec<CowStr<'s>>>,
113113+ /// JWS signing algorithms supported for resource-bound tokens.
62114 pub resource_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
115115+ /// URL of a page with human-readable information about the resource.
63116 pub resource_documentation: Option<CowStr<'s>>,
117117+ /// URL of the resource server's privacy policy.
64118 pub resource_policy_uri: Option<CowStr<'s>>,
119119+ /// URL of the resource server's terms of service.
65120 pub resource_tos_uri: Option<CowStr<'s>>,
66121}
67122
+68-10
crates/jacquard-oauth/src/types/request.rs
···11use jacquard_common::{CowStr, IntoStatic};
22use serde::{Deserialize, Serialize};
3344+/// The `response_type` parameter for an OAuth 2.0 authorization request.
55+///
66+/// Determines what the authorization server returns in the redirect response.
47#[derive(Serialize, Deserialize, Debug)]
58#[serde(rename_all = "snake_case")]
69pub enum AuthorizationResponseType {
1010+ /// Authorization code flow — server returns a short-lived code for token exchange.
711 Code,
1212+ /// Implicit flow — server returns an access token directly (not recommended for new clients).
813 Token,
99- // OIDC (https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html)
1414+ /// OpenID Connect ID token response (see the
1515+ /// [multiple response types spec](https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html)).
1016 IdToken,
1117}
12181919+/// The `response_mode` parameter controlling how the authorization response is returned.
2020+///
2121+/// Defaults to `query` for `code` response type and `fragment` for `token`.
1322#[derive(Serialize, Deserialize, Debug)]
1423#[serde(rename_all = "snake_case")]
1524pub enum AuthorizationResponseMode {
2525+ /// Parameters are appended as query string components to the redirect URI.
1626 Query,
2727+ /// Parameters are appended as URI fragment components to the redirect URI.
1728 Fragment,
1818- // https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode
2929+ /// Parameters are encoded in an HTML form POSTed to the redirect URI.
3030+ ///
3131+ /// <https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode>
1932 FormPost,
2033}
21343535+/// PKCE code challenge method, as defined in RFC 7636.
3636+///
3737+/// `S256` is strongly preferred; `Plain` should only be used when the client
3838+/// cannot perform SHA-256.
2239#[derive(Serialize, Deserialize, Debug)]
2340pub enum AuthorizationCodeChallengeMethod {
4141+ /// SHA-256 hash of the code verifier, base64url-encoded (recommended).
2442 S256,
4343+ /// Raw code verifier used as the challenge (not recommended).
2544 #[serde(rename = "plain")]
2645 Plain,
2746}
28474848+/// Parameters for a Pushed Authorization Request (PAR), as defined in RFC 9126.
4949+///
5050+/// PAR allows clients to push their authorization parameters directly to the
5151+/// authorization server before redirecting the user, improving security by keeping
5252+/// parameters out of the browser URL.
2953#[derive(Serialize, Deserialize, Debug)]
3054pub struct ParParameters<'a> {
3131- // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
5555+ /// The response type to request (e.g. `code`).
5656+ ///
5757+ /// <https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1>
3258 pub response_type: AuthorizationResponseType,
5959+ /// The redirect URI where the authorization response will be sent.
3360 #[serde(borrow)]
3461 pub redirect_uri: CowStr<'a>,
6262+ /// An opaque CSRF state value to be echoed back in the callback.
3563 pub state: CowStr<'a>,
6464+ /// Space-separated list of requested scopes.
3665 pub scope: Option<CowStr<'a>>,
3737- // https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes
6666+ /// How the authorization response parameters are delivered to the client.
6767+ ///
6868+ /// <https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes>
3869 pub response_mode: Option<AuthorizationResponseMode>,
3939- // https://datatracker.ietf.org/doc/html/rfc7636#section-4.3
7070+ /// The PKCE code challenge derived from the code verifier.
7171+ ///
7272+ /// <https://datatracker.ietf.org/doc/html/rfc7636#section-4.3>
4073 pub code_challenge: CowStr<'a>,
7474+ /// The method used to derive the code challenge.
4175 pub code_challenge_method: AuthorizationCodeChallengeMethod,
4242- // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
7676+ /// Hint to pre-fill the login form with a handle or email.
7777+ ///
7878+ /// <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>
4379 pub login_hint: Option<CowStr<'a>>,
8080+ /// Prompt hint controlling authorization server UI behavior.
4481 pub prompt: Option<CowStr<'a>>,
4582}
46838484+/// The `grant_type` parameter for a token endpoint request.
4785#[derive(Serialize, Deserialize)]
4886#[serde(rename_all = "snake_case")]
4987pub enum TokenGrantType {
8888+ /// Exchange an authorization code for tokens.
5089 AuthorizationCode,
9090+ /// Use a refresh token to obtain a new access token.
5191 RefreshToken,
5292}
53939494+/// Parameters for exchanging an authorization code for tokens (RFC 6749 §4.1.3).
5495#[derive(Serialize, Deserialize)]
5596pub struct TokenRequestParameters<'a> {
5656- // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
9797+ /// Must be `authorization_code` for the authorization code grant.
9898+ ///
9999+ /// <https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3>
57100 pub grant_type: TokenGrantType,
101101+ /// The authorization code received from the authorization server.
58102 #[serde(borrow)]
59103 pub code: CowStr<'a>,
104104+ /// The redirect URI used in the original authorization request.
60105 pub redirect_uri: CowStr<'a>,
6161- // https://datatracker.ietf.org/doc/html/rfc7636#section-4.5
106106+ /// The PKCE code verifier that was used to generate the code challenge (RFC 7636 §4.5).
107107+ ///
108108+ /// <https://datatracker.ietf.org/doc/html/rfc7636#section-4.5>
62109 pub code_verifier: CowStr<'a>,
63110}
64111112112+/// Parameters for refreshing an access token using a refresh token (RFC 6749 §6).
65113#[derive(Serialize, Deserialize)]
66114pub struct RefreshRequestParameters<'a> {
6767- // https://datatracker.ietf.org/doc/html/rfc6749#section-6
115115+ /// Must be `refresh_token` for the refresh grant.
116116+ ///
117117+ /// <https://datatracker.ietf.org/doc/html/rfc6749#section-6>
68118 pub grant_type: TokenGrantType,
119119+ /// The refresh token previously issued to the client.
69120 #[serde(borrow)]
70121 pub refresh_token: CowStr<'a>,
122122+ /// Optional scope to request; must not exceed the originally granted scope.
71123 pub scope: Option<CowStr<'a>>,
72124}
731257474-// https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
126126+/// Parameters for a token revocation request (RFC 7009 §2.1).
127127+///
128128+/// Sent to the revocation endpoint to invalidate an access or refresh token,
129129+/// for example on logout.
130130+///
131131+/// <https://datatracker.ietf.org/doc/html/rfc7009#section-2.1>
75132#[derive(Serialize, Deserialize)]
76133pub struct RevocationRequestParameters<'a> {
134134+ /// The token to be revoked.
77135 #[serde(borrow)]
78136 pub token: CowStr<'a>,
79137 // ?
+18-1
crates/jacquard-oauth/src/types/response.rs
···11use serde::{Deserialize, Serialize};
22use smol_str::SmolStr;
3344+/// The response from a Pushed Authorization Request (PAR) endpoint.
55+///
66+/// The returned `request_uri` is used in place of inline authorization parameters
77+/// when redirecting the user to the authorization server.
48#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
59pub struct OAuthParResponse {
1010+ /// A short-lived URI representing the pushed authorization request.
611 pub request_uri: SmolStr,
1212+ /// Number of seconds until the `request_uri` expires.
713 pub expires_in: Option<u32>,
814}
9151616+/// The token type returned by the authorization server, indicating how to present the token.
1017#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1118pub enum OAuthTokenType {
1919+ /// Demonstration of Proof of Possession (DPoP) token (RFC 9449). Requires a DPoP proof header.
1220 DPoP,
2121+ /// Standard Bearer token (RFC 6750). Sent as `Authorization: Bearer <token>`.
1322 Bearer,
1423}
15241625impl OAuthTokenType {
2626+ /// Returns the string representation used in HTTP `Authorization` headers.
1727 pub fn as_str(&self) -> &'static str {
1828 match self {
1929 OAuthTokenType::DPoP => "DPoP",
···2232 }
2333}
24342525-// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
3535+/// A successful token response from the authorization server (RFC 6749 §5.1).
3636+/// <https://datatracker.ietf.org/doc/html/rfc6749#section-5.1>
2637#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2738pub struct OAuthTokenResponse {
3939+ /// The issued access token.
2840 pub access_token: SmolStr,
4141+ /// The type of token, indicating the presentation scheme to use.
2942 pub token_type: OAuthTokenType,
4343+ /// Lifetime of the access token in seconds from the time of issuance.
3044 pub expires_in: Option<i64>,
4545+ /// A refresh token that can be used to obtain new access tokens.
3146 pub refresh_token: Option<SmolStr>,
4747+ /// The scopes actually granted, if different from those requested.
3248 pub scope: Option<SmolStr>,
3349 // ATPROTO extension: add the sub claim to the token response to allow
3450 // clients to resolve the PDS url (audience) using the did resolution
3551 // mechanism.
5252+ /// The subject (DID) the token was issued for; ATProto extension for PDS discovery.
3653 pub sub: Option<SmolStr>,
3754}
+13
crates/jacquard-oauth/src/types/token.rs
···33use jacquard_common::{CowStr, IntoStatic};
44use serde::{Deserialize, Serialize};
5566+/// A complete set of OAuth tokens and associated claims for an authenticated session.
77+///
88+/// Combines the token response with resolved identity claims to give the client
99+/// everything it needs to make authorized requests. This is stored in the session
1010+/// and refreshed transparently by `OAuthSession`.
611#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
712pub struct TokenSet<'s> {
1313+ /// The issuer URL of the authorization server that issued these tokens.
814 #[serde(borrow)]
915 pub iss: CowStr<'s>,
1616+ /// The subject DID identifying the authenticated user.
1017 pub sub: Did<'s>,
1818+ /// The audience (resource server URL or DID) the tokens are intended for.
1119 pub aud: CowStr<'s>,
2020+ /// The scopes granted by the authorization server.
1221 pub scope: Option<CowStr<'s>>,
13222323+ /// A refresh token that can be exchanged for new access tokens.
1424 pub refresh_token: Option<CowStr<'s>>,
2525+ /// The current access token to include in API requests.
1526 pub access_token: CowStr<'s>,
2727+ /// Whether the access token must be presented as a DPoP or Bearer token.
1628 pub token_type: OAuthTokenType,
17293030+ /// The point in time at which the access token expires.
1831 pub expires_at: Option<Datetime>,
1932}
2033
+22-1
crates/jacquard-oauth/src/utils.rs
···991010use crate::{FALLBACK_ALG, types::OAuthAuthorizationServerMetadata};
11111212+/// Generate a fresh JWK secret key using the first algorithm from `allowed_algos` that is
1313+/// supported, returning `None` if none are supported.
1414+///
1515+/// Currently only `ES256` (P-256 ECDSA) is implemented; other algorithm identifiers are skipped.
1216pub fn generate_key(allowed_algos: &[CowStr]) -> Option<Key> {
1317 for alg in allowed_algos {
1418 #[allow(clippy::single_match)]
···2630 None
2731}
28323333+/// Generate a cryptographically random 16-byte nonce encoded as base64url (no padding).
2934pub fn generate_nonce() -> CowStr<'static> {
3035 URL_SAFE_NO_PAD
3136 .encode(get_random_values::<_, 16>(&mut ThreadRng::default()))
3237 .into()
3338}
34394040+/// Generate a cryptographically random 43-byte PKCE code verifier encoded as base64url (no padding).
3541pub fn generate_verifier() -> CowStr<'static> {
3642 URL_SAFE_NO_PAD
3743 .encode(get_random_values::<_, 43>(&mut ThreadRng::default()))
3844 .into()
3945}
40464747+/// Fill a `LEN`-byte array with cryptographically random bytes from `rng`.
4148pub fn get_random_values<R, const LEN: usize>(rng: &mut R) -> [u8; LEN]
4249where
4350 R: RngCore + CryptoRng,
···4754 bytes
4855}
49565050-// 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order)
5757+/// Compare two algorithm identifier strings by preference order for DPoP key generation.
5858+///
5959+/// The ordering is: ES256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other.
6060+/// Algorithms within the same family are ordered by key length, preferring shorter (faster) keys first.
5161pub fn compare_algos(a: &CowStr, b: &CowStr) -> Ordering {
5262 if a.as_ref() == "ES256K" {
5363 return Ordering::Less;
···7383 Ordering::Equal
7484}
75858686+/// Generate a PKCE challenge/verifier pair.
8787+///
8888+/// Returns `(challenge, verifier)` where `challenge` is the base64url-encoded SHA-256 hash
8989+/// of the verifier, per [RFC 7636 §4.1](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1).
9090+/// The verifier must be kept secret and sent at the token endpoint; the challenge is sent at
9191+/// the authorization endpoint.
7692pub fn generate_pkce() -> (CowStr<'static>, CowStr<'static>) {
7793 // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
7894 let verifier = generate_verifier();
···84100 )
85101}
86102103103+/// Generate a DPoP signing key compatible with the algorithms advertised by the authorization server.
104104+///
105105+/// Reads `dpop_signing_alg_values_supported` from the server metadata, sorts by preference
106106+/// using [`compare_algos`], and attempts to generate a key for the most preferred supported
107107+/// algorithm. Falls back to [`crate::FALLBACK_ALG`] if the server does not advertise any algorithms.
87108pub fn generate_dpop_key(metadata: &OAuthAuthorizationServerMetadata) -> Option<Key> {
88109 let mut algs = metadata
89110 .dpop_signing_alg_values_supported
+3-2
crates/jacquard/src/lib.rs
···224224pub mod client;
225225226226#[cfg(feature = "streaming")]
227227-/// Experimental streaming endpoints
227227+/// Streaming endpoints
228228pub mod streaming;
229229230230#[cfg(feature = "api_bluesky")]
···247247248248/// Prelude with the extension traits you're likely to want and some other stuff
249249pub mod prelude {
250250+ pub use crate::client::Agent;
250251 pub use crate::client::AgentSession;
251252 #[cfg(feature = "api")]
252253 pub use crate::client::AgentSessionExt;
···254255 pub use crate::common::http_client::HttpClient;
255256 pub use crate::common::xrpc::XrpcClient;
256257 pub use crate::common::xrpc::XrpcExt;
257257- pub use crate::identity::PublicResolver;
258258+ pub use crate::identity::JacquardResolver;
258259 pub use crate::identity::resolver::IdentityResolver;
259260 pub use crate::oauth::dpop::DpopExt;
260261 pub use crate::oauth::resolver::OAuthResolver;