···11+[](https://crates.io/crates/jacquard) [](https://docs.rs/jacquard)
22+33+# Jacquard
44+55+A suite of Rust crates intended to make it much easier to get started with atproto development, without sacrificing flexibility or performance.
66+77+[Jacquard is simpler](https://alpha.weaver.sh/nonbinary.computer/jacquard/jacquard_magic) because it is designed in a way which makes things simple that almost every other atproto library seems to make difficult.
88+99+It is also designed around zero-copy/borrowed deserialization: types like [`Post<'_>`](https://tangled.org/nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/app_bsky/feed/post.rs) can borrow data (via the [`CowStr<'_>`](https://docs.rs/jacquard/latest/jacquard/cowstr/enum.CowStr.html) type and a host of other types built on top of it) directly from the response buffer instead of allocating owned copies. Owned versions are themselves mostly inlined or reference-counted pointers and are therefore still quite efficient. The `IntoStatic` trait (which is derivable) makes it easy to get an owned version and avoid worrying about lifetimes.
1010+1111+## Features
1212+1313+- Validated, spec-compliant, easy to work with, and performant baseline types
1414+- Designed such that you can just work with generated API bindings easily
1515+- Straightforward OAuth
1616+- Server-side convenience features
1717+- Lexicon Data value type for working with unknown atproto data (dag-cbor or json)
1818+- An order of magnitude less boilerplate than some existing crates
1919+- Batteries-included, but easily replaceable batteries.
2020+ - Easy to extend with custom lexicons using code generation or handwritten api types
2121+ - Stateless options (or options where you handle the state) for rolling your own
2222+ - All the building blocks of the convenient abstractions are available
2323+ - Use as much or as little from the crates as you need
2424+2525+2626+## Example
2727+2828+Dead simple API client. Logs in with OAuth and prints the latest 5 posts from your timeline.
2929+3030+```rust
3131+// Note: this requires the `loopback` feature enabled (it is currently by default)
3232+use clap::Parser;
3333+use jacquard::CowStr;
3434+use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
3535+use jacquard::client::{Agent, FileAuthStore};
3636+use jacquard::oauth::client::OAuthClient;
3737+use jacquard::oauth::loopback::LoopbackConfig;
3838+use jacquard::types::xrpc::XrpcClient;
3939+use miette::IntoDiagnostic;
4040+4141+#[derive(Parser, Debug)]
4242+#[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")]
4343+struct Args {
4444+ /// Handle (e.g., alice.bsky.social), DID, or PDS URL
4545+ input: CowStr<'static>,
4646+4747+ /// Path to auth store file (will be created if missing)
4848+ #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
4949+ store: String,
5050+}
5151+5252+#[tokio::main]
5353+async fn main() -> miette::Result<()> {
5454+ let args = Args::parse();
5555+5656+ // Build an OAuth client with file-backed auth store and default localhost config
5757+ let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
5858+ // Authenticate with a PDS, using a loopback server to handle the callback flow
5959+ let session = oauth
6060+ .login_with_local_server(
6161+ args.input.clone(),
6262+ Default::default(),
6363+ LoopbackConfig::default(),
6464+ )
6565+ .await?;
6666+ // Wrap in Agent and fetch the timeline
6767+ let agent: Agent<_> = Agent::from(session);
6868+ let timeline = agent
6969+ .send(&GetTimeline::new().limit(5).build())
7070+ .await?
7171+ .into_output()?;
7272+ for (i, post) in timeline.feed.iter().enumerate() {
7373+ println!("\n{}. by {}", i + 1, post.post.author.handle);
7474+ println!(
7575+ " {}",
7676+ serde_json::to_string_pretty(&post.post.record).into_diagnostic()?
7777+ );
7878+ }
7979+8080+ Ok(())
8181+}
8282+8383+```
8484+8585+If you have `just` installed, you can run the [examples](https://tangled.org/nonbinary.computer/jacquard/tree/main/examples) using `just example {example-name} {ARGS}` or `just examples` to see what's available.
8686+8787+> [!WARNING]
8888+> The latest version swaps from the `url` crate to the lighter and quicker `fluent-uri`. It also moves the re-exported crate paths around and renames the `Uri<'_>` value type enum to `UriValue<'_>` to avoid confusion. This is likely to have broken some things. Migrating is pretty straightforward but consider yourself forewarned. This crate is *not* 1.0 for a reason.
8989+9090+### Changelog
9191+9292+[CHANGELOG.md](./CHANGELOG.md)
9393+9494+#### 0.11 Release Highlights:
9595+9696+- `jacquard-lexgen` and `jacquard-identity` no longer depend on the generated API crate. This is mostly for my own benefit.
9797+9898+**Code generation pipeline overhaul** (`jacquard-lexicon`, `jacquard-lexgen`)
9999+- Jacquard's codegen output already was nice to *use*. now it's going to be nice to read.
100100+- New code generation tracks the types used, makes an import block for the file, and then organizes the file with stuff you care about at the top and internal stuff, like the builders, at the bottom.
101101+- Import resolution pass now conditionally generates short paths when types are unambiguous within a module, falling back to fully-qualified paths when collisions exist
102102+103103+#### 0.10 Release Highlights:
104104+105105+**URL type migration**
106106+- Migrated from `url` crate to `fluent_uri` for validated URL/URI types
107107+- All `Url` types are now `Uri` from `fluent_uri`
108108+- Affects any code that constructs, passes, or pattern-matches on endpoint URLs
109109+110110+**Re-exported crate paths**
111111+- Re-exported crates (including non-proc-macro dependencies of the generated API crate) are now centralized into a distinct module
112112+- Import paths for re-exported types have changed
113113+114114+**`no_std` groundwork**
115115+- Initial work toward allowing jacquard to function on platforms without access to the standard library.
116116+- `std` usage is now feature-gated. the library currently *does not compile* without `std` due to some remaining dependencies.
117117+118118+### Projects using Jacquard
119119+120120+- [Tranquil PDS](https://tangled.org/tranquil.farm/tranquil-pds)
121121+- [skywatch-phash-rs](https://tangled.org/skywatch.blue/skywatch-phash-rs)
122122+- [Weaver](https://weaver.sh/) - [tangled repository](https://tangled.org/nonbinary.computer/weaver)
123123+- [wisp.place CLI tool](https://docs.wisp.place/cli/) - formerly
124124+- [PDS MOOver](https://pdsmoover.com/) - [tangled repository](https://tangled.org/baileytownsend.dev/pds-moover)
125125+126126+## Component crates
127127+128128+Jacquard is broken up into several crates for modularity. The correct one to use is generally `jacquard` itself, as it re-exports most of the others.
129129+130130+| | | |
131131+| --- | --- | --- |
132132+| `jacquard` | Main crate | [](https://crates.io/crates/jacquard) [](https://docs.rs/jacquard) |
133133+|`jacquard-common` | Foundation crate | [](https://crates.io/crates/jacquard-common) [](https://docs.rs/jacquard-common)|
134134+| `jacquard-axum` | Axum extractor and other helpers | [](https://crates.io/crates/jacquard-axum) [](https://docs.rs/jacquard-axum) |
135135+| `jacquard-api` | Autogenerated API bindings | [](https://crates.io/crates/jacquard-api) [](https://docs.rs/jacquard-api) |
136136+| `jacquard-oauth` | atproto OAuth implementation | [](https://crates.io/crates/jacquard-oauth) [](https://docs.rs/jacquard-oauth) |
137137+| `jacquard-identity` | Identity resolution | [](https://crates.io/crates/jacquard-identity) [](https://docs.rs/jacquard-identity) |
138138+| `jacquard-repo` | Repository primitives (MST, commits, CAR I/O) | [](https://crates.io/crates/jacquard-repo) [](https://docs.rs/jacquard-repo) |
139139+| `jacquard-lexicon` | Lexicon parsing and code generation | [](https://crates.io/crates/jacquard-lexicon) [](https://docs.rs/jacquard-lexicon) |
140140+| `jacquard-lexgen` | Code generation binaries | [](https://crates.io/crates/jacquard-lexgen) [](https://docs.rs/jacquard-lexgen) |
141141+| `jacquard-derive` | Macros for lexicon types | [](https://crates.io/crates/jacquard-derive) [](https://docs.rs/jacquard-derive) |
142142+143143+### Testimonials
144144+145145+- ["the most straightforward interface to atproto I've encountered so far."](https://bsky.app/profile/offline.mountainherder.xyz/post/3m3xwewzs3k2v) - @offline.mountainherder.xyz
146146+- "It has saved me a lot of time already! Well worth a few beers and or microcontrollers" - [@baileytownsend.dev](https://bsky.app/profile/baileytownsend.dev)
147147+- ["This is what your library allowed me to do in an hour!!! Thank you!!!"](https://bsky.app/profile/desertthunder.dev/post/3mhhbcula6224) - @desertthunder.dev
148148+149149+150150+## Development
151151+152152+This repo uses [Flakes](https://nixos.asia/en/flakes)
153153+154154+```bash
155155+# Dev shell
156156+nix develop
157157+158158+# or run via cargo
159159+nix develop -c cargo run
160160+161161+# build
162162+nix build
163163+```
164164+165165+There's also a [`justfile`](https://just.systems/) for Makefile-esque commands to be run inside of the devShell, and you can generally `cargo ...` or `just ...` whatever just fine if you don't want to use Nix and have the prerequisites installed.
166166+167167+168168+169169+[](./LICENSE)
+594
src-tauri/vendor/jacquard-oauth/src/atproto.rs
···11+use crate::types::OAuthClientMetadata;
22+use crate::{keyset::Keyset, scopes::Scope};
33+use jacquard_common::cowstr::ToCowStr;
44+use jacquard_common::deps::fluent_uri::Uri;
55+use jacquard_common::{CowStr, IntoStatic};
66+use serde::{Deserialize, Serialize};
77+use smol_str::{SmolStr, ToSmolStr};
88+use thiserror::Error;
99+1010+/// Errors that can occur when building AT Protocol OAuth client metadata.
1111+#[derive(Error, Debug)]
1212+#[non_exhaustive]
1313+pub enum Error {
1414+ /// The `client_id` is not a valid URL.
1515+ #[error("`client_id` must be a valid URL")]
1616+ InvalidClientId,
1717+ /// The `grant_types` list does not include `authorization_code`, which is required by atproto.
1818+ #[error("`grant_types` must include `authorization_code`")]
1919+ InvalidGrantTypes,
2020+ /// The `scope` list does not include `atproto`, which is required for all atproto clients.
2121+ #[error("`scope` must not include `atproto`")]
2222+ InvalidScope,
2323+ /// No redirect URIs were provided; at least one is required.
2424+ #[error("`redirect_uris` must not be empty")]
2525+ EmptyRedirectUris,
2626+ /// The `private_key_jwt` auth method was requested but no JWK keys were provided.
2727+ #[error("`private_key_jwt` auth method requires `jwks` keys")]
2828+ 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.
3131+ #[error(
3232+ "`private_key_jwt` auth method requires `token_endpoint_auth_signing_alg`, otherwise must not be provided"
3333+ )]
3434+ AuthSigningAlg,
3535+ /// HTML form serialization of the loopback `client_id` query string failed.
3636+ #[error(transparent)]
3737+ SerdeHtmlForm(#[from] serde_html_form::ser::Error),
3838+ /// A localhost-specific validation error occurred.
3939+ #[error(transparent)]
4040+ LocalhostClient(#[from] LocalhostClientError),
4141+}
4242+4343+/// 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`).
4747+#[derive(Error, Debug)]
4848+#[non_exhaustive]
4949+pub enum LocalhostClientError {
5050+ /// The redirect URI could not be parsed.
5151+ #[error("invalid redirect_uri: {0}")]
5252+ Invalid(#[from] jacquard_common::deps::fluent_uri::ParseError),
5353+ /// Loopback redirect URIs must use `http:`, not `https:` or any other scheme.
5454+ #[error("loopback client_id must use `http:` redirect_uri")]
5555+ NotHttpScheme,
5656+ /// The hostname `localhost` is not allowed; use a numeric loopback address instead.
5757+ #[error("loopback client_id must not use `localhost` as redirect_uri hostname")]
5858+ Localhost,
5959+ /// The redirect URI host is not a loopback address (127.x.x.x or ::1).
6060+ #[error("loopback client_id must not use loopback addresses as redirect_uri")]
6161+ NotLoopbackHost,
6262+}
6363+6464+/// Convenience result type for AT Protocol client metadata operations.
6565+pub type Result<T> = core::result::Result<T, Error>;
6666+6767+/// 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.
7171+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
7272+#[serde(rename_all = "snake_case")]
7373+pub enum AuthMethod {
7474+ /// No client authentication; used for public and loopback clients.
7575+ None,
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>
7878+ PrivateKeyJwt,
7979+}
8080+8181+impl From<AuthMethod> for CowStr<'static> {
8282+ fn from(value: AuthMethod) -> Self {
8383+ match value {
8484+ AuthMethod::None => CowStr::new_static("none"),
8585+ AuthMethod::PrivateKeyJwt => CowStr::new_static("private_key_jwt"),
8686+ }
8787+ }
8888+}
8989+9090+/// OAuth 2.0 grant types supported by AT Protocol clients.
9191+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9292+#[serde(rename_all = "snake_case")]
9393+pub enum GrantType {
9494+ /// Standard authorization code grant, required by atproto.
9595+ AuthorizationCode,
9696+ /// Refresh token grant, used to obtain new access tokens without re-authorization.
9797+ RefreshToken,
9898+}
9999+100100+impl From<GrantType> for CowStr<'static> {
101101+ fn from(value: GrantType) -> Self {
102102+ match value {
103103+ GrantType::AuthorizationCode => CowStr::new_static("authorization_code"),
104104+ GrantType::RefreshToken => CowStr::new_static("refresh_token"),
105105+ }
106106+ }
107107+}
108108+109109+/// 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.
115115+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
116116+pub struct AtprotoClientMetadata<'m> {
117117+ /// The unique identifier for this client, typically the URL of its metadata document.
118118+ pub client_id: Uri<String>,
119119+ /// The URI of the client's homepage or information page.
120120+ pub client_uri: Option<Uri<String>>,
121121+ /// The list of allowed redirect URIs for the authorization code flow.
122122+ pub redirect_uris: Vec<Uri<String>>,
123123+ /// The grant types this client will use.
124124+ pub grant_types: Vec<GrantType>,
125125+ /// The OAuth scopes this client requests; must include `atproto`.
126126+ #[serde(borrow)]
127127+ pub scopes: Vec<Scope<'m>>,
128128+ /// URI pointing to the client's JWK Set; mutually exclusive with inline `jwks`.
129129+ pub jwks_uri: Option<Uri<String>>,
130130+ /// Human-readable display name for the client.
131131+ pub client_name: Option<SmolStr>,
132132+ /// URI of the client's logo image.
133133+ pub logo_uri: Option<Uri<String>>,
134134+ /// URI of the client's terms of service document.
135135+ pub tos_uri: Option<Uri<String>>,
136136+ /// URI of the client's privacy policy document.
137137+ pub privacy_policy_uri: Option<Uri<String>>,
138138+}
139139+140140+impl<'m> IntoStatic for AtprotoClientMetadata<'m> {
141141+ type Output = AtprotoClientMetadata<'static>;
142142+ fn into_static(self) -> AtprotoClientMetadata<'static> {
143143+ AtprotoClientMetadata {
144144+ client_id: self.client_id,
145145+ client_uri: self.client_uri,
146146+ redirect_uris: self.redirect_uris,
147147+ grant_types: self.grant_types,
148148+ scopes: self.scopes.into_static(),
149149+ jwks_uri: self.jwks_uri,
150150+ client_name: self.client_name,
151151+ logo_uri: self.logo_uri,
152152+ tos_uri: self.tos_uri,
153153+ privacy_policy_uri: None,
154154+ }
155155+ }
156156+}
157157+158158+impl<'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.
163163+ pub fn with_prod_info(
164164+ mut self,
165165+ client_name: &str,
166166+ logo_uri: Option<Uri<String>>,
167167+ tos_uri: Option<Uri<String>>,
168168+ privacy_policy_uri: Option<Uri<String>>,
169169+ ) -> Self {
170170+ self.client_name = Some(client_name.to_smolstr());
171171+ self.logo_uri = logo_uri;
172172+ self.tos_uri = tos_uri;
173173+ self.privacy_policy_uri = privacy_policy_uri;
174174+ self
175175+ }
176176+177177+ /// 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.
182182+ pub fn default_localhost() -> Self {
183183+ Self::new_localhost(
184184+ None,
185185+ Some(Scope::parse_multiple("atproto transition:generic").unwrap()),
186186+ )
187187+ }
188188+189189+ /// 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.
195195+ pub fn new_localhost(
196196+ redirect_uris: Option<Vec<Uri<String>>>,
197197+ scopes: Option<Vec<Scope<'static>>>,
198198+ ) -> AtprotoClientMetadata<'static> {
199199+ // determine client_id
200200+ #[derive(serde::Serialize)]
201201+ struct Parameters<'a> {
202202+ #[serde(skip_serializing_if = "Option::is_none")]
203203+ redirect_uri: Option<Vec<CowStr<'a>>>,
204204+ #[serde(skip_serializing_if = "Option::is_none")]
205205+ scope: Option<CowStr<'a>>,
206206+ }
207207+ let redir_str = redirect_uris.as_ref().map(|uris| {
208208+ uris.iter()
209209+ .map(|u| u.as_str().trim_end_matches("/").to_cowstr().into_static())
210210+ .collect()
211211+ });
212212+ let query = serde_html_form::to_string(Parameters {
213213+ redirect_uri: redir_str,
214214+ scope: scopes
215215+ .as_ref()
216216+ .map(|s| Scope::serialize_multiple(s.as_slice())),
217217+ })
218218+ .ok();
219219+ let mut client_id = String::from("http://localhost/");
220220+ if let Some(query) = query
221221+ && !query.is_empty()
222222+ {
223223+ client_id.push_str(&format!("?{query}"));
224224+ }
225225+ AtprotoClientMetadata {
226226+ client_id: Uri::parse(client_id).unwrap(),
227227+ client_uri: None,
228228+ redirect_uris: redirect_uris.unwrap_or(vec![
229229+ Uri::parse("http://127.0.0.1".to_string()).unwrap(),
230230+ Uri::parse("http://[::1]".to_string()).unwrap(),
231231+ ]),
232232+ grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
233233+ scopes: scopes.unwrap_or(vec![Scope::Atproto]),
234234+ jwks_uri: None,
235235+ client_name: None,
236236+ logo_uri: None,
237237+ tos_uri: None,
238238+ privacy_policy_uri: None,
239239+ }
240240+ }
241241+}
242242+243243+/// 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.
249249+pub fn atproto_client_metadata<'m>(
250250+ metadata: AtprotoClientMetadata<'m>,
251251+ keyset: &Option<Keyset>,
252252+) -> Result<OAuthClientMetadata<'static>> {
253253+ let is_loopback = metadata.client_id.scheme().as_str() == "http"
254254+ && metadata.client_id.authority().map(|a| a.host()) == Some("localhost");
255255+ let application_type = if is_loopback {
256256+ Some(CowStr::new_static("native"))
257257+ } else {
258258+ Some(CowStr::new_static("web"))
259259+ };
260260+ if metadata.redirect_uris.is_empty() {
261261+ return Err(Error::EmptyRedirectUris);
262262+ }
263263+ if !metadata.grant_types.contains(&GrantType::AuthorizationCode) {
264264+ return Err(Error::InvalidGrantTypes);
265265+ }
266266+ if !metadata.scopes.contains(&Scope::Atproto) {
267267+ return Err(Error::InvalidScope);
268268+ }
269269+ let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset {
270270+ let jwks = if metadata.jwks_uri.is_none() {
271271+ Some(keyset.public_jwks())
272272+ } else {
273273+ None
274274+ };
275275+ (AuthMethod::PrivateKeyJwt, metadata.jwks_uri, jwks)
276276+ } else {
277277+ (AuthMethod::None, None, None)
278278+ };
279279+ let client_id = metadata
280280+ .client_id
281281+ .as_str()
282282+ .trim_end_matches("/")
283283+ .to_string();
284284+ let client_uri = metadata
285285+ .client_uri
286286+ .as_ref()
287287+ .map(|u| u.as_str().trim_end_matches("/").to_string().into());
288288+ let redirect_uris = metadata
289289+ .redirect_uris
290290+ .iter()
291291+ .map(|u| u.as_str().trim_end_matches("/").to_string().into())
292292+ .collect();
293293+ let jwks_uri = jwks_uri.map(|u| u.as_str().trim_end_matches("/").to_string().into());
294294+ Ok(OAuthClientMetadata {
295295+ client_id: client_id.into(),
296296+ client_uri,
297297+ redirect_uris,
298298+ application_type,
299299+ token_endpoint_auth_method: Some(auth_method.into()),
300300+ grant_types: Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()),
301301+ response_types: vec!["code".to_cowstr()],
302302+ scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())),
303303+ dpop_bound_access_tokens: Some(true),
304304+ jwks_uri,
305305+ jwks,
306306+ token_endpoint_auth_signing_alg: if keyset.is_some() {
307307+ Some(CowStr::new_static("ES256"))
308308+ } else {
309309+ None
310310+ },
311311+ client_name: metadata.client_name,
312312+ logo_uri: metadata
313313+ .logo_uri
314314+ .as_ref()
315315+ .map(|u| u.as_str().to_string().into()),
316316+ tos_uri: metadata
317317+ .tos_uri
318318+ .as_ref()
319319+ .map(|u| u.as_str().to_string().into()),
320320+ privacy_policy_uri: metadata
321321+ .privacy_policy_uri
322322+ .as_ref()
323323+ .map(|u| u.as_str().to_string().into()),
324324+ })
325325+}
326326+327327+#[cfg(test)]
328328+mod tests {
329329+ use crate::scopes::TransitionScope;
330330+331331+ use super::*;
332332+ use elliptic_curve::SecretKey;
333333+ use jose_jwk::{Jwk, Key, Parameters};
334334+ use p256::pkcs8::DecodePrivateKey;
335335+336336+ const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
337337+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgED1AAgC7Fc9kPh5T
338338+4i4Tn+z+tc47W1zYgzXtyjJtD92hRANCAAT80DqC+Z/JpTO7/pkPBmWqIV1IGh1P
339339+gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3
340340+-----END PRIVATE KEY-----"#;
341341+342342+ #[test]
343343+ fn test_localhost_client_metadata_default() {
344344+ assert_eq!(
345345+ atproto_client_metadata(AtprotoClientMetadata::new_localhost(None, None), &None)
346346+ .unwrap(),
347347+ OAuthClientMetadata {
348348+ client_id: CowStr::new_static("http://localhost"),
349349+ client_uri: None,
350350+ redirect_uris: vec![
351351+ CowStr::new_static("http://127.0.0.1"),
352352+ CowStr::new_static("http://[::1]"),
353353+ ],
354354+ application_type: Some(CowStr::new_static("native")),
355355+ scope: Some(CowStr::new_static("atproto")),
356356+ grant_types: Some(vec![
357357+ "authorization_code".to_cowstr(),
358358+ "refresh_token".to_cowstr()
359359+ ]),
360360+ response_types: vec!["code".to_cowstr()],
361361+ token_endpoint_auth_method: Some(AuthMethod::None.into()),
362362+ dpop_bound_access_tokens: Some(true),
363363+ jwks_uri: None,
364364+ jwks: None,
365365+ token_endpoint_auth_signing_alg: None,
366366+ tos_uri: None,
367367+ privacy_policy_uri: None,
368368+ client_name: None,
369369+ logo_uri: None,
370370+ }
371371+ );
372372+ }
373373+374374+ #[test]
375375+ fn test_localhost_client_metadata_custom() {
376376+ assert_eq!(
377377+ atproto_client_metadata(
378378+ AtprotoClientMetadata::new_localhost(
379379+ Some(vec![
380380+ Uri::parse("http://127.0.0.1/callback".to_string()).unwrap(),
381381+ Uri::parse("http://[::1]/callback".to_string()).unwrap(),
382382+ ]),
383383+ Some(vec![
384384+ Scope::Atproto,
385385+ Scope::Transition(TransitionScope::Generic),
386386+ Scope::parse("account:email").unwrap()
387387+ ])
388388+ ),
389389+ &None
390390+ )
391391+ .expect("failed to convert metadata"),
392392+ OAuthClientMetadata {
393393+ client_id: CowStr::new_static(
394394+ "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric"
395395+ ),
396396+ client_uri: None,
397397+ redirect_uris: vec![
398398+ CowStr::new_static("http://127.0.0.1/callback"),
399399+ CowStr::new_static("http://[::1]/callback"),
400400+ ],
401401+ scope: Some(CowStr::new_static(
402402+ "account:email atproto transition:generic"
403403+ )),
404404+ application_type: Some(CowStr::new_static("native")),
405405+ grant_types: Some(vec![
406406+ "authorization_code".to_cowstr(),
407407+ "refresh_token".to_cowstr()
408408+ ]),
409409+ response_types: vec!["code".to_cowstr()],
410410+ token_endpoint_auth_method: Some(AuthMethod::None.into()),
411411+ dpop_bound_access_tokens: Some(true),
412412+ jwks_uri: None,
413413+ jwks: None,
414414+ token_endpoint_auth_signing_alg: None,
415415+ tos_uri: None,
416416+ privacy_policy_uri: None,
417417+ client_name: None,
418418+ logo_uri: None,
419419+ }
420420+ );
421421+ }
422422+423423+ #[test]
424424+ fn test_localhost_client_metadata_invalid() {
425425+ // Invalid inputs are coerced to http://localhost rather than failing
426426+ {
427427+ let out = atproto_client_metadata(
428428+ AtprotoClientMetadata::new_localhost(
429429+ Some(vec![Uri::parse("https://127.0.0.1".to_string()).unwrap()]),
430430+ None,
431431+ ),
432432+ &None,
433433+ )
434434+ .expect("should coerce to 127.0.0.1");
435435+ assert_eq!(
436436+ out,
437437+ OAuthClientMetadata {
438438+ client_id: CowStr::new_static(
439439+ "http://localhost/?redirect_uri=https%3A%2F%2F127.0.0.1"
440440+ ),
441441+ application_type: Some(CowStr::new_static("native")),
442442+ client_uri: None,
443443+ redirect_uris: vec![CowStr::new_static("https://127.0.0.1")],
444444+ scope: Some(CowStr::new_static("atproto")),
445445+ grant_types: Some(vec![
446446+ "authorization_code".to_cowstr(),
447447+ "refresh_token".to_cowstr()
448448+ ]),
449449+ response_types: vec!["code".to_cowstr()],
450450+ token_endpoint_auth_method: Some(AuthMethod::None.into()),
451451+ dpop_bound_access_tokens: Some(true),
452452+ jwks_uri: None,
453453+ jwks: None,
454454+ token_endpoint_auth_signing_alg: None,
455455+ tos_uri: None,
456456+ privacy_policy_uri: None,
457457+ client_name: None,
458458+ logo_uri: None,
459459+ }
460460+ );
461461+ }
462462+ {
463463+ let out = atproto_client_metadata(
464464+ AtprotoClientMetadata::new_localhost(
465465+ Some(vec![
466466+ Uri::parse("http://localhost:8000".to_string()).unwrap(),
467467+ ]),
468468+ None,
469469+ ),
470470+ &None,
471471+ )
472472+ .expect("should coerce to 127.0.0.1");
473473+ assert_eq!(
474474+ out,
475475+ OAuthClientMetadata {
476476+ client_id: CowStr::new_static(
477477+ "http://localhost/?redirect_uri=http%3A%2F%2Flocalhost%3A8000"
478478+ ),
479479+ client_uri: None,
480480+ redirect_uris: vec![CowStr::new_static("http://localhost:8000")],
481481+ scope: Some(CowStr::new_static("atproto")),
482482+ grant_types: Some(vec![
483483+ "authorization_code".to_cowstr(),
484484+ "refresh_token".to_cowstr()
485485+ ]),
486486+ application_type: Some(CowStr::new_static("native")),
487487+ response_types: vec!["code".to_cowstr()],
488488+ token_endpoint_auth_method: Some(AuthMethod::None.into()),
489489+ dpop_bound_access_tokens: Some(true),
490490+ jwks_uri: None,
491491+ jwks: None,
492492+ token_endpoint_auth_signing_alg: None,
493493+ tos_uri: None,
494494+ privacy_policy_uri: None,
495495+ client_name: None,
496496+ logo_uri: None,
497497+ }
498498+ );
499499+ }
500500+ {
501501+ let out = atproto_client_metadata(
502502+ AtprotoClientMetadata::new_localhost(
503503+ Some(vec![Uri::parse("http://192.168.0.0/".to_string()).unwrap()]),
504504+ None,
505505+ ),
506506+ &None,
507507+ )
508508+ .expect("should coerce to 127.0.0.1");
509509+ assert_eq!(
510510+ out,
511511+ OAuthClientMetadata {
512512+ client_id: CowStr::new_static(
513513+ "http://localhost/?redirect_uri=http%3A%2F%2F192.168.0.0"
514514+ ),
515515+ client_uri: None,
516516+ redirect_uris: vec![CowStr::new_static("http://192.168.0.0")],
517517+ scope: Some(CowStr::new_static("atproto")),
518518+ grant_types: Some(vec![
519519+ "authorization_code".to_cowstr(),
520520+ "refresh_token".to_cowstr()
521521+ ]),
522522+ application_type: Some(CowStr::new_static("native")),
523523+ response_types: vec!["code".to_cowstr()],
524524+ token_endpoint_auth_method: Some(AuthMethod::None.into()),
525525+ dpop_bound_access_tokens: Some(true),
526526+ jwks_uri: None,
527527+ jwks: None,
528528+ token_endpoint_auth_signing_alg: None,
529529+ tos_uri: None,
530530+ privacy_policy_uri: None,
531531+ client_name: None,
532532+ logo_uri: None,
533533+ }
534534+ );
535535+ }
536536+ }
537537+538538+ #[test]
539539+ fn test_client_metadata() {
540540+ let metadata = AtprotoClientMetadata {
541541+ client_id: Uri::parse("https://example.com/client_metadata.json".to_string()).unwrap(),
542542+ client_uri: Some(Uri::parse("https://example.com".to_string()).unwrap()),
543543+ redirect_uris: vec![Uri::parse("https://example.com/callback".to_string()).unwrap()],
544544+ grant_types: vec![GrantType::AuthorizationCode],
545545+ scopes: vec![Scope::Atproto],
546546+ jwks_uri: None,
547547+ client_name: None,
548548+ logo_uri: None,
549549+ tos_uri: None,
550550+ privacy_policy_uri: None,
551551+ };
552552+ {
553553+ // Non-loopback clients without a keyset should fail (must provide JWKS)
554554+ let metadata = metadata.clone();
555555+ let err = atproto_client_metadata(metadata, &None);
556556+ assert!(err.is_ok());
557557+ }
558558+ {
559559+ let metadata = metadata.clone();
560560+ let secret_key = SecretKey::<p256::NistP256>::from_pkcs8_pem(PRIVATE_KEY)
561561+ .expect("failed to parse private key");
562562+ let keys = vec![Jwk {
563563+ key: Key::from(&secret_key.into()),
564564+ prm: Parameters {
565565+ kid: Some(String::from("kid00")),
566566+ ..Default::default()
567567+ },
568568+ }];
569569+ let keyset = Keyset::try_from(keys.clone()).expect("failed to create keyset");
570570+ assert_eq!(
571571+ atproto_client_metadata(metadata, &Some(keyset.clone()))
572572+ .expect("failed to convert metadata"),
573573+ OAuthClientMetadata {
574574+ client_id: CowStr::new_static("https://example.com/client_metadata.json"),
575575+ client_uri: Some(CowStr::new_static("https://example.com")),
576576+ redirect_uris: vec![CowStr::new_static("https://example.com/callback")],
577577+ application_type: Some(CowStr::new_static("web")),
578578+ scope: Some(CowStr::new_static("atproto")),
579579+ grant_types: Some(vec![CowStr::new_static("authorization_code")]),
580580+ token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()),
581581+ dpop_bound_access_tokens: Some(true),
582582+ response_types: vec!["code".to_cowstr()],
583583+ jwks_uri: None,
584584+ jwks: Some(keyset.public_jwks()),
585585+ token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")),
586586+ client_name: None,
587587+ logo_uri: None,
588588+ tos_uri: None,
589589+ privacy_policy_uri: None,
590590+ }
591591+ );
592592+ }
593593+ }
594594+}
+157
src-tauri/vendor/jacquard-oauth/src/authstore.rs
···11+use std::future::Future;
22+use std::sync::Arc;
33+44+use dashmap::DashMap;
55+use jacquard_common::{
66+ IntoStatic,
77+ session::{SessionStore, SessionStoreError},
88+ types::did::Did,
99+};
1010+use smol_str::{SmolStr, ToSmolStr, format_smolstr};
1111+1212+use crate::session::{AuthRequestData, ClientSessionData};
1313+1414+/// 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.
2020+#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
2121+pub trait ClientAuthStore {
2222+ /// Retrieve an active session for the given DID and session identifier, if one exists.
2323+ fn get_session(
2424+ &self,
2525+ did: &Did<'_>,
2626+ session_id: &str,
2727+ ) -> impl Future<Output = Result<Option<ClientSessionData<'_>>, SessionStoreError>>;
2828+2929+ /// Insert or update a session, replacing any existing entry for the same DID and session ID.
3030+ fn upsert_session(
3131+ &self,
3232+ session: ClientSessionData<'_>,
3333+ ) -> impl Future<Output = Result<(), SessionStoreError>>;
3434+3535+ /// Delete the session for the given DID and session identifier.
3636+ fn delete_session(
3737+ &self,
3838+ did: &Did<'_>,
3939+ session_id: &str,
4040+ ) -> impl Future<Output = Result<(), SessionStoreError>>;
4141+4242+ /// Retrieve the authorization request data associated with the given OAuth `state` value.
4343+ fn get_auth_req_info(
4444+ &self,
4545+ state: &str,
4646+ ) -> impl Future<Output = Result<Option<AuthRequestData<'_>>, SessionStoreError>>;
4747+4848+ /// Persist authorization request data so it can be retrieved after the OAuth redirect.
4949+ fn save_auth_req_info(
5050+ &self,
5151+ auth_req_info: &AuthRequestData<'_>,
5252+ ) -> impl Future<Output = Result<(), SessionStoreError>>;
5353+5454+ /// Remove authorization request data after the callback has been handled.
5555+ fn delete_auth_req_info(
5656+ &self,
5757+ state: &str,
5858+ ) -> impl Future<Output = Result<(), SessionStoreError>>;
5959+}
6060+6161+/// An in-memory implementation of [`ClientAuthStore`], suitable for testing and single-process
6262+/// deployments where session persistence across restarts is not required.
6363+pub struct MemoryAuthStore {
6464+ sessions: DashMap<SmolStr, ClientSessionData<'static>>,
6565+ auth_reqs: DashMap<SmolStr, AuthRequestData<'static>>,
6666+}
6767+6868+impl MemoryAuthStore {
6969+ /// Create a new, empty in-memory auth store.
7070+ pub fn new() -> Self {
7171+ Self {
7272+ sessions: DashMap::new(),
7373+ auth_reqs: DashMap::new(),
7474+ }
7575+ }
7676+}
7777+7878+impl ClientAuthStore for MemoryAuthStore {
7979+ async fn get_session(
8080+ &self,
8181+ did: &Did<'_>,
8282+ session_id: &str,
8383+ ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> {
8484+ let key = format_smolstr!("{}_{}", did, session_id);
8585+ Ok(self.sessions.get(&key).map(|v| v.clone()))
8686+ }
8787+8888+ async fn upsert_session(
8989+ &self,
9090+ session: ClientSessionData<'_>,
9191+ ) -> Result<(), SessionStoreError> {
9292+ let key = format_smolstr!("{}_{}", session.account_did, session.session_id);
9393+ self.sessions.insert(key, session.into_static());
9494+ Ok(())
9595+ }
9696+9797+ async fn delete_session(
9898+ &self,
9999+ did: &Did<'_>,
100100+ session_id: &str,
101101+ ) -> Result<(), SessionStoreError> {
102102+ let key = format_smolstr!("{}_{}", did, session_id);
103103+ self.sessions.remove(&key);
104104+ Ok(())
105105+ }
106106+107107+ async fn get_auth_req_info(
108108+ &self,
109109+ state: &str,
110110+ ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> {
111111+ Ok(self.auth_reqs.get(state).map(|v| v.clone()))
112112+ }
113113+114114+ async fn save_auth_req_info(
115115+ &self,
116116+ auth_req_info: &AuthRequestData<'_>,
117117+ ) -> Result<(), SessionStoreError> {
118118+ self.auth_reqs.insert(
119119+ auth_req_info.state.clone().to_smolstr(),
120120+ auth_req_info.clone().into_static(),
121121+ );
122122+ Ok(())
123123+ }
124124+125125+ async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> {
126126+ self.auth_reqs.remove(state);
127127+ Ok(())
128128+ }
129129+}
130130+131131+impl<T: ClientAuthStore + Send + Sync>
132132+ SessionStore<(Did<'static>, SmolStr), ClientSessionData<'static>> for Arc<T>
133133+{
134134+ /// Get the current session if present.
135135+ async fn get(&self, key: &(Did<'static>, SmolStr)) -> Option<ClientSessionData<'static>> {
136136+ let (did, session_id) = key;
137137+ self.as_ref()
138138+ .get_session(did, session_id)
139139+ .await
140140+ .ok()
141141+ .flatten()
142142+ .into_static()
143143+ }
144144+ /// Persist the given session.
145145+ async fn set(
146146+ &self,
147147+ _key: (Did<'static>, SmolStr),
148148+ session: ClientSessionData<'static>,
149149+ ) -> Result<(), SessionStoreError> {
150150+ self.as_ref().upsert_session(session).await
151151+ }
152152+ /// Delete the given session.
153153+ async fn del(&self, key: &(Did<'static>, SmolStr)) -> Result<(), SessionStoreError> {
154154+ let (did, session_id) = key;
155155+ self.as_ref().delete_session(did, session_id).await
156156+ }
157157+}
+1085
src-tauri/vendor/jacquard-oauth/src/client.rs
···11+use crate::{
22+ atproto::atproto_client_metadata,
33+ authstore::ClientAuthStore,
44+ dpop::DpopExt,
55+ error::{CallbackError, Result},
66+ request::{OAuthMetadata, exchange_code, par},
77+ resolver::OAuthResolver,
88+ scopes::Scope,
99+ session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry},
1010+ types::{AuthorizeOptions, CallbackParams},
1111+};
1212+use jacquard_common::{
1313+ AuthorizationToken, CowStr, IntoStatic,
1414+ cowstr::ToCowStr,
1515+ deps::fluent_uri::Uri,
1616+ error::{AuthError, ClientError, XrpcResult},
1717+ http_client::HttpClient,
1818+ types::{did::Did, string::Handle},
1919+ xrpc::{
2020+ CallOptions, Response, XrpcClient, XrpcError, XrpcExt, XrpcRequest, XrpcResp, XrpcResponse,
2121+ build_http_request, process_response,
2222+ },
2323+};
2424+2525+#[cfg(feature = "websocket")]
2626+use jacquard_common::websocket::{WebSocketClient, WebSocketConnection};
2727+#[cfg(feature = "websocket")]
2828+use jacquard_common::xrpc::XrpcSubscription;
2929+use jacquard_identity::{
3030+ JacquardResolver,
3131+ resolver::{DidDocResponse, IdentityError, IdentityResolver, ResolverOptions},
3232+};
3333+use jose_jwk::JwkSet;
3434+use std::{future::Future, sync::Arc};
3535+use tokio::sync::RwLock;
3636+3737+/// The top-level OAuth client responsible for driving the authorization flow.
3838+pub struct OAuthClient<T, S>
3939+where
4040+ T: OAuthResolver,
4141+ S: ClientAuthStore,
4242+{
4343+ /// Shared session registry that mediates access to the backing auth store.
4444+ pub registry: Arc<SessionRegistry<T, S>>,
4545+ /// Default call options applied to every outgoing XRPC request.
4646+ pub options: RwLock<CallOptions<'static>>,
4747+ /// Override for the XRPC base URI; falls back to the public Bluesky AppView when `None`.
4848+ pub endpoint: RwLock<Option<Uri<String>>>,
4949+ /// Underlying HTTP/identity/OAuth resolver used for all network operations.
5050+ pub client: Arc<T>,
5151+}
5252+5353+impl<S: ClientAuthStore> OAuthClient<JacquardResolver, S> {
5454+ /// Create an `OAuthClient` using the default [`JacquardResolver`] for identity and metadata resolution.
5555+ pub fn new(store: S, client_data: ClientData<'static>) -> Self {
5656+ let client = JacquardResolver::default();
5757+ Self::new_from_resolver(store, client, client_data)
5858+ }
5959+6060+ /// Create an OAuth client with the provided store and default localhost client metadata.
6161+ ///
6262+ /// This is a convenience constructor for quickly setting up an OAuth client
6363+ /// with default localhost redirect URIs and "atproto transition:generic" scopes.
6464+ ///
6565+ /// # Example
6666+ ///
6767+ /// ```no_run
6868+ /// # use jacquard_oauth::client::OAuthClient;
6969+ /// # use jacquard_oauth::authstore::MemoryAuthStore;
7070+ /// # #[tokio::main]
7171+ /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7272+ /// let store = MemoryAuthStore::new();
7373+ /// let oauth = OAuthClient::with_default_config(store);
7474+ /// # Ok(())
7575+ /// # }
7676+ /// ```
7777+ pub fn with_default_config(store: S) -> Self {
7878+ let client_data = ClientData {
7979+ keyset: None,
8080+ config: crate::atproto::AtprotoClientMetadata::default_localhost(),
8181+ };
8282+ Self::new(store, client_data)
8383+ }
8484+}
8585+8686+impl OAuthClient<JacquardResolver, crate::authstore::MemoryAuthStore> {
8787+ /// Create an OAuth client with an in-memory auth store and default localhost client metadata.
8888+ ///
8989+ /// This is a convenience constructor for simple testing and development.
9090+ /// The session will not persist across restarts.
9191+ ///
9292+ /// # Example
9393+ ///
9494+ /// ```no_run
9595+ /// # use jacquard_oauth::client::OAuthClient;
9696+ /// # #[tokio::main]
9797+ /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9898+ /// let oauth = OAuthClient::with_memory_store();
9999+ /// # Ok(())
100100+ /// # }
101101+ /// ```
102102+ pub fn with_memory_store() -> Self {
103103+ Self::with_default_config(crate::authstore::MemoryAuthStore::new())
104104+ }
105105+}
106106+107107+impl<T, S> OAuthClient<T, S>
108108+where
109109+ T: OAuthResolver,
110110+ S: ClientAuthStore,
111111+{
112112+ /// Create an OAuth client from an explicit resolver instance, taking ownership of both.
113113+ pub fn new_from_resolver(store: S, client: T, client_data: ClientData<'static>) -> Self {
114114+ // #[cfg(feature = "tracing")]
115115+ // tracing::info!(
116116+ // redirect_uris = ?client_data.config.redirect_uris,
117117+ // scopes = ?client_data.config.scopes,
118118+ // has_keyset = client_data.keyset.is_some(),
119119+ // "oauth client created:"
120120+ // );
121121+122122+ let client = Arc::new(client);
123123+ let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data));
124124+ Self {
125125+ registry,
126126+ client,
127127+ options: RwLock::new(CallOptions::default()),
128128+ endpoint: RwLock::new(None),
129129+ }
130130+ }
131131+132132+ /// Create an OAuth client from already-`Arc`-wrapped store and resolver.
133133+ pub fn new_with_shared(
134134+ store: Arc<S>,
135135+ client: Arc<T>,
136136+ client_data: ClientData<'static>,
137137+ ) -> Self {
138138+ let registry = Arc::new(SessionRegistry::new_shared(
139139+ store,
140140+ client.clone(),
141141+ client_data,
142142+ ));
143143+ Self {
144144+ registry,
145145+ client,
146146+ options: RwLock::new(CallOptions::default()),
147147+ endpoint: RwLock::new(None),
148148+ }
149149+ }
150150+}
151151+152152+impl<T, S> OAuthClient<T, S>
153153+where
154154+ S: ClientAuthStore + Send + Sync + 'static,
155155+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
156156+{
157157+ /// Return the public JWK set for this client's keyset, or an empty set if no keyset is configured.
158158+ pub fn jwks(&self) -> JwkSet {
159159+ self.registry
160160+ .client_data
161161+ .keyset
162162+ .as_ref()
163163+ .map(|keyset| keyset.public_jwks())
164164+ .unwrap_or_default()
165165+ }
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.
174174+ #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self, input), fields(input = input.as_ref())))]
175175+ pub async fn start_auth(
176176+ &self,
177177+ input: impl AsRef<str>,
178178+ options: AuthorizeOptions<'_>,
179179+ ) -> Result<String> {
180180+ let client_metadata = atproto_client_metadata(
181181+ self.registry.client_data.config.clone(),
182182+ &self.registry.client_data.keyset,
183183+ )?;
184184+ let (server_metadata, identity) = self.client.resolve_oauth(input.as_ref()).await?;
185185+ let login_hint = if identity.is_some() {
186186+ Some(input.as_ref().into())
187187+ } else {
188188+ None
189189+ };
190190+ let metadata = OAuthMetadata {
191191+ server_metadata,
192192+ client_metadata,
193193+ keyset: self.registry.client_data.keyset.clone(),
194194+ };
195195+196196+ let auth_req_info = par(
197197+ self.client.as_ref(),
198198+ login_hint,
199199+ options.prompt,
200200+ &metadata,
201201+ options.state,
202202+ )
203203+ .await?;
204204+205205+ // Persist state for callback handling
206206+ self.registry
207207+ .store
208208+ .save_auth_req_info(&auth_req_info)
209209+ .await?;
210210+211211+ #[derive(serde::Serialize)]
212212+ struct Parameters<'s> {
213213+ client_id: CowStr<'s>,
214214+ request_uri: CowStr<'s>,
215215+ }
216216+ Ok(metadata.server_metadata.authorization_endpoint.to_string()
217217+ + "?"
218218+ + &serde_html_form::to_string(Parameters {
219219+ client_id: metadata.client_metadata.client_id,
220220+ request_uri: auth_req_info.request_uri,
221221+ })
222222+ .unwrap())
223223+ }
224224+225225+ /// 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.
230230+ #[cfg_attr(feature = "tracing", tracing::instrument(level = "info", skip_all, fields(state = params.state.as_ref().map(|s| s.as_ref()))))]
231231+ pub async fn callback(&self, params: CallbackParams<'_>) -> Result<OAuthSession<T, S>> {
232232+ let Some(state_key) = params.state else {
233233+ return Err(CallbackError::MissingState.into());
234234+ };
235235+236236+ let Some(auth_req_info) = self.registry.store.get_auth_req_info(&state_key).await? else {
237237+ return Err(CallbackError::MissingState.into());
238238+ };
239239+240240+ self.registry.store.delete_auth_req_info(&state_key).await?;
241241+242242+ let metadata = self
243243+ .client
244244+ .get_authorization_server_metadata(&auth_req_info.authserver_url.to_cowstr())
245245+ .await?;
246246+247247+ if let Some(iss) = params.iss {
248248+ if iss != metadata.issuer {
249249+ return Err(CallbackError::IssuerMismatch {
250250+ expected: metadata.issuer.to_string(),
251251+ got: iss.to_string(),
252252+ }
253253+ .into());
254254+ }
255255+ } else if metadata.authorization_response_iss_parameter_supported == Some(true) {
256256+ return Err(CallbackError::MissingIssuer.into());
257257+ }
258258+ let metadata = OAuthMetadata {
259259+ server_metadata: metadata,
260260+ client_metadata: atproto_client_metadata(
261261+ self.registry.client_data.config.clone(),
262262+ &self.registry.client_data.keyset,
263263+ )?,
264264+ keyset: self.registry.client_data.keyset.clone(),
265265+ };
266266+ let authserver_nonce = auth_req_info.dpop_data.dpop_authserver_nonce.clone();
267267+268268+ match exchange_code(
269269+ self.client.as_ref(),
270270+ &mut auth_req_info.dpop_data.clone(),
271271+ ¶ms.code,
272272+ &auth_req_info.pkce_verifier,
273273+ &metadata,
274274+ )
275275+ .await
276276+ {
277277+ Ok(token_set) => {
278278+ let scopes = if let Some(scope) = &token_set.scope {
279279+ Scope::parse_multiple_reduced(&scope)
280280+ .expect("Failed to parse scopes")
281281+ .into_static()
282282+ } else {
283283+ vec![]
284284+ };
285285+ let client_data = ClientSessionData {
286286+ account_did: token_set.sub.clone(),
287287+ session_id: auth_req_info.state,
288288+ host_url: Uri::parse(token_set.aud.as_ref())?.to_owned(),
289289+ authserver_url: auth_req_info.authserver_url.to_cowstr(),
290290+ authserver_token_endpoint: auth_req_info.authserver_token_endpoint,
291291+ authserver_revocation_endpoint: auth_req_info.authserver_revocation_endpoint,
292292+ scopes,
293293+ dpop_data: DpopClientData {
294294+ dpop_key: auth_req_info.dpop_data.dpop_key.clone(),
295295+ dpop_authserver_nonce: authserver_nonce.unwrap_or(CowStr::default()),
296296+ dpop_host_nonce: auth_req_info
297297+ .dpop_data
298298+ .dpop_authserver_nonce
299299+ .unwrap_or(CowStr::default()),
300300+ },
301301+ token_set,
302302+ };
303303+304304+ self.create_session(client_data).await
305305+ }
306306+ Err(e) => Err(e.into()),
307307+ }
308308+ }
309309+310310+ async fn create_session(&self, data: ClientSessionData<'_>) -> Result<OAuthSession<T, S>> {
311311+ self.registry.set(data.clone()).await?;
312312+ Ok(OAuthSession::new(
313313+ self.registry.clone(),
314314+ self.client.clone(),
315315+ data.into_static(),
316316+ ))
317317+ }
318318+319319+ /// Restore a previously created session from the backing store, refreshing tokens if needed.
320320+ pub async fn restore(&self, did: &Did<'_>, session_id: &str) -> Result<OAuthSession<T, S>> {
321321+ self.create_session(self.registry.get(did, session_id, true).await?)
322322+ .await
323323+ }
324324+325325+ /// 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.
330330+ pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> {
331331+ Ok(self.registry.del(did, session_id).await?)
332332+ }
333333+}
334334+335335+impl<T, S> HttpClient for OAuthClient<T, S>
336336+where
337337+ S: ClientAuthStore + Send + Sync + 'static,
338338+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
339339+{
340340+ type Error = T::Error;
341341+342342+ async fn send_http(
343343+ &self,
344344+ request: http::Request<Vec<u8>>,
345345+ ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
346346+ self.client.send_http(request).await
347347+ }
348348+}
349349+350350+impl<T, S> IdentityResolver for OAuthClient<T, S>
351351+where
352352+ S: ClientAuthStore + Send + Sync + 'static,
353353+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
354354+{
355355+ fn options(&self) -> &ResolverOptions {
356356+ self.client.options()
357357+ }
358358+359359+ async fn resolve_handle(
360360+ &self,
361361+ handle: &Handle<'_>,
362362+ ) -> jacquard_identity::resolver::Result<Did<'static>> {
363363+ self.client.resolve_handle(handle).await
364364+ }
365365+366366+ async fn resolve_did_doc(
367367+ &self,
368368+ did: &Did<'_>,
369369+ ) -> jacquard_identity::resolver::Result<DidDocResponse> {
370370+ self.client.resolve_did_doc(did).await
371371+ }
372372+}
373373+374374+impl<T, S> XrpcClient for OAuthClient<T, S>
375375+where
376376+ S: ClientAuthStore + Send + Sync + 'static,
377377+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
378378+{
379379+ async fn base_uri(&self) -> Uri<String> {
380380+ self.endpoint.read().await.clone().unwrap_or_else(|| {
381381+ Uri::parse("https://public.api.bsky.app")
382382+ .expect("hardcoded URI is valid")
383383+ .to_owned()
384384+ })
385385+ }
386386+387387+ async fn opts(&self) -> CallOptions<'_> {
388388+ self.options.read().await.clone()
389389+ }
390390+391391+ async fn set_opts(&self, opts: CallOptions<'_>) {
392392+ let mut guard = self.options.write().await;
393393+ *guard = opts.into_static();
394394+ }
395395+396396+ async fn set_base_uri(&self, uri: Uri<String>) {
397397+ let normalized = jacquard_common::xrpc::normalize_base_uri(uri);
398398+ let mut guard = self.endpoint.write().await;
399399+ *guard = Some(normalized);
400400+ }
401401+402402+ async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>>
403403+ where
404404+ R: XrpcRequest + Send + Sync,
405405+ <R as XrpcRequest>::Response: Send + Sync,
406406+ {
407407+ let opts = self.options.read().await.clone();
408408+ self.send_with_opts(request, opts).await
409409+ }
410410+411411+ async fn send_with_opts<R>(
412412+ &self,
413413+ request: R,
414414+ opts: CallOptions<'_>,
415415+ ) -> XrpcResult<XrpcResponse<R>>
416416+ where
417417+ R: XrpcRequest + Send + Sync,
418418+ <R as XrpcRequest>::Response: Send + Sync,
419419+ {
420420+ let base_uri = self.base_uri().await;
421421+ self.client
422422+ .xrpc(base_uri)
423423+ .with_options(opts.clone())
424424+ .send(&request)
425425+ .await
426426+ }
427427+}
428428+429429+/// 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`].
436436+pub struct OAuthSession<T, S, W = ()>
437437+where
438438+ T: OAuthResolver,
439439+ S: ClientAuthStore,
440440+{
441441+ /// Shared registry used to persist and retrieve session data across refresh operations.
442442+ pub registry: Arc<SessionRegistry<T, S>>,
443443+ /// Underlying HTTP/identity/OAuth resolver shared with the parent `OAuthClient`.
444444+ pub client: Arc<T>,
445445+ /// Optional WebSocket client; `()` when WebSocket support is not required.
446446+ pub ws_client: W,
447447+ /// Mutable session data including DPoP key, nonces, and token set.
448448+ pub data: RwLock<ClientSessionData<'static>>,
449449+ /// Default call options applied to every outgoing XRPC request from this session.
450450+ pub options: RwLock<CallOptions<'static>>,
451451+}
452452+453453+impl<T, S> OAuthSession<T, S, ()>
454454+where
455455+ T: OAuthResolver,
456456+ S: ClientAuthStore,
457457+{
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`].
462462+ pub fn new(
463463+ registry: Arc<SessionRegistry<T, S>>,
464464+ client: Arc<T>,
465465+ data: ClientSessionData<'static>,
466466+ ) -> Self {
467467+ Self {
468468+ registry,
469469+ client,
470470+ ws_client: (),
471471+ data: RwLock::new(data),
472472+ options: RwLock::new(CallOptions::default()),
473473+ }
474474+ }
475475+}
476476+477477+impl<T, S, W> OAuthSession<T, S, W>
478478+where
479479+ T: OAuthResolver,
480480+ S: ClientAuthStore,
481481+{
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.
487487+ pub fn new_with_ws(
488488+ registry: Arc<SessionRegistry<T, S>>,
489489+ client: Arc<T>,
490490+ ws_client: W,
491491+ data: ClientSessionData<'static>,
492492+ ) -> Self {
493493+ Self {
494494+ registry,
495495+ client,
496496+ ws_client,
497497+ data: RwLock::new(data),
498498+ options: RwLock::new(CallOptions::default()),
499499+ }
500500+ }
501501+502502+ /// 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.
506506+ pub fn with_options(self, options: CallOptions<'_>) -> Self {
507507+ Self {
508508+ registry: self.registry,
509509+ client: self.client,
510510+ ws_client: self.ws_client,
511511+ data: self.data,
512512+ options: RwLock::new(options.into_static()),
513513+ }
514514+ }
515515+516516+ /// Get a reference to the WebSocket client.
517517+ pub fn ws_client(&self) -> &W {
518518+ &self.ws_client
519519+ }
520520+521521+ /// Replace the default call options for this session without consuming it.
522522+ pub async fn set_options(&self, options: CallOptions<'_>) {
523523+ *self.options.write().await = options.into_static();
524524+ }
525525+526526+ /// 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`].
530530+ pub async fn session_info(&self) -> (Did<'_>, CowStr<'_>) {
531531+ let data = self.data.read().await;
532532+ (data.account_did.clone(), data.session_id.clone())
533533+ }
534534+535535+ /// Return the resource server (PDS) base URI for this session.
536536+ pub async fn endpoint(&self) -> Uri<String> {
537537+ self.data.read().await.host_url.clone()
538538+ }
539539+540540+ /// 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.
544544+ pub async fn access_token(&self) -> AuthorizationToken<'_> {
545545+ AuthorizationToken::Dpop(self.data.read().await.token_set.access_token.clone())
546546+ }
547547+548548+ /// 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.
552552+ pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> {
553553+ self.data
554554+ .read()
555555+ .await
556556+ .token_set
557557+ .refresh_token
558558+ .as_ref()
559559+ .map(|t| AuthorizationToken::Dpop(t.clone()))
560560+ }
561561+562562+ /// 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.
566566+ pub fn to_client(&self) -> OAuthClient<T, S> {
567567+ OAuthClient::from_session(self)
568568+ }
569569+}
570570+impl<T, S, W> OAuthSession<T, S, W>
571571+where
572572+ S: ClientAuthStore + Send + Sync + 'static,
573573+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
574574+{
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.
580580+ pub async fn logout(&self) -> Result<()> {
581581+ use crate::request::{OAuthMetadata, revoke};
582582+ let mut data = self.data.write().await;
583583+ let meta =
584584+ OAuthMetadata::new(self.client.as_ref(), &self.registry.client_data, &data).await?;
585585+ if meta.server_metadata.revocation_endpoint.is_some() {
586586+ let token = data.token_set.access_token.clone();
587587+ revoke(self.client.as_ref(), &mut data.dpop_data, &token, &meta)
588588+ .await
589589+ .ok();
590590+ }
591591+ // Remove from store
592592+ self.registry
593593+ .del(&data.account_did, &data.session_id)
594594+ .await?;
595595+ Ok(())
596596+ }
597597+}
598598+599599+impl<T, S> OAuthClient<T, S>
600600+where
601601+ T: OAuthResolver,
602602+ S: ClientAuthStore,
603603+{
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.
608608+ pub fn from_session<W>(session: &OAuthSession<T, S, W>) -> Self {
609609+ Self {
610610+ registry: session.registry.clone(),
611611+ client: session.client.clone(),
612612+ options: RwLock::new(CallOptions::default()),
613613+ endpoint: RwLock::new(None),
614614+ }
615615+ }
616616+}
617617+impl<T, S, W> OAuthSession<T, S, W>
618618+where
619619+ S: ClientAuthStore + Send + Sync + 'static,
620620+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
621621+{
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.
630630+ #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))]
631631+ pub async fn refresh(&self) -> Result<AuthorizationToken<'_>> {
632632+ // Read identifiers without holding the lock across await
633633+ let (did, sid) = {
634634+ let data = self.data.read().await;
635635+ (data.account_did.clone(), data.session_id.clone())
636636+ };
637637+ let refreshed = self.registry.as_ref().get(&did, &sid, true).await?;
638638+ let token = AuthorizationToken::Dpop(refreshed.token_set.access_token.clone());
639639+ // Write back updated session
640640+ *self.data.write().await = refreshed.clone().into_static();
641641+ // Store in the registry
642642+ self.registry.set(refreshed).await?;
643643+ Ok(token)
644644+ }
645645+}
646646+647647+impl<T, S, W> HttpClient for OAuthSession<T, S, W>
648648+where
649649+ S: ClientAuthStore + Send + Sync + 'static,
650650+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
651651+ W: Send + Sync,
652652+{
653653+ type Error = T::Error;
654654+655655+ async fn send_http(
656656+ &self,
657657+ request: http::Request<Vec<u8>>,
658658+ ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
659659+ self.client.send_http(request).await
660660+ }
661661+}
662662+663663+impl<T, S, W> XrpcClient for OAuthSession<T, S, W>
664664+where
665665+ S: ClientAuthStore + Send + Sync + 'static,
666666+ T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static,
667667+ W: Send + Sync,
668668+{
669669+ async fn base_uri(&self) -> Uri<String> {
670670+ self.data.read().await.host_url.clone()
671671+ }
672672+673673+ async fn opts(&self) -> CallOptions<'_> {
674674+ self.options.read().await.clone()
675675+ }
676676+677677+ async fn set_opts(&self, opts: CallOptions<'_>) {
678678+ let mut guard = self.options.write().await;
679679+ *guard = opts.into_static();
680680+ }
681681+682682+ async fn set_base_uri(&self, uri: Uri<String>) {
683683+ let normalized = jacquard_common::xrpc::normalize_base_uri(uri);
684684+ let mut guard = self.data.write().await;
685685+ guard.host_url = normalized;
686686+ }
687687+688688+ async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>>
689689+ where
690690+ R: XrpcRequest + Send + Sync,
691691+ <R as XrpcRequest>::Response: Send + Sync,
692692+ {
693693+ let opts = self.options.read().await.clone();
694694+ self.send_with_opts(request, opts).await
695695+ }
696696+697697+ async fn send_with_opts<R>(
698698+ &self,
699699+ request: R,
700700+ mut opts: CallOptions<'_>,
701701+ ) -> XrpcResult<XrpcResponse<R>>
702702+ where
703703+ R: XrpcRequest + Send + Sync,
704704+ <R as XrpcRequest>::Response: Send + Sync,
705705+ {
706706+ let base_uri = self.base_uri().await;
707707+ let original_token = self.access_token().await;
708708+ opts.auth = Some(original_token.clone());
709709+ // Clone dpop_data and release read lock before the await point
710710+ let mut dpop = self.data.read().await.dpop_data.clone();
711711+ let http_response = self
712712+ .client
713713+ .dpop_call(&mut dpop)
714714+ .send(build_http_request(&base_uri, &request, &opts)?)
715715+ .await
716716+ .map_err(|e| ClientError::from(e).for_nsid(R::NSID))?;
717717+ let resp = process_response(http_response);
718718+719719+ // Write back updated nonce to session data (dpop_call may have updated it)
720720+ {
721721+ let mut guard = self.data.write().await;
722722+ guard.dpop_data.dpop_host_nonce = dpop.dpop_host_nonce.clone();
723723+ }
724724+725725+ if is_invalid_token_response(&resp) {
726726+ // Optimistic refresh: check if another request already refreshed the token
727727+ let current_token = self.access_token().await;
728728+ if current_token != original_token {
729729+ // Token was already refreshed by another concurrent request, use it
730730+ opts.auth = Some(current_token);
731731+ } else {
732732+ // We need to refresh - this will be serialized by the registry's Mutex
733733+ opts.auth = Some(
734734+ self.refresh()
735735+ .await
736736+ .map_err(|e| ClientError::transport(e))?,
737737+ );
738738+ }
739739+ // Re-read dpop_data after refresh (refresh may have updated it)
740740+ let mut dpop = self.data.read().await.dpop_data.clone();
741741+ let http_response = self
742742+ .client
743743+ .dpop_call(&mut dpop)
744744+ .send(build_http_request(&base_uri, &request, &opts)?)
745745+ .await
746746+ .map_err(|e| {
747747+ ClientError::from(e)
748748+ .for_nsid(R::NSID)
749749+ .append_context("after token refresh")
750750+ })?;
751751+ let resp = process_response(http_response);
752752+753753+ // Write back updated nonce after retry
754754+ {
755755+ let mut guard = self.data.write().await;
756756+ guard.dpop_data.dpop_host_nonce = dpop.dpop_host_nonce.clone();
757757+ }
758758+759759+ resp
760760+ } else {
761761+ resp
762762+ }
763763+ }
764764+}
765765+766766+#[cfg(feature = "streaming")]
767767+impl<T, S, W> jacquard_common::http_client::HttpClientExt for OAuthSession<T, S, W>
768768+where
769769+ S: ClientAuthStore + Send + Sync + 'static,
770770+ T: OAuthResolver
771771+ + DpopExt
772772+ + XrpcExt
773773+ + jacquard_common::http_client::HttpClientExt
774774+ + Send
775775+ + Sync
776776+ + 'static,
777777+ W: Send + Sync,
778778+{
779779+ async fn send_http_streaming(
780780+ &self,
781781+ request: http::Request<Vec<u8>>,
782782+ ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error>
783783+ {
784784+ self.client.send_http_streaming(request).await
785785+ }
786786+787787+ #[cfg(not(target_arch = "wasm32"))]
788788+ async fn send_http_bidirectional<Str>(
789789+ &self,
790790+ parts: http::request::Parts,
791791+ body: Str,
792792+ ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error>
793793+ where
794794+ Str: n0_future::Stream<
795795+ Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>,
796796+ > + Send
797797+ + 'static,
798798+ {
799799+ self.client.send_http_bidirectional(parts, body).await
800800+ }
801801+802802+ #[cfg(target_arch = "wasm32")]
803803+ async fn send_http_bidirectional<Str>(
804804+ &self,
805805+ parts: http::request::Parts,
806806+ body: Str,
807807+ ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error>
808808+ where
809809+ Str: n0_future::Stream<
810810+ Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>,
811811+ > + 'static,
812812+ {
813813+ self.client.send_http_bidirectional(parts, body).await
814814+ }
815815+}
816816+817817+#[cfg(feature = "streaming")]
818818+impl<T, S, W> jacquard_common::xrpc::XrpcStreamingClient for OAuthSession<T, S, W>
819819+where
820820+ S: ClientAuthStore + Send + Sync + 'static,
821821+ T: OAuthResolver
822822+ + DpopExt
823823+ + XrpcExt
824824+ + jacquard_common::http_client::HttpClientExt
825825+ + Send
826826+ + Sync
827827+ + 'static,
828828+ W: Send + Sync,
829829+{
830830+ async fn download<R>(
831831+ &self,
832832+ request: R,
833833+ ) -> core::result::Result<jacquard_common::xrpc::StreamingResponse, jacquard_common::StreamError>
834834+ where
835835+ R: XrpcRequest + Send + Sync,
836836+ <R as XrpcRequest>::Response: Send + Sync,
837837+ {
838838+ use jacquard_common::StreamError;
839839+840840+ let base_uri = <Self as XrpcClient>::base_uri(self).await;
841841+ let mut opts = self.options.read().await.clone();
842842+ opts.auth = Some(self.access_token().await);
843843+ let http_request = build_http_request(&base_uri, &request, &opts)
844844+ .map_err(|e| StreamError::protocol(e.to_string()))?;
845845+ let guard = self.data.read().await;
846846+ let mut dpop = guard.dpop_data.clone();
847847+ let result = self
848848+ .client
849849+ .dpop_call(&mut dpop)
850850+ .send_streaming(http_request)
851851+ .await;
852852+ drop(guard);
853853+854854+ match result {
855855+ Ok(response) => Ok(response),
856856+ Err(_e) => {
857857+ // Check if it's an auth error and retry
858858+ opts.auth = Some(
859859+ self.refresh()
860860+ .await
861861+ .map_err(|e| StreamError::transport(e))?,
862862+ );
863863+ let http_request = build_http_request(&base_uri, &request, &opts)
864864+ .map_err(|e| StreamError::protocol(e.to_string()))?;
865865+ let guard = self.data.read().await;
866866+ let mut dpop = guard.dpop_data.clone();
867867+ self.client
868868+ .dpop_call(&mut dpop)
869869+ .send_streaming(http_request)
870870+ .await
871871+ .map_err(StreamError::transport)
872872+ }
873873+ }
874874+ }
875875+876876+ async fn stream<Str>(
877877+ &self,
878878+ stream: jacquard_common::xrpc::streaming::XrpcProcedureSend<Str::Frame<'static>>,
879879+ ) -> core::result::Result<
880880+ jacquard_common::xrpc::streaming::XrpcResponseStream<
881881+ <<Str as jacquard_common::xrpc::streaming::XrpcProcedureStream>::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>,
882882+ >,
883883+ jacquard_common::StreamError,
884884+ >
885885+ where
886886+ Str: jacquard_common::xrpc::streaming::XrpcProcedureStream + 'static,
887887+ <<Str as jacquard_common::xrpc::streaming::XrpcProcedureStream>::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>: jacquard_common::xrpc::streaming::XrpcStreamResp,
888888+ {
889889+ use jacquard_common::StreamError;
890890+ use n0_future::TryStreamExt;
891891+892892+ let base_uri = self.base_uri().await;
893893+ let mut opts = self.options.read().await.clone();
894894+ opts.auth = Some(self.access_token().await);
895895+896896+ let mut path = String::from(base_uri.as_str().trim_end_matches('/'));
897897+ path.push_str("/xrpc/");
898898+ path.push_str(<Str::Request as jacquard_common::xrpc::XrpcRequest>::NSID);
899899+900900+ let mut builder = http::Request::post(path);
901901+902902+ if let Some(token) = &opts.auth {
903903+ use jacquard_common::AuthorizationToken;
904904+ let hv = match token {
905905+ AuthorizationToken::Bearer(t) => {
906906+ http::HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
907907+ }
908908+ AuthorizationToken::Dpop(t) => {
909909+ http::HeaderValue::from_str(&format!("DPoP {}", t.as_ref()))
910910+ }
911911+ }
912912+ .map_err(|e| StreamError::protocol(format!("Invalid authorization token: {}", e)))?;
913913+ builder = builder.header(http::header::AUTHORIZATION, hv);
914914+ }
915915+916916+ if let Some(proxy) = &opts.atproto_proxy {
917917+ builder = builder.header("atproto-proxy", proxy.as_ref());
918918+ }
919919+ if let Some(labelers) = &opts.atproto_accept_labelers {
920920+ if !labelers.is_empty() {
921921+ let joined = labelers
922922+ .iter()
923923+ .map(|s| s.as_ref())
924924+ .collect::<Vec<_>>()
925925+ .join(", ");
926926+ builder = builder.header("atproto-accept-labelers", joined);
927927+ }
928928+ }
929929+ for (name, value) in &opts.extra_headers {
930930+ builder = builder.header(name, value);
931931+ }
932932+933933+ let (parts, _) = builder
934934+ .body(())
935935+ .map_err(|e| StreamError::protocol(e.to_string()))?
936936+ .into_parts();
937937+938938+ let body_stream =
939939+ jacquard_common::stream::ByteStream::new(Box::pin(stream.0.map_ok(|f| f.buffer)));
940940+941941+ let guard = self.data.read().await;
942942+ let mut dpop = guard.dpop_data.clone();
943943+ let result = self
944944+ .client
945945+ .dpop_call(&mut dpop)
946946+ .send_bidirectional(parts, body_stream)
947947+ .await;
948948+ drop(guard);
949949+950950+ match result {
951951+ Ok(response) => {
952952+ let (resp_parts, resp_body) = response.into_parts();
953953+ Ok(
954954+ jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts(
955955+ resp_parts, resp_body,
956956+ ),
957957+ )
958958+ }
959959+ Err(e) => {
960960+ // OAuth token refresh and retry is handled by dpop wrapper
961961+ // If we get here, it's a real error
962962+ Err(StreamError::transport(e))
963963+ }
964964+ }
965965+ }
966966+}
967967+968968+fn is_invalid_token_response<R: XrpcResp>(response: &XrpcResult<Response<R>>) -> bool {
969969+ use jacquard_common::error::ClientErrorKind;
970970+971971+ match response {
972972+ Err(e) => match e.kind() {
973973+ ClientErrorKind::Auth(AuthError::InvalidToken) => true,
974974+ ClientErrorKind::Auth(AuthError::Other(value)) => value
975975+ .to_str()
976976+ .is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")),
977977+ _ => false,
978978+ },
979979+ Ok(resp) => match resp.parse() {
980980+ Err(XrpcError::Auth(AuthError::InvalidToken)) => true,
981981+ _ => false,
982982+ },
983983+ }
984984+}
985985+986986+impl<T, S, W> IdentityResolver for OAuthSession<T, S, W>
987987+where
988988+ S: ClientAuthStore + Send + Sync + 'static,
989989+ T: OAuthResolver + IdentityResolver + XrpcExt + Send + Sync + 'static,
990990+ W: Send + Sync,
991991+{
992992+ fn options(&self) -> &ResolverOptions {
993993+ self.client.options()
994994+ }
995995+996996+ fn resolve_handle(
997997+ &self,
998998+ handle: &Handle<'_>,
999999+ ) -> impl Future<Output = std::result::Result<Did<'static>, IdentityError>> {
10001000+ async { self.client.resolve_handle(handle).await }
10011001+ }
10021002+10031003+ fn resolve_did_doc(
10041004+ &self,
10051005+ did: &Did<'_>,
10061006+ ) -> impl Future<Output = std::result::Result<DidDocResponse, IdentityError>> {
10071007+ async { self.client.resolve_did_doc(did).await }
10081008+ }
10091009+}
10101010+10111011+#[cfg(feature = "websocket")]
10121012+impl<T, S, W> WebSocketClient for OAuthSession<T, S, W>
10131013+where
10141014+ S: ClientAuthStore + Send + Sync + 'static,
10151015+ T: OAuthResolver + Send + Sync + 'static,
10161016+ W: WebSocketClient + Send + Sync,
10171017+{
10181018+ type Error = W::Error;
10191019+10201020+ async fn connect(
10211021+ &self,
10221022+ uri: Uri<&str>,
10231023+ ) -> std::result::Result<WebSocketConnection, Self::Error> {
10241024+ self.ws_client.connect(uri).await
10251025+ }
10261026+10271027+ async fn connect_with_headers(
10281028+ &self,
10291029+ uri: Uri<&str>,
10301030+ headers: Vec<(CowStr<'_>, CowStr<'_>)>,
10311031+ ) -> std::result::Result<WebSocketConnection, Self::Error> {
10321032+ self.ws_client.connect_with_headers(uri, headers).await
10331033+ }
10341034+}
10351035+10361036+#[cfg(feature = "websocket")]
10371037+impl<T, S, W> jacquard_common::xrpc::SubscriptionClient for OAuthSession<T, S, W>
10381038+where
10391039+ S: ClientAuthStore + Send + Sync + 'static,
10401040+ T: OAuthResolver + Send + Sync + 'static,
10411041+ W: WebSocketClient + Send + Sync,
10421042+{
10431043+ async fn base_uri(&self) -> Uri<String> {
10441044+ self.data.read().await.host_url.clone()
10451045+ }
10461046+10471047+ async fn subscription_opts(&self) -> jacquard_common::xrpc::SubscriptionOptions<'_> {
10481048+ let mut opts = jacquard_common::xrpc::SubscriptionOptions::default();
10491049+ let token = self.access_token().await;
10501050+ let auth_value = match token {
10511051+ AuthorizationToken::Bearer(t) => format!("Bearer {}", t.as_ref()),
10521052+ AuthorizationToken::Dpop(t) => format!("DPoP {}", t.as_ref()),
10531053+ };
10541054+ opts.headers
10551055+ .push((CowStr::from("Authorization"), CowStr::from(auth_value)));
10561056+ opts
10571057+ }
10581058+10591059+ async fn subscribe<Sub>(
10601060+ &self,
10611061+ params: &Sub,
10621062+ ) -> std::result::Result<jacquard_common::xrpc::SubscriptionStream<Sub::Stream>, Self::Error>
10631063+ where
10641064+ Sub: XrpcSubscription + Send + Sync,
10651065+ {
10661066+ let opts = self.subscription_opts().await;
10671067+ self.subscribe_with_opts(params, opts).await
10681068+ }
10691069+10701070+ async fn subscribe_with_opts<Sub>(
10711071+ &self,
10721072+ params: &Sub,
10731073+ opts: jacquard_common::xrpc::SubscriptionOptions<'_>,
10741074+ ) -> std::result::Result<jacquard_common::xrpc::SubscriptionStream<Sub::Stream>, Self::Error>
10751075+ where
10761076+ Sub: XrpcSubscription + Send + Sync,
10771077+ {
10781078+ use jacquard_common::xrpc::SubscriptionExt;
10791079+ let base = self.base_uri().await;
10801080+ self.subscription(base)
10811081+ .with_options(opts)
10821082+ .subscribe(params)
10831083+ .await
10841084+ }
10851085+}
+810
src-tauri/vendor/jacquard-oauth/src/dpop.rs
···11+use std::error::Error as StdError;
22+use std::fmt;
33+use std::future::Future;
44+55+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
66+use chrono::Utc;
77+use http::{Request, Response, header::InvalidHeaderValue};
88+use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient};
99+use jacquard_identity::JacquardResolver;
1010+use jose_jwa::{Algorithm, Signing};
1111+use jose_jwk::{Jwk, Key, crypto};
1212+use p256::ecdsa::SigningKey;
1313+use rand::{RngCore, SeedableRng};
1414+use sha2::Digest;
1515+use smol_str::SmolStr;
1616+1717+use crate::{
1818+ jose::{
1919+ jws::RegisteredHeader,
2020+ jwt::{Claims, PublicClaims, RegisteredClaims},
2121+ signing,
2222+ },
2323+ session::DpopDataSource,
2424+};
2525+2626+/// The `typ` header value required in all DPoP proof JWTs, per RFC 9449.
2727+pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt";
2828+2929+#[derive(serde::Deserialize)]
3030+struct ErrorResponse {
3131+ error: String,
3232+}
3333+3434+/// Boxed error type for error sources.
3535+pub type BoxError = Box<dyn StdError + Send + Sync + 'static>;
3636+3737+/// Target server type for DPoP requests.
3838+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3939+pub enum DpopTarget {
4040+ /// OAuth authorization server (token endpoint, PAR, etc.)
4141+ AuthServer,
4242+ /// Resource server (PDS, AppView, etc.)
4343+ ResourceServer,
4444+}
4545+4646+impl fmt::Display for DpopTarget {
4747+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4848+ match self {
4949+ DpopTarget::AuthServer => write!(f, "auth server"),
5050+ DpopTarget::ResourceServer => write!(f, "resource server"),
5151+ }
5252+ }
5353+}
5454+5555+/// Error categories for DPoP operations.
5656+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5757+#[non_exhaustive]
5858+pub enum DpopErrorKind {
5959+ /// DPoP proof construction failed.
6060+ ProofBuild,
6161+ /// Initial HTTP request failed.
6262+ Transport,
6363+ /// Retry after nonce update also failed.
6464+ NonceRetry,
6565+ /// Header value parsing failed.
6666+ InvalidHeader,
6767+ /// JWK crypto operation failed.
6868+ Crypto,
6969+ /// Key type not supported for DPoP.
7070+ UnsupportedKey,
7171+ /// JSON serialization failed.
7272+ Serialization,
7373+}
7474+7575+impl fmt::Display for DpopErrorKind {
7676+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
7777+ match self {
7878+ DpopErrorKind::ProofBuild => write!(f, "DPoP proof construction failed"),
7979+ DpopErrorKind::Transport => write!(f, "HTTP request failed"),
8080+ DpopErrorKind::NonceRetry => write!(f, "request failed after nonce retry"),
8181+ DpopErrorKind::InvalidHeader => write!(f, "invalid header value"),
8282+ DpopErrorKind::Crypto => write!(f, "JWK crypto operation failed"),
8383+ DpopErrorKind::UnsupportedKey => write!(f, "unsupported key type"),
8484+ DpopErrorKind::Serialization => write!(f, "JSON serialization failed"),
8585+ }
8686+ }
8787+}
8888+8989+/// DPoP operation error with rich context.
9090+#[derive(Debug, miette::Diagnostic)]
9191+pub struct DpopError {
9292+ kind: DpopErrorKind,
9393+ target: Option<DpopTarget>,
9494+ url: Option<SmolStr>,
9595+ source: Option<BoxError>,
9696+ context: Option<SmolStr>,
9797+ #[help]
9898+ help: Option<&'static str>,
9999+}
100100+101101+impl fmt::Display for DpopError {
102102+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103103+ write!(f, "{}", self.kind)?;
104104+105105+ if let Some(target) = &self.target {
106106+ write!(f, " (to {})", target)?;
107107+ }
108108+109109+ if let Some(url) = &self.url {
110110+ write!(f, " [{}]", url)?;
111111+ }
112112+113113+ if let Some(ctx) = &self.context {
114114+ write!(f, ": {}", ctx)?;
115115+ }
116116+117117+ Ok(())
118118+ }
119119+}
120120+121121+impl StdError for DpopError {
122122+ fn source(&self) -> Option<&(dyn StdError + 'static)> {
123123+ self.source
124124+ .as_ref()
125125+ .map(|e| e.as_ref() as &(dyn StdError + 'static))
126126+ }
127127+}
128128+129129+impl DpopError {
130130+ /// Create a new error with the given kind.
131131+ fn new(kind: DpopErrorKind) -> Self {
132132+ Self {
133133+ kind,
134134+ target: None,
135135+ url: None,
136136+ source: None,
137137+ context: None,
138138+ help: None,
139139+ }
140140+ }
141141+142142+ /// Get the error kind.
143143+ pub fn kind(&self) -> DpopErrorKind {
144144+ self.kind
145145+ }
146146+147147+ /// Get the target server type, if known.
148148+ pub fn target(&self) -> Option<DpopTarget> {
149149+ self.target
150150+ }
151151+152152+ /// Get the URL, if known.
153153+ pub fn url(&self) -> Option<&str> {
154154+ self.url.as_deref()
155155+ }
156156+157157+ /// Get the context string, if any.
158158+ pub fn context(&self) -> Option<&str> {
159159+ self.context.as_deref()
160160+ }
161161+162162+ // Builder methods
163163+164164+ fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self {
165165+ self.source = Some(Box::new(source));
166166+ self
167167+ }
168168+169169+ fn with_target(mut self, target: DpopTarget) -> Self {
170170+ self.target = Some(target);
171171+ self
172172+ }
173173+174174+ fn with_url(mut self, url: impl Into<SmolStr>) -> Self {
175175+ self.url = Some(url.into());
176176+ self
177177+ }
178178+179179+ fn with_help(mut self, help: &'static str) -> Self {
180180+ self.help = Some(help);
181181+ self
182182+ }
183183+184184+ /// Add context information to the error.
185185+ pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
186186+ self.context = Some(context.into());
187187+ self
188188+ }
189189+190190+ /// Append additional context to the error.
191191+ pub fn append_context(mut self, additional: impl AsRef<str>) -> Self {
192192+ self.context = Some(match self.context.take() {
193193+ Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()),
194194+ None => SmolStr::new(additional.as_ref()),
195195+ });
196196+ self
197197+ }
198198+199199+ /// Add NSID context (for use by higher-level code).
200200+ pub fn for_nsid(self, nsid: &str) -> Self {
201201+ self.append_context(smol_str::format_smolstr!("[{}]", nsid))
202202+ }
203203+204204+ // Constructors for specific error kinds
205205+206206+ /// Create a proof build error.
207207+ pub fn proof_build(source: impl StdError + Send + Sync + 'static) -> Self {
208208+ Self::new(DpopErrorKind::ProofBuild)
209209+ .with_source(source)
210210+ .with_help("check that the DPoP key is valid and the JWT claims are correct")
211211+ }
212212+213213+ /// Create a transport error for initial request.
214214+ pub fn transport(
215215+ target: DpopTarget,
216216+ url: impl Into<SmolStr>,
217217+ source: impl StdError + Send + Sync + 'static,
218218+ ) -> Self {
219219+ Self::new(DpopErrorKind::Transport)
220220+ .with_target(target)
221221+ .with_url(url)
222222+ .with_source(source)
223223+ }
224224+225225+ /// Create a nonce retry error.
226226+ pub fn nonce_retry(
227227+ target: DpopTarget,
228228+ url: impl Into<SmolStr>,
229229+ source: impl StdError + Send + Sync + 'static,
230230+ ) -> Self {
231231+ Self::new(DpopErrorKind::NonceRetry)
232232+ .with_target(target)
233233+ .with_url(url)
234234+ .with_source(source)
235235+ .with_help(
236236+ "the server rejected both the initial request and the retry with updated nonce",
237237+ )
238238+ }
239239+240240+ /// Create an invalid header error.
241241+ pub fn invalid_header(source: InvalidHeaderValue) -> Self {
242242+ Self::new(DpopErrorKind::InvalidHeader)
243243+ .with_source(source)
244244+ .with_help("the DPoP proof could not be set as a header value")
245245+ }
246246+247247+ /// Create a crypto error.
248248+ pub fn crypto(source: crypto::Error) -> Self {
249249+ Self::new(DpopErrorKind::Crypto)
250250+ .with_context(format!("{:?}", source))
251251+ .with_help(
252252+ "ensure the key is a valid secret key in JWK format with a supported algorithm",
253253+ )
254254+ }
255255+256256+ /// Create an unsupported key error.
257257+ pub fn unsupported_key() -> Self {
258258+ Self::new(DpopErrorKind::UnsupportedKey)
259259+ .with_help("DPoP requires an EC P-256 key; other key types are not currently supported")
260260+ }
261261+262262+ /// Create a serialization error.
263263+ pub fn serialization(source: serde_json::Error) -> Self {
264264+ Self::new(DpopErrorKind::Serialization)
265265+ .with_source(source)
266266+ .with_help("failed to serialize JWT claims or header")
267267+ }
268268+}
269269+270270+impl From<InvalidHeaderValue> for DpopError {
271271+ fn from(e: InvalidHeaderValue) -> Self {
272272+ Self::invalid_header(e)
273273+ }
274274+}
275275+276276+impl From<serde_json::Error> for DpopError {
277277+ fn from(e: serde_json::Error) -> Self {
278278+ Self::serialization(e)
279279+ }
280280+}
281281+282282+impl From<DpopError> for jacquard_common::error::ClientError {
283283+ fn from(e: DpopError) -> Self {
284284+ use jacquard_common::error::{AuthError, ClientError};
285285+286286+ // Extract context from DpopError before converting
287287+ let kind = e.kind;
288288+ let url = e.url.clone();
289289+ let context = e.context.clone();
290290+ let target = e.target;
291291+292292+ // Build combined context string
293293+ let combined_context = match (target, context) {
294294+ (Some(t), Some(c)) => Some(smol_str::format_smolstr!("to {}: {}", t, c)),
295295+ (Some(t), None) => Some(smol_str::format_smolstr!("to {}", t)),
296296+ (None, Some(c)) => Some(c),
297297+ (None, None) => None,
298298+ };
299299+300300+ // Map DpopErrorKind to appropriate ClientError
301301+ let mut client_err = match kind {
302302+ DpopErrorKind::ProofBuild | DpopErrorKind::Crypto | DpopErrorKind::UnsupportedKey => {
303303+ ClientError::auth(AuthError::DpopProofFailed)
304304+ }
305305+ DpopErrorKind::NonceRetry => ClientError::auth(AuthError::DpopNonceFailed),
306306+ DpopErrorKind::Transport => ClientError::new(
307307+ jacquard_common::error::ClientErrorKind::Transport,
308308+ Some(Box::new(e)),
309309+ ),
310310+ DpopErrorKind::InvalidHeader | DpopErrorKind::Serialization => {
311311+ let msg = smol_str::format_smolstr!("DPoP: {:?}", kind);
312312+ ClientError::encode(msg)
313313+ }
314314+ };
315315+316316+ // Add URL if present (skip for Transport since e was consumed)
317317+ if !matches!(kind, DpopErrorKind::Transport) {
318318+ if let Some(u) = url {
319319+ client_err = client_err.with_url(u);
320320+ }
321321+ }
322322+323323+ // Add combined context if present (skip for Transport since e was consumed)
324324+ if !matches!(kind, DpopErrorKind::Transport) {
325325+ if let Some(ctx) = combined_context {
326326+ client_err = client_err.with_context(ctx);
327327+ }
328328+ }
329329+330330+ client_err
331331+ }
332332+}
333333+334334+type Result<T> = core::result::Result<T, DpopError>;
335335+336336+/// 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.
340340+#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
341341+pub trait DpopClient: HttpClient {
342342+ /// Send a DPoP-protected request to an authorization server (token endpoint, PAR, etc.).
343343+ fn dpop_server(
344344+ &self,
345345+ request: Request<Vec<u8>>,
346346+ ) -> impl Future<Output = Result<Response<Vec<u8>>>>;
347347+ /// Send a DPoP-protected request to a resource server (PDS, AppView, etc.).
348348+ fn dpop_client(
349349+ &self,
350350+ request: Request<Vec<u8>>,
351351+ ) -> impl Future<Output = Result<Response<Vec<u8>>>>;
352352+ /// Send a DPoP-protected request, inferring the target type from the request context.
353353+ fn wrap_request(
354354+ &self,
355355+ request: Request<Vec<u8>>,
356356+ ) -> impl Future<Output = Result<Response<Vec<u8>>>>;
357357+}
358358+359359+/// Extension trait for any [`HttpClient`] that adds builder methods for constructing
360360+/// DPoP-protected request calls without requiring a full [`DpopClient`] implementation.
361361+pub trait DpopExt: HttpClient {
362362+ /// Begin building a DPoP-protected request targeting an authorization server.
363363+ fn dpop_server_call<'r, D>(&'r self, data_source: &'r mut D) -> DpopCall<'r, Self, D>
364364+ where
365365+ Self: Sized,
366366+ D: DpopDataSource,
367367+ {
368368+ DpopCall::server(self, data_source)
369369+ }
370370+371371+ /// Begin building a DPoP-protected request targeting a resource server.
372372+ fn dpop_call<'r, N>(&'r self, data_source: &'r mut N) -> DpopCall<'r, Self, N>
373373+ where
374374+ Self: Sized,
375375+ N: DpopDataSource,
376376+ {
377377+ DpopCall::client(self, data_source)
378378+ }
379379+}
380380+381381+/// 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.
383383+pub struct DpopCall<'r, C: HttpClient, D: DpopDataSource> {
384384+ /// The HTTP client that will send the request.
385385+ 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.
390390+ pub is_to_auth_server: bool,
391391+ /// The session data source providing the DPoP key and current nonces.
392392+ pub data_source: &'r mut D,
393393+}
394394+395395+impl<'r, C: HttpClient, N: DpopDataSource> DpopCall<'r, C, N> {
396396+ /// Create a call builder targeting an authorization server.
397397+ pub fn server(client: &'r C, data_source: &'r mut N) -> Self {
398398+ Self {
399399+ client,
400400+ is_to_auth_server: true,
401401+ data_source,
402402+ }
403403+ }
404404+405405+ /// Create a call builder targeting a resource server.
406406+ pub fn client(client: &'r C, data_source: &'r mut N) -> Self {
407407+ Self {
408408+ client,
409409+ is_to_auth_server: false,
410410+ data_source,
411411+ }
412412+ }
413413+414414+ /// Send the request with a DPoP proof, retrying once if the server provides a new nonce.
415415+ pub async fn send(self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>> {
416416+ wrap_request_with_dpop(
417417+ self.client,
418418+ self.data_source,
419419+ self.is_to_auth_server,
420420+ request,
421421+ )
422422+ .await
423423+ }
424424+425425+ /// Sends the request with DPoP proof and returns a streaming response.
426426+ #[cfg(feature = "streaming")]
427427+ pub async fn send_streaming(
428428+ self,
429429+ request: Request<Vec<u8>>,
430430+ ) -> Result<jacquard_common::xrpc::StreamingResponse>
431431+ where
432432+ C: jacquard_common::http_client::HttpClientExt,
433433+ {
434434+ wrap_request_with_dpop_streaming(
435435+ self.client,
436436+ self.data_source,
437437+ self.is_to_auth_server,
438438+ request,
439439+ )
440440+ .await
441441+ }
442442+443443+ /// Sends the request with DPoP proof using bidirectional streaming.
444444+ #[cfg(feature = "streaming")]
445445+ pub async fn send_bidirectional(
446446+ self,
447447+ parts: http::request::Parts,
448448+ body: jacquard_common::stream::ByteStream,
449449+ ) -> Result<jacquard_common::xrpc::StreamingResponse>
450450+ where
451451+ C: jacquard_common::http_client::HttpClientExt,
452452+ {
453453+ wrap_request_with_dpop_bidirectional(
454454+ self.client,
455455+ self.data_source,
456456+ self.is_to_auth_server,
457457+ parts,
458458+ body,
459459+ )
460460+ .await
461461+ }
462462+}
463463+464464+/// Extract authorization hash from request headers
465465+fn extract_ath(headers: &http::HeaderMap) -> Option<CowStr<'static>> {
466466+ headers
467467+ .get("authorization")
468468+ .filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP ")))
469469+ .map(|auth| {
470470+ URL_SAFE_NO_PAD
471471+ .encode(sha2::Sha256::digest(&auth.as_bytes()[5..]))
472472+ .into()
473473+ })
474474+}
475475+476476+/// Get nonce from data source based on target
477477+fn get_nonce<N: DpopDataSource>(data_source: &N, is_to_auth_server: bool) -> Option<CowStr<'_>> {
478478+ if is_to_auth_server {
479479+ data_source.authserver_nonce()
480480+ } else {
481481+ data_source.host_nonce()
482482+ }
483483+}
484484+485485+/// Store nonce in data source based on target
486486+fn store_nonce<N: DpopDataSource>(
487487+ data_source: &mut N,
488488+ is_to_auth_server: bool,
489489+ nonce: CowStr<'static>,
490490+) {
491491+ if is_to_auth_server {
492492+ data_source.set_authserver_nonce(nonce);
493493+ } else {
494494+ data_source.set_host_nonce(nonce);
495495+ }
496496+}
497497+498498+/// 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.
503503+pub async fn wrap_request_with_dpop<T, N>(
504504+ client: &T,
505505+ data_source: &mut N,
506506+ is_to_auth_server: bool,
507507+ mut request: Request<Vec<u8>>,
508508+) -> Result<Response<Vec<u8>>>
509509+where
510510+ T: HttpClient,
511511+ N: DpopDataSource,
512512+{
513513+ let target = if is_to_auth_server {
514514+ DpopTarget::AuthServer
515515+ } else {
516516+ DpopTarget::ResourceServer
517517+ };
518518+ let uri = request.uri().clone();
519519+ let method = request.method().to_cowstr().into_static();
520520+ let url_str: SmolStr = uri.to_cowstr().as_ref().into();
521521+ let uri = uri.to_cowstr();
522522+ let ath = extract_ath(request.headers());
523523+524524+ let init_nonce = get_nonce(data_source, is_to_auth_server);
525525+ let init_proof = build_dpop_proof(
526526+ data_source.key(),
527527+ method.clone(),
528528+ uri.clone(),
529529+ init_nonce.clone(),
530530+ ath.clone(),
531531+ )?;
532532+ request.headers_mut().insert("DPoP", init_proof.parse()?);
533533+ let response = client
534534+ .send_http(request.clone())
535535+ .await
536536+ .map_err(|e| DpopError::transport(target, url_str.clone(), e))?;
537537+538538+ let next_nonce = response
539539+ .headers()
540540+ .get("dpop-nonce")
541541+ .and_then(|v| v.to_str().ok())
542542+ .map(|c| CowStr::copy_from_str(c));
543543+ match &next_nonce {
544544+ Some(s) if next_nonce != init_nonce => {
545545+ store_nonce(data_source, is_to_auth_server, s.clone());
546546+ }
547547+ _ => {
548548+ return Ok(response);
549549+ }
550550+ }
551551+552552+ if !is_use_dpop_nonce_error(is_to_auth_server, &response) {
553553+ return Ok(response);
554554+ }
555555+ let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?;
556556+ request.headers_mut().insert("DPoP", next_proof.parse()?);
557557+ let response = client
558558+ .send_http(request)
559559+ .await
560560+ .map_err(|e| DpopError::nonce_retry(target, url_str, e))?;
561561+ Ok(response)
562562+}
563563+564564+/// 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.
569569+#[cfg(feature = "streaming")]
570570+pub async fn wrap_request_with_dpop_streaming<T, N>(
571571+ client: &T,
572572+ data_source: &mut N,
573573+ is_to_auth_server: bool,
574574+ mut request: Request<Vec<u8>>,
575575+) -> Result<jacquard_common::xrpc::StreamingResponse>
576576+where
577577+ T: jacquard_common::http_client::HttpClientExt,
578578+ N: DpopDataSource,
579579+{
580580+ use jacquard_common::xrpc::StreamingResponse;
581581+582582+ let target = if is_to_auth_server {
583583+ DpopTarget::AuthServer
584584+ } else {
585585+ DpopTarget::ResourceServer
586586+ };
587587+ let uri = request.uri().clone();
588588+ let method = request.method().to_cowstr().into_static();
589589+ let url_str: SmolStr = uri.to_cowstr().as_ref().into();
590590+ let uri = uri.to_cowstr();
591591+ let ath = extract_ath(request.headers());
592592+593593+ let init_nonce = get_nonce(data_source, is_to_auth_server);
594594+ let init_proof = build_dpop_proof(
595595+ data_source.key(),
596596+ method.clone(),
597597+ uri.clone(),
598598+ init_nonce.clone(),
599599+ ath.clone(),
600600+ )?;
601601+ request.headers_mut().insert("DPoP", init_proof.parse()?);
602602+ let http_response = client
603603+ .send_http_streaming(request.clone())
604604+ .await
605605+ .map_err(|e| DpopError::transport(target, url_str.clone(), e))?;
606606+607607+ let (parts, body) = http_response.into_parts();
608608+ let next_nonce = parts
609609+ .headers
610610+ .get("DPoP-Nonce")
611611+ .and_then(|v| v.to_str().ok())
612612+ .map(|c| CowStr::from(c.to_string()));
613613+ match &next_nonce {
614614+ Some(s) if next_nonce != init_nonce => {
615615+ store_nonce(data_source, is_to_auth_server, s.clone());
616616+ }
617617+ _ => {
618618+ return Ok(StreamingResponse::new(parts, body));
619619+ }
620620+ }
621621+622622+ // For streaming responses, we can't easily check the body for use_dpop_nonce error
623623+ // We check status code + headers only
624624+ if !is_use_dpop_nonce_error_streaming(is_to_auth_server, parts.status, &parts.headers) {
625625+ return Ok(StreamingResponse::new(parts, body));
626626+ }
627627+628628+ let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?;
629629+ request.headers_mut().insert("DPoP", next_proof.parse()?);
630630+ let http_response = client
631631+ .send_http_streaming(request)
632632+ .await
633633+ .map_err(|e| DpopError::nonce_retry(target, url_str, e))?;
634634+ let (parts, body) = http_response.into_parts();
635635+ Ok(StreamingResponse::new(parts, body))
636636+}
637637+638638+/// 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.
642642+#[cfg(feature = "streaming")]
643643+pub async fn wrap_request_with_dpop_bidirectional<T, N>(
644644+ client: &T,
645645+ data_source: &mut N,
646646+ is_to_auth_server: bool,
647647+ mut parts: http::request::Parts,
648648+ body: jacquard_common::stream::ByteStream,
649649+) -> Result<jacquard_common::xrpc::StreamingResponse>
650650+where
651651+ T: jacquard_common::http_client::HttpClientExt,
652652+ N: DpopDataSource,
653653+{
654654+ use jacquard_common::xrpc::StreamingResponse;
655655+656656+ let target = if is_to_auth_server {
657657+ DpopTarget::AuthServer
658658+ } else {
659659+ DpopTarget::ResourceServer
660660+ };
661661+ let uri = parts.uri.clone();
662662+ let method = parts.method.to_cowstr().into_static();
663663+ let url_str: SmolStr = uri.to_cowstr().as_ref().into();
664664+ let uri = uri.to_cowstr();
665665+ let ath = extract_ath(&parts.headers);
666666+667667+ let init_nonce = get_nonce(data_source, is_to_auth_server);
668668+ let init_proof = build_dpop_proof(
669669+ data_source.key(),
670670+ method.clone(),
671671+ uri.clone(),
672672+ init_nonce.clone(),
673673+ ath.clone(),
674674+ )?;
675675+ parts.headers.insert("DPoP", init_proof.parse()?);
676676+677677+ // Clone the stream for potential retry
678678+ let (body1, body2) = body.tee();
679679+680680+ let http_response = client
681681+ .send_http_bidirectional(parts.clone(), body1.into_inner())
682682+ .await
683683+ .map_err(|e| DpopError::transport(target, url_str.clone(), e))?;
684684+685685+ let (resp_parts, resp_body) = http_response.into_parts();
686686+ let next_nonce = resp_parts
687687+ .headers
688688+ .get("DPoP-Nonce")
689689+ .and_then(|v| v.to_str().ok())
690690+ .map(|c| CowStr::from(c.to_string()));
691691+ match &next_nonce {
692692+ Some(s) if next_nonce != init_nonce => {
693693+ store_nonce(data_source, is_to_auth_server, s.clone());
694694+ }
695695+ _ => {
696696+ return Ok(StreamingResponse::new(resp_parts, resp_body));
697697+ }
698698+ }
699699+700700+ // For streaming responses, we can't easily check the body for use_dpop_nonce error
701701+ // We check status code + headers only
702702+ if !is_use_dpop_nonce_error_streaming(is_to_auth_server, resp_parts.status, &resp_parts.headers)
703703+ {
704704+ return Ok(StreamingResponse::new(resp_parts, resp_body));
705705+ }
706706+707707+ let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?;
708708+ parts.headers.insert("DPoP", next_proof.parse()?);
709709+ let http_response = client
710710+ .send_http_bidirectional(parts, body2.into_inner())
711711+ .await
712712+ .map_err(|e| DpopError::nonce_retry(target, url_str, e))?;
713713+ let (parts, body) = http_response.into_parts();
714714+ Ok(StreamingResponse::new(parts, body))
715715+}
716716+717717+#[cfg(feature = "streaming")]
718718+fn is_use_dpop_nonce_error_streaming(
719719+ is_to_auth_server: bool,
720720+ status: http::StatusCode,
721721+ headers: &http::HeaderMap,
722722+) -> bool {
723723+ if is_to_auth_server && status == 400 {
724724+ // Can't check body for streaming, so we rely on DPoP-Nonce header presence
725725+ return false;
726726+ }
727727+ if !is_to_auth_server && status == 401 {
728728+ if let Some(www_auth) = headers
729729+ .get("www-authenticate")
730730+ .and_then(|v| v.to_str().ok())
731731+ {
732732+ return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#);
733733+ }
734734+ }
735735+ false
736736+}
737737+738738+#[inline]
739739+fn is_use_dpop_nonce_error(is_to_auth_server: bool, response: &Response<Vec<u8>>) -> bool {
740740+ // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
741741+ if is_to_auth_server {
742742+ if response.status() == 400 {
743743+ if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) {
744744+ return res.error == "use_dpop_nonce";
745745+ };
746746+ }
747747+ }
748748+ // https://datatracker.ietf.org/doc/html/rfc6750#section-3
749749+ // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
750750+ else if response.status() == 401 {
751751+ if let Some(www_auth) = response
752752+ .headers()
753753+ .get("www-authenticate")
754754+ .and_then(|v| v.to_str().ok())
755755+ {
756756+ return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#);
757757+ }
758758+ }
759759+ false
760760+}
761761+762762+#[inline]
763763+pub(crate) fn generate_jti() -> CowStr<'static> {
764764+ let mut rng = rand::rngs::SmallRng::from_entropy();
765765+ let mut bytes = [0u8; 12];
766766+ rng.fill_bytes(&mut bytes);
767767+ URL_SAFE_NO_PAD.encode(bytes).into()
768768+}
769769+770770+/// Build a compact JWS (ES256) for DPoP with embedded public JWK.
771771+#[inline]
772772+pub fn build_dpop_proof<'s>(
773773+ key: &Key,
774774+ method: CowStr<'s>,
775775+ url: CowStr<'s>,
776776+ nonce: Option<CowStr<'s>>,
777777+ ath: Option<CowStr<'s>>,
778778+) -> Result<CowStr<'s>> {
779779+ let secret = match crypto::Key::try_from(key).map_err(DpopError::crypto)? {
780780+ crypto::Key::P256(crypto::Kind::Secret(sk)) => sk,
781781+ _ => return Err(DpopError::unsupported_key()),
782782+ };
783783+ let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
784784+ header.typ = Some(JWT_HEADER_TYP_DPOP.into());
785785+ header.jwk = Some(Jwk {
786786+ key: Key::from(&crypto::Key::from(secret.public_key())),
787787+ prm: Default::default(),
788788+ });
789789+790790+ let claims = Claims {
791791+ registered: RegisteredClaims {
792792+ jti: Some(generate_jti()),
793793+ iat: Some(Utc::now().timestamp()),
794794+ ..Default::default()
795795+ },
796796+ public: PublicClaims {
797797+ htm: Some(method),
798798+ htu: Some(url),
799799+ ath: ath,
800800+ nonce: nonce,
801801+ },
802802+ };
803803+ Ok(signing::create_signed_jwt_es256(
804804+ SigningKey::from(secret.clone()),
805805+ header.into(),
806806+ claims,
807807+ )?)
808808+}
809809+810810+impl DpopExt for JacquardResolver {}
+100
src-tauri/vendor/jacquard-oauth/src/error.rs
···11+use jacquard_common::session::SessionStoreError;
22+use miette::Diagnostic;
33+44+use crate::request::RequestError;
55+use crate::resolver::ResolverError;
66+77+/// High-level errors emitted by OAuth helpers.
88+#[derive(Debug, thiserror::Error, Diagnostic)]
99+#[non_exhaustive]
1010+pub enum OAuthError {
1111+ /// An error occurred during identity or metadata resolution.
1212+ #[error(transparent)]
1313+ #[diagnostic(code(jacquard_oauth::resolver))]
1414+ Resolver(#[from] ResolverError),
1515+1616+ /// An error occurred while making an OAuth HTTP request.
1717+ #[error(transparent)]
1818+ #[diagnostic(code(jacquard_oauth::request))]
1919+ Request(#[from] RequestError),
2020+2121+ /// An error occurred reading or writing session state.
2222+ #[error(transparent)]
2323+ #[diagnostic(code(jacquard_oauth::storage))]
2424+ Storage(#[from] SessionStoreError),
2525+2626+ /// An error occurred during DPoP proof generation or validation.
2727+ #[error(transparent)]
2828+ #[diagnostic(code(jacquard_oauth::dpop))]
2929+ Dpop(#[from] crate::dpop::DpopError),
3030+3131+ /// An error occurred with the client's key set.
3232+ #[error(transparent)]
3333+ #[diagnostic(code(jacquard_oauth::keyset))]
3434+ Keyset(#[from] crate::keyset::Error),
3535+3636+ /// An ATProto-specific OAuth error (e.g. scope validation, client ID).
3737+ #[error(transparent)]
3838+ #[diagnostic(code(jacquard_oauth::atproto))]
3939+ Atproto(#[from] crate::atproto::Error),
4040+4141+ /// An error occurred managing or refreshing an OAuth session.
4242+ #[error(transparent)]
4343+ #[diagnostic(code(jacquard_oauth::session))]
4444+ Session(#[from] crate::session::Error),
4545+4646+ /// A JSON serialization or deserialization error.
4747+ #[error(transparent)]
4848+ #[diagnostic(code(jacquard_oauth::serde_json))]
4949+ SerdeJson(#[from] serde_json::Error),
5050+5151+ /// A URI parse error.
5252+ #[error(transparent)]
5353+ #[diagnostic(code(jacquard_oauth::url))]
5454+ Url(#[from] jacquard_common::deps::fluent_uri::ParseError),
5555+5656+ /// A form (URL-encoded) serialization error.
5757+ #[error(transparent)]
5858+ #[diagnostic(code(jacquard_oauth::form))]
5959+ Form(#[from] serde_html_form::ser::Error),
6060+6161+ /// An error validating an authorization callback.
6262+ #[error(transparent)]
6363+ #[diagnostic(code(jacquard_oauth::callback))]
6464+ Callback(#[from] CallbackError),
6565+}
6666+6767+/// Typed callback validation errors (redirect handling).
6868+#[derive(Debug, thiserror::Error, Diagnostic)]
6969+#[non_exhaustive]
7070+pub 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.
7474+ #[error("missing state parameter in callback")]
7575+ #[diagnostic(code(jacquard_oauth::callback::missing_state))]
7676+ 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.
8181+ #[error("missing `iss` parameter")]
8282+ #[diagnostic(code(jacquard_oauth::callback::missing_iss))]
8383+ MissingIssuer,
8484+ /// The issuer in the callback did not match the expected authorization server.
8585+ #[error("issuer mismatch: expected {expected}, got {got}")]
8686+ #[diagnostic(code(jacquard_oauth::callback::issuer_mismatch))]
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.
9494+ #[error("timeout")]
9595+ #[diagnostic(code(jacquard_oauth::callback::timeout))]
9696+ Timeout,
9797+}
9898+9999+/// Convenience alias for `Result<T, OAuthError>`.
100100+pub type Result<T> = core::result::Result<T, OAuthError>;
+20
src-tauri/vendor/jacquard-oauth/src/jose.rs
···11+/// JWS (JSON Web Signature) header types.
22+pub mod jws;
33+/// JWT (JSON Web Token) claims types.
44+pub mod jwt;
55+/// Signed JWT creation for supported algorithms (ES256, ES384, ES256K, EdDSA).
66+pub mod signing;
77+88+use serde::{Deserialize, Serialize};
99+1010+/// 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.
1313+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1414+#[serde(untagged)]
1515+pub enum Header<'a> {
1616+ /// A JWS compact-serialization header.
1717+ #[serde(borrow)]
1818+ Jws(jws::Header<'a>),
1919+}
2020+
+97
src-tauri/vendor/jacquard-oauth/src/jose/jws.rs
···11+use jacquard_common::{CowStr, IntoStatic};
22+use jose_jwa::Algorithm;
33+use jose_jwk::Jwk;
44+use serde::{Deserialize, Serialize};
55+66+/// A JWS compact-serialization header, wrapping the registered header fields.
77+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88+pub struct Header<'a> {
99+ /// The registered header parameters defined by the JWS specification.
1010+ #[serde(flatten)]
1111+ #[serde(borrow)]
1212+ pub registered: RegisteredHeader<'a>,
1313+}
1414+1515+impl<'a> From<Header<'a>> for super::super::jose::Header<'a> {
1616+ fn from(header: Header<'a>) -> Self {
1717+ super::super::jose::Header::Jws(header)
1818+ }
1919+}
2020+2121+/// Registered JWS header parameters as defined in RFC 7515 §4.1.
2222+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2323+2424+pub struct RegisteredHeader<'a> {
2525+ /// The cryptographic algorithm used to sign the JWS (e.g., `ES256`).
2626+ pub alg: Algorithm,
2727+ /// JWK Set URL: a URI pointing to a resource containing the public key(s) used to sign the JWS.
2828+ #[serde(borrow)]
2929+ #[serde(skip_serializing_if = "Option::is_none")]
3030+ pub jku: Option<CowStr<'a>>,
3131+ /// JSON Web Key: the public key used to verify the JWS, embedded directly in the header.
3232+ #[serde(skip_serializing_if = "Option::is_none")]
3333+ pub jwk: Option<Jwk>,
3434+ /// Key ID: a hint indicating which key was used to sign the JWS.
3535+ #[serde(skip_serializing_if = "Option::is_none")]
3636+ 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.
3838+ #[serde(skip_serializing_if = "Option::is_none")]
3939+ pub x5u: Option<CowStr<'a>>,
4040+ /// X.509 certificate chain: the certificate (and chain) corresponding to the key used to sign the JWS.
4141+ #[serde(skip_serializing_if = "Option::is_none")]
4242+ pub x5c: Option<CowStr<'a>>,
4343+ /// X.509 certificate SHA-1 thumbprint: base64url-encoded SHA-1 digest of the DER-encoded certificate.
4444+ #[serde(skip_serializing_if = "Option::is_none")]
4545+ pub x5t: Option<CowStr<'a>>,
4646+ /// X.509 certificate SHA-256 thumbprint: base64url-encoded SHA-256 digest of the DER-encoded certificate.
4747+ #[serde(skip_serializing_if = "Option::is_none")]
4848+ #[serde(rename = "x5t#S256")]
4949+ pub x5ts256: Option<CowStr<'a>>,
5050+ /// Type: declares the media type of the complete JWS, used by applications to disambiguate among JOSe objects.
5151+ #[serde(skip_serializing_if = "Option::is_none")]
5252+ pub typ: Option<CowStr<'a>>,
5353+ /// Content type: declares the media type of the secured content (the payload).
5454+ #[serde(skip_serializing_if = "Option::is_none")]
5555+ pub cty: Option<CowStr<'a>>,
5656+}
5757+5858+impl From<Algorithm> for RegisteredHeader<'_> {
5959+ fn from(alg: Algorithm) -> Self {
6060+ Self {
6161+ alg,
6262+ jku: None,
6363+ jwk: None,
6464+ kid: None,
6565+ x5u: None,
6666+ x5c: None,
6767+ x5t: None,
6868+ x5ts256: None,
6969+ typ: None,
7070+ cty: None,
7171+ }
7272+ }
7373+}
7474+7575+impl<'a> From<RegisteredHeader<'a>> for super::super::jose::Header<'a> {
7676+ fn from(registered: RegisteredHeader<'a>) -> Self {
7777+ super::super::jose::Header::Jws(Header { registered })
7878+ }
7979+}
8080+8181+impl IntoStatic for RegisteredHeader<'_> {
8282+ type Output = RegisteredHeader<'static>;
8383+ fn into_static(self) -> Self::Output {
8484+ RegisteredHeader {
8585+ alg: self.alg,
8686+ jku: self.jku.map(IntoStatic::into_static),
8787+ jwk: self.jwk,
8888+ kid: self.kid.map(IntoStatic::into_static),
8989+ x5u: self.x5u.map(IntoStatic::into_static),
9090+ x5c: self.x5c.map(IntoStatic::into_static),
9191+ x5t: self.x5t.map(IntoStatic::into_static),
9292+ x5ts256: self.x5ts256.map(IntoStatic::into_static),
9393+ typ: self.typ.map(IntoStatic::into_static),
9494+ cty: self.cty.map(IntoStatic::into_static),
9595+ }
9696+ }
9797+}
+123
src-tauri/vendor/jacquard-oauth/src/jose/jwt.rs
···11+use jacquard_common::{CowStr, IntoStatic};
22+use serde::{Deserialize, Serialize};
33+44+/// Full JWT claims payload, combining registered and public (DPoP-specific) claims.
55+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
66+pub struct Claims<'a> {
77+ /// Standard registered JWT claims (iss, sub, aud, exp, etc.).
88+ #[serde(flatten)]
99+ pub registered: RegisteredClaims<'a>,
1010+ /// Public claims used in DPoP proofs (htm, htu, ath, nonce).
1111+ #[serde(flatten)]
1212+ #[serde(borrow)]
1313+ pub public: PublicClaims<'a>,
1414+}
1515+1616+/// Standard registered JWT claims as defined in RFC 7519 §4.1.
1717+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
1818+1919+pub struct RegisteredClaims<'a> {
2020+ /// Issuer: identifies the principal that issued the JWT.
2121+ #[serde(borrow)]
2222+ #[serde(skip_serializing_if = "Option::is_none")]
2323+ pub iss: Option<CowStr<'a>>,
2424+ /// Subject: identifies the principal that is the subject of the JWT.
2525+ #[serde(skip_serializing_if = "Option::is_none")]
2626+ pub sub: Option<CowStr<'a>>,
2727+ /// Audience: recipients that the JWT is intended for.
2828+ #[serde(skip_serializing_if = "Option::is_none")]
2929+ pub aud: Option<RegisteredClaimsAud<'a>>,
3030+ /// Expiration time (Unix timestamp): the JWT must not be accepted on or after this time.
3131+ #[serde(skip_serializing_if = "Option::is_none")]
3232+ pub exp: Option<i64>,
3333+ /// Not before (Unix timestamp): the JWT must not be accepted before this time.
3434+ #[serde(skip_serializing_if = "Option::is_none")]
3535+ pub nbf: Option<i64>,
3636+ /// Issued at (Unix timestamp): identifies when the JWT was created.
3737+ #[serde(skip_serializing_if = "Option::is_none")]
3838+ pub iat: Option<i64>,
3939+ /// JWT ID: unique identifier for the token, used to prevent replay attacks.
4040+ #[serde(skip_serializing_if = "Option::is_none")]
4141+ pub jti: Option<CowStr<'a>>,
4242+}
4343+4444+/// 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.
4848+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
4949+5050+pub struct PublicClaims<'a> {
5151+ /// HTTP method of the request the DPoP proof is bound to (e.g., `"POST"`).
5252+ #[serde(borrow)]
5353+ #[serde(skip_serializing_if = "Option::is_none")]
5454+ pub htm: Option<CowStr<'a>>,
5555+ /// HTTP target URI of the request the DPoP proof is bound to.
5656+ #[serde(skip_serializing_if = "Option::is_none")]
5757+ pub htu: Option<CowStr<'a>>,
5858+ /// Access token hash: base64url-encoded SHA-256 of the access token, binding the proof to a specific token.
5959+ #[serde(skip_serializing_if = "Option::is_none")]
6060+ pub ath: Option<CowStr<'a>>,
6161+ /// Server-provided nonce, included to prevent replay attacks when required by the authorization server.
6262+ #[serde(skip_serializing_if = "Option::is_none")]
6363+ pub nonce: Option<CowStr<'a>>,
6464+}
6565+6666+impl<'a> From<RegisteredClaims<'a>> for Claims<'a> {
6767+ fn from(registered: RegisteredClaims<'a>) -> Self {
6868+ Self {
6969+ registered,
7070+ public: PublicClaims::default(),
7171+ }
7272+ }
7373+}
7474+7575+/// The `aud` (audience) claim, which may be a single string or a list of strings per RFC 7519.
7676+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
7777+#[serde(untagged)]
7878+pub enum RegisteredClaimsAud<'a> {
7979+ /// A single audience identifier.
8080+ #[serde(borrow)]
8181+ Single(CowStr<'a>),
8282+ /// Multiple audience identifiers.
8383+ Multiple(Vec<CowStr<'a>>),
8484+}
8585+8686+impl IntoStatic for RegisteredClaims<'_> {
8787+ type Output = RegisteredClaims<'static>;
8888+ fn into_static(self) -> Self::Output {
8989+ RegisteredClaims {
9090+ iss: self.iss.map(IntoStatic::into_static),
9191+ sub: self.sub.map(IntoStatic::into_static),
9292+ aud: self.aud.map(IntoStatic::into_static),
9393+ exp: self.exp,
9494+ nbf: self.nbf,
9595+ iat: self.iat,
9696+ jti: self.jti.map(IntoStatic::into_static),
9797+ }
9898+ }
9999+}
100100+101101+impl IntoStatic for PublicClaims<'_> {
102102+ type Output = PublicClaims<'static>;
103103+ fn into_static(self) -> Self::Output {
104104+ PublicClaims {
105105+ htm: self.htm.map(IntoStatic::into_static),
106106+ htu: self.htu.map(IntoStatic::into_static),
107107+ ath: self.ath.map(IntoStatic::into_static),
108108+ nonce: self.nonce.map(IntoStatic::into_static),
109109+ }
110110+ }
111111+}
112112+113113+impl IntoStatic for RegisteredClaimsAud<'_> {
114114+ type Output = RegisteredClaimsAud<'static>;
115115+ fn into_static(self) -> Self::Output {
116116+ match self {
117117+ RegisteredClaimsAud::Single(s) => RegisteredClaimsAud::Single(s.into_static()),
118118+ RegisteredClaimsAud::Multiple(v) => {
119119+ RegisteredClaimsAud::Multiple(v.into_iter().map(IntoStatic::into_static).collect())
120120+ }
121121+ }
122122+ }
123123+}
···11+use base64::Engine;
22+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33+use jacquard_common::CowStr;
44+55+use super::{Header, jwt::Claims};
66+77+/// Builds the base64url-encoded `header.payload` signing input.
88+fn signing_input(header: &Header, claims: &Claims) -> serde_json::Result<(String, String)> {
99+ let h = URL_SAFE_NO_PAD.encode(serde_json::to_string(header)?);
1010+ let p = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims)?);
1111+ Ok((h, p))
1212+}
1313+1414+/// Assembles a compact JWS from pre-encoded parts and raw signature bytes.
1515+fn assemble(header: &str, payload: &str, sig: &[u8]) -> CowStr<'static> {
1616+ format!("{header}.{payload}.{}", URL_SAFE_NO_PAD.encode(sig)).into()
1717+}
1818+1919+/// Creates a compact-serialized signed JWT using ES256 (P-256 ECDSA with SHA-256).
2020+pub fn create_signed_jwt_es256(
2121+ key: p256::ecdsa::SigningKey,
2222+ header: Header,
2323+ claims: Claims,
2424+) -> serde_json::Result<CowStr<'static>> {
2525+ use p256::ecdsa::signature::Signer;
2626+ let (h, p) = signing_input(&header, &claims)?;
2727+ let sig: p256::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes());
2828+ Ok(assemble(&h, &p, &sig.to_bytes()))
2929+}
3030+3131+/// Creates a compact-serialized signed JWT using ES384 (P-384 ECDSA with SHA-384).
3232+pub fn create_signed_jwt_es384(
3333+ key: p384::ecdsa::SigningKey,
3434+ header: Header,
3535+ claims: Claims,
3636+) -> serde_json::Result<CowStr<'static>> {
3737+ use p384::ecdsa::signature::Signer;
3838+ let (h, p) = signing_input(&header, &claims)?;
3939+ let sig: p384::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes());
4040+ Ok(assemble(&h, &p, &sig.to_bytes()))
4141+}
4242+4343+/// Creates a compact-serialized signed JWT using ES256K (secp256k1 ECDSA with SHA-256).
4444+pub fn create_signed_jwt_es256k(
4545+ key: k256::ecdsa::SigningKey,
4646+ header: Header,
4747+ claims: Claims,
4848+) -> serde_json::Result<CowStr<'static>> {
4949+ use k256::ecdsa::signature::Signer;
5050+ let (h, p) = signing_input(&header, &claims)?;
5151+ let sig: k256::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes());
5252+ Ok(assemble(&h, &p, &sig.to_bytes()))
5353+}
5454+5555+/// Creates a compact-serialized signed JWT using EdDSA (Ed25519).
5656+pub fn create_signed_jwt_eddsa(
5757+ key: ed25519_dalek::SigningKey,
5858+ header: Header,
5959+ claims: Claims,
6060+) -> serde_json::Result<CowStr<'static>> {
6161+ use ed25519_dalek::Signer;
6262+ let (h, p) = signing_input(&header, &claims)?;
6363+ let sig = key.sign(format!("{h}.{p}").as_bytes());
6464+ Ok(assemble(&h, &p, &sig.to_bytes()))
6565+}
+257
src-tauri/vendor/jacquard-oauth/src/keyset.rs
···11+use crate::jose::jws::RegisteredHeader;
22+use crate::jose::jwt::Claims;
33+use crate::jose::signing;
44+use jacquard_common::CowStr;
55+use jose_jwa::{Algorithm, Signing};
66+use jose_jwk::{Class, EcCurves, OkpCurves, crypto};
77+use jose_jwk::{Jwk, JwkSet, Key};
88+use std::collections::HashSet;
99+use thiserror::Error;
1010+1111+/// Errors that can occur when constructing or using a [`Keyset`].
1212+#[derive(Error, Debug)]
1313+#[non_exhaustive]
1414+pub enum Error {
1515+ /// Two keys in the set share the same `kid`, which would make key selection ambiguous.
1616+ #[error("duplicate kid: {0}")]
1717+ DuplicateKid(String),
1818+ /// A keyset with no keys cannot sign anything.
1919+ #[error("keys must not be empty")]
2020+ EmptyKeys,
2121+ /// Each key must carry a `kid` so it can be referenced in JWS headers.
2222+ #[error("key at index {0} must have a `kid`")]
2323+ EmptyKid(usize),
2424+ /// No key in the set matches any of the requested signing algorithms.
2525+ #[error("no signing key found for algorithms: {0:?}")]
2626+ NotFound(Vec<Signing>),
2727+ /// Only secret (private) keys may be used for signing; a public key was provided.
2828+ #[error("key for signing must be a secret key")]
2929+ PublicKey,
3030+ /// The key type or curve is not supported for signing.
3131+ #[error("unsupported key type for signing")]
3232+ UnsupportedKey,
3333+ /// The private key (`d` parameter) is missing from the JWK.
3434+ #[error("missing private key material")]
3535+ MissingPrivateKey,
3636+ /// An error from the underlying JWK cryptographic operation.
3737+ #[error("crypto error: {0:?}")]
3838+ JwkCrypto(crypto::Error),
3939+ /// The raw key bytes have an invalid length or format.
4040+ #[error("invalid key material: {0}")]
4141+ InvalidKey(String),
4242+ /// JSON serialization of a JWT header or claims payload failed.
4343+ #[error(transparent)]
4444+ SerdeJson(#[from] serde_json::Error),
4545+}
4646+4747+/// Convenience result type for keyset operations.
4848+pub type Result<T> = core::result::Result<T, Error>;
4949+5050+/// Signing algorithm preference order for AT Protocol OAuth.
5151+///
5252+/// EdDSA and ES256K are preferred for their security properties, followed by
5353+/// the NIST curves. This order matches common AT Protocol server expectations.
5454+const PREFERRED_SIGNING_ALGORITHMS: [Signing; 4] = [
5555+ Signing::EdDsa,
5656+ Signing::Es256K,
5757+ Signing::Es256,
5858+ Signing::Es384,
5959+];
6060+6161+/// A validated collection of JWK secret keys used for signing DPoP proofs and client assertions.
6262+///
6363+/// Key selection follows [`PREFERRED_SIGNING_ALGORITHMS`] when multiple keys match.
6464+/// Supported algorithms: EdDSA (Ed25519), ES256K (secp256k1), ES256 (P-256), ES384 (P-384).
6565+#[derive(Clone, Debug, Default, PartialEq, Eq)]
6666+pub struct Keyset(Vec<Jwk>);
6767+6868+impl Keyset {
6969+ /// Returns a [`JwkSet`] containing the public halves of all keys in this keyset.
7070+ pub fn public_jwks(&self) -> JwkSet {
7171+ let mut keys = Vec::with_capacity(self.0.len());
7272+ for mut key in self.0.clone() {
7373+ match key.key {
7474+ Key::Ec(ref mut ec) => {
7575+ ec.d = None;
7676+ }
7777+ Key::Okp(ref mut okp) => {
7878+ okp.d = None;
7979+ }
8080+ _ => {}
8181+ }
8282+ keys.push(key);
8383+ }
8484+ JwkSet { keys }
8585+ }
8686+8787+ /// Signs a JWT with the best available key that matches one of the requested algorithms.
8888+ ///
8989+ /// Returns [`Error::NotFound`] if no key in the keyset supports any of the given algorithms.
9090+ pub fn create_jwt(&self, algs: &[Signing], claims: Claims) -> Result<CowStr<'static>> {
9191+ let Some(jwk) = self.find_key(algs, Class::Signing) else {
9292+ return Err(Error::NotFound(algs.to_vec()));
9393+ };
9494+ self.create_jwt_with_key(jwk, claims)
9595+ }
9696+9797+ fn find_key(&self, algs: &[Signing], cls: Class) -> Option<&Jwk> {
9898+ let candidates = self
9999+ .0
100100+ .iter()
101101+ .filter_map(|key| {
102102+ if key.prm.cls.is_some_and(|c| c != cls) {
103103+ return None;
104104+ }
105105+ let alg = alg_for_key(&key.key)?;
106106+ Some((alg, key)).filter(|(alg, _)| algs.contains(alg))
107107+ })
108108+ .collect::<Vec<_>>();
109109+ for pref_alg in PREFERRED_SIGNING_ALGORITHMS {
110110+ for (alg, key) in &candidates {
111111+ if *alg == pref_alg {
112112+ return Some(key);
113113+ }
114114+ }
115115+ }
116116+ None
117117+ }
118118+119119+ fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result<CowStr<'static>> {
120120+ let kid = key.prm.kid.clone().unwrap();
121121+ match &key.key {
122122+ Key::Ec(ec) => {
123123+ let d = ec.d.as_ref().ok_or(Error::MissingPrivateKey)?;
124124+ let d_bytes: &[u8] = d.as_ref();
125125+ match ec.crv {
126126+ EcCurves::P256 => {
127127+ let signing_key = p256::ecdsa::SigningKey::from_bytes(d_bytes.into())
128128+ .map_err(|e| Error::InvalidKey(e.to_string()))?;
129129+ let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
130130+ header.kid = Some(kid.into());
131131+ Ok(signing::create_signed_jwt_es256(
132132+ signing_key,
133133+ header.into(),
134134+ claims,
135135+ )?)
136136+ }
137137+ EcCurves::P384 => {
138138+ let signing_key = p384::ecdsa::SigningKey::from_bytes(d_bytes.into())
139139+ .map_err(|e| Error::InvalidKey(e.to_string()))?;
140140+ let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es384));
141141+ header.kid = Some(kid.into());
142142+ Ok(signing::create_signed_jwt_es384(
143143+ signing_key,
144144+ header.into(),
145145+ claims,
146146+ )?)
147147+ }
148148+ EcCurves::P256K => {
149149+ let signing_key = k256::ecdsa::SigningKey::from_bytes(d_bytes.into())
150150+ .map_err(|e| Error::InvalidKey(e.to_string()))?;
151151+ let mut header =
152152+ RegisteredHeader::from(Algorithm::Signing(Signing::Es256K));
153153+ header.kid = Some(kid.into());
154154+ Ok(signing::create_signed_jwt_es256k(
155155+ signing_key,
156156+ header.into(),
157157+ claims,
158158+ )?)
159159+ }
160160+ _ => Err(Error::UnsupportedKey),
161161+ }
162162+ }
163163+ Key::Okp(okp) => match okp.crv {
164164+ OkpCurves::Ed25519 => {
165165+ let d = okp.d.as_ref().ok_or(Error::MissingPrivateKey)?;
166166+ let d_bytes: &[u8] = d.as_ref();
167167+ let signing_key = ed25519_dalek::SigningKey::try_from(d_bytes)
168168+ .map_err(|e| Error::InvalidKey(e.to_string()))?;
169169+ let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::EdDsa));
170170+ header.kid = Some(kid.into());
171171+ Ok(signing::create_signed_jwt_eddsa(
172172+ signing_key,
173173+ header.into(),
174174+ claims,
175175+ )?)
176176+ }
177177+ _ => Err(Error::UnsupportedKey),
178178+ },
179179+ _ => Err(Error::UnsupportedKey),
180180+ }
181181+ }
182182+}
183183+184184+/// Returns the signing algorithm for the given JWK key type, if supported.
185185+fn alg_for_key(key: &Key) -> Option<Signing> {
186186+ match key {
187187+ Key::Ec(ec) => match ec.crv {
188188+ EcCurves::P256 => Some(Signing::Es256),
189189+ EcCurves::P384 => Some(Signing::Es384),
190190+ EcCurves::P256K => Some(Signing::Es256K),
191191+ _ => None,
192192+ },
193193+ Key::Okp(okp) => match okp.crv {
194194+ OkpCurves::Ed25519 => Some(Signing::EdDsa),
195195+ _ => None,
196196+ },
197197+ _ => None,
198198+ }
199199+}
200200+201201+/// Parses a string-based algorithm name into a [`Signing`] variant, if it maps to
202202+/// an algorithm this crate supports.
203203+pub fn parse_signing_alg(s: &str) -> Option<Signing> {
204204+ match s {
205205+ "ES256" => Some(Signing::Es256),
206206+ "ES384" => Some(Signing::Es384),
207207+ "ES256K" => Some(Signing::Es256K),
208208+ "EdDSA" => Some(Signing::EdDsa),
209209+ _ => None,
210210+ }
211211+}
212212+213213+impl TryFrom<Vec<Jwk>> for Keyset {
214214+ type Error = Error;
215215+216216+ fn try_from(keys: Vec<Jwk>) -> Result<Self> {
217217+ if keys.is_empty() {
218218+ return Err(Error::EmptyKeys);
219219+ }
220220+ let mut v = Vec::with_capacity(keys.len());
221221+ let mut hs = HashSet::with_capacity(keys.len());
222222+ for (i, key) in keys.into_iter().enumerate() {
223223+ if let Some(kid) = key.prm.kid.clone() {
224224+ if hs.contains(&kid) {
225225+ return Err(Error::DuplicateKid(kid));
226226+ }
227227+ hs.insert(kid);
228228+229229+ // Validate that the key has private material and is a supported type.
230230+ match &key.key {
231231+ Key::Ec(ec) => {
232232+ if ec.d.is_none() {
233233+ return Err(Error::PublicKey);
234234+ }
235235+ if alg_for_key(&key.key).is_none() {
236236+ return Err(Error::UnsupportedKey);
237237+ }
238238+ }
239239+ Key::Okp(okp) => {
240240+ if okp.d.is_none() {
241241+ return Err(Error::PublicKey);
242242+ }
243243+ if alg_for_key(&key.key).is_none() {
244244+ return Err(Error::UnsupportedKey);
245245+ }
246246+ }
247247+ _ => return Err(Error::UnsupportedKey),
248248+ }
249249+250250+ v.push(key);
251251+ } else {
252252+ return Err(Error::EmptyKid(i));
253253+ }
254254+ }
255255+ Ok(Self(v))
256256+ }
257257+}
+82
src-tauri/vendor/jacquard-oauth/src/lib.rs
···11+//! # Jacquard OAuth 2.1 implementation for the AT Protocol
22+//!
33+//! Implements the AT Protocol OAuth profile, including DPoP (Demonstrating
44+//! Proof-of-Possession), PKCE, PAR (Pushed Authorization Requests), and token management.
55+//!
66+//!
77+//! ## Authentication flow
88+//!
99+//! ```no_run
1010+//! # #[cfg(feature = "loopback")]
1111+//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1212+//! use jacquard_oauth::client::OAuthClient;
1313+//! use jacquard_oauth::session::ClientData;
1414+//! use jacquard_oauth::atproto::AtprotoClientMetadata;
1515+//! use jacquard_oauth::loopback::LoopbackConfig;
1616+//! use jacquard_oauth::authstore::MemoryAuthStore;
1717+//!
1818+//! let store = MemoryAuthStore::new();
1919+//!
2020+//! // Create client with metadata
2121+//! let client_data = ClientData {
2222+//! keyset: None, // Will generate ES256 keypair if needed
2323+//! config: AtprotoClientMetadata::default_localhost(),
2424+//! };
2525+//! let oauth = OAuthClient::new(store, client_data);
2626+//!
2727+//! // Start auth flow (with loopback feature)
2828+//! let session = oauth.login_with_local_server(
2929+//! "alice.bsky.social",
3030+//! Default::default(),
3131+//! LoopbackConfig::default(),
3232+//! ).await?;
3333+//!
3434+//! // Session handles token refresh automatically
3535+//! # Ok(())
3636+//! # }
3737+//! ```
3838+//!
3939+//! ## AT Protocol specifics
4040+//!
4141+//! The AT Protocol OAuth profile adds:
4242+//! - Required DPoP for all token requests
4343+//! - PAR (Pushed Authorization Requests) for better security
4444+//! - Specific scope format (`atproto`, `transition:generic`, etc.)
4545+//! - Server metadata discovery at `/.well-known/oauth-authorization-server`
4646+//!
4747+//! See [`atproto`] module for AT Protocol-specific metadata helpers.
4848+4949+#![warn(missing_docs)]
5050+/// AT Protocol-specific OAuth client metadata helpers and builder types.
5151+pub mod atproto;
5252+/// Storage trait and in-memory implementation for OAuth client auth state.
5353+pub mod authstore;
5454+/// High-level OAuth client for driving the full authorization code flow.
5555+pub mod client;
5656+/// DPoP (Demonstrating Proof-of-Possession) key generation and request signing.
5757+pub mod dpop;
5858+/// Top-level OAuth error types for the authorization flow.
5959+pub mod error;
6060+/// JOSE primitives: JWS headers, JWT claims, and signing utilities.
6161+pub mod jose;
6262+/// JWK keyset management for signing keys used in DPoP and client auth.
6363+pub mod keyset;
6464+/// Low-level OAuth request helpers: PAR, token exchange, and refresh.
6565+pub mod request;
6666+/// OAuth server metadata resolution: authorization server and protected resource discovery.
6767+pub mod resolver;
6868+///
6969+pub mod scopes;
7070+/// OAuth session types, token storage, and DPoP session state.
7171+pub mod session;
7272+/// OAuth protocol types: client metadata, token sets, and server metadata.
7373+pub mod types;
7474+/// Miscellaneous cryptographic utilities: key generation, PKCE, and hashing helpers.
7575+pub mod utils;
7676+7777+/// Fallback signing algorithm used when no preferred algorithm is negotiated with the server.
7878+pub const FALLBACK_ALG: &str = "ES256";
7979+8080+/// Loopback server helpers for the local redirect-based OAuth flow.
8181+#[cfg(feature = "loopback")]
8282+pub mod loopback;
+273
src-tauri/vendor/jacquard-oauth/src/loopback.rs
···11+//!
22+//! Helpers for the local loopback server method of atproto OAuth.
33+//!
44+//! `OAuthClient::login_with_local_server()` is the nice helper. Here is where
55+//! it and its components live. Below is what it does, so you can have more
66+//! granular control without having to make your own loopback server.
77+//!
88+//! ```ignore
99+//! let input = "your_handle_here";
1010+//! let cfg = LoopbackConfig::default();
1111+//! let opts = AuthorizeOptions::default();
1212+//! let port = match cfg.port {
1313+//! LoopbackPort::Fixed(p) => p,
1414+//! LoopbackPort::Ephemeral => 0,
1515+//! };
1616+//! // TODO: fix this to it also accepts ipv6 and properly finds a free port
1717+//! let bind_addr: SocketAddr = format!("0.0.0.0:{}", port)
1818+//! .parse()
1919+//! .expect("invalid loopback host/port");
2020+//! let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
2121+//!
2222+//! let (local_addr, handle) = one_shot_server(bind_addr);
2323+//! println!("Listening on {}", local_addr);
2424+//!
2525+//! let client_data = oauth.build_localhost_client_data(&cfg, &opts, local_addr);
2626+//! // Build client using store and resolver
2727+//! let flow_client = OAuthClient::new_with_shared(
2828+//! self.registry.store.clone(),
2929+//! self.client.clone(),
3030+//! client_data,
3131+//! );
3232+//!
3333+//! // Start auth and get authorization URL
3434+//! let auth_url = flow_client.start_auth(input.as_ref(), opts).await?;
3535+//! // Print URL for copy/paste
3636+//! println!("To authenticate with your PDS, visit:\n{}\n", auth_url);
3737+//! // Optionally open browser
3838+//! if cfg.open_browser {
3939+//! let _ = try_open_in_browser(&auth_url);
4040+//! }
4141+//!
4242+//! handle_localhost_callback(handle, &flow_client, &cfg).await
4343+//! ```
4444+//!
4545+//!
4646+#![cfg(feature = "loopback")]
4747+use crate::{
4848+ atproto::AtprotoClientMetadata,
4949+ authstore::ClientAuthStore,
5050+ client::OAuthClient,
5151+ dpop::DpopExt,
5252+ error::{CallbackError, OAuthError},
5353+ resolver::OAuthResolver,
5454+ types::{AuthorizeOptions, CallbackParams},
5555+};
5656+use jacquard_common::deps::fluent_uri::Uri;
5757+use jacquard_common::{IntoStatic, cowstr::ToCowStr};
5858+use rouille::Server;
5959+use std::net::SocketAddr;
6060+use tokio::sync::mpsc;
6161+6262+/// Port selection strategy for the loopback OAuth callback server.
6363+#[derive(Clone, Debug)]
6464+pub enum LoopbackPort {
6565+ /// Bind to a specific port number.
6666+ Fixed(u16),
6767+ /// Let the OS assign an available port.
6868+ Ephemeral,
6969+}
7070+7171+/// Configuration for the loopback OAuth callback server.
7272+#[derive(Clone, Debug)]
7373+pub struct LoopbackConfig {
7474+ /// The host address to bind to (e.g., `"127.0.0.1"`).
7575+ pub host: String,
7676+ /// Port selection strategy.
7777+ pub port: LoopbackPort,
7878+ /// Whether to attempt opening the authorization URL in the user's browser.
7979+ pub open_browser: bool,
8080+ /// How long to wait for the callback before timing out, in milliseconds.
8181+ pub timeout_ms: u64,
8282+}
8383+8484+impl Default for LoopbackConfig {
8585+ fn default() -> Self {
8686+ Self {
8787+ host: "127.0.0.1".into(),
8888+ port: LoopbackPort::Fixed(4000),
8989+ open_browser: true,
9090+ timeout_ms: 5 * 60 * 1000,
9191+ }
9292+ }
9393+}
9494+9595+/// Attempts to open the given URL in the user's default browser.
9696+///
9797+/// Returns `true` if the browser was opened successfully, `false` otherwise.
9898+#[cfg(feature = "browser-open")]
9999+pub fn try_open_in_browser(url: &str) -> bool {
100100+ webbrowser::open(url).is_ok()
101101+}
102102+/// Stub for when the `browser-open` feature is disabled. Always returns `false`.
103103+#[cfg(not(feature = "browser-open"))]
104104+pub fn try_open_in_browser(_url: &str) -> bool {
105105+ false
106106+}
107107+108108+fn create_callback_router(
109109+ request: &rouille::Request,
110110+ tx: mpsc::Sender<CallbackParams>,
111111+) -> rouille::Response {
112112+ rouille::router!(request,
113113+ (GET) (/oauth/callback) => {
114114+ let state = request.get_param("state").unwrap();
115115+ let code = request.get_param("code").unwrap();
116116+ let iss = request.get_param("iss").unwrap();
117117+ let callback_params = CallbackParams {
118118+ state: Some(state.to_cowstr().into_static()),
119119+ code: code.to_cowstr().into_static(),
120120+ iss: Some(iss.to_cowstr().into_static()),
121121+ };
122122+ tx.try_send(callback_params).unwrap();
123123+ rouille::Response::text("Logged in!")
124124+ },
125125+ _ => rouille::Response::empty_404()
126126+ )
127127+}
128128+129129+/// Handle to a running loopback callback server, used to await the OAuth redirect.
130130+pub struct CallbackHandle {
131131+ #[allow(dead_code)]
132132+ server_handle: std::thread::JoinHandle<()>,
133133+ server_stop: std::sync::mpsc::Sender<()>,
134134+ callback_rx: mpsc::Receiver<CallbackParams<'static>>,
135135+}
136136+137137+/// One-shot OAuth callback server.
138138+///
139139+/// Starts an ephemeral in-process web server that listens for the OAuth
140140+/// callback redirect. Returns the server address and a [`CallbackHandle`]
141141+/// that can be used to wait for the callback and stop the server.
142142+///
143143+/// Use in combination with [`handle_localhost_callback`] to handle the
144144+/// callback for the localhost loopback server.
145145+pub fn one_shot_server(addr: SocketAddr) -> (SocketAddr, CallbackHandle) {
146146+ let (tx, callback_rx) = mpsc::channel(5);
147147+ let server = Server::new(addr, move |request| {
148148+ create_callback_router(request, tx.clone())
149149+ })
150150+ .expect("Could not start server");
151151+ let (server_handle, server_stop) = server.stoppable();
152152+ let handle = CallbackHandle {
153153+ server_handle,
154154+ server_stop,
155155+ callback_rx,
156156+ };
157157+ (addr, handle)
158158+}
159159+160160+/// Handles the OAuth callback for the localhost loopback server.
161161+///
162162+/// Returns a session if the callback succeeds within the configured timeout
163163+/// and shuts down the server.
164164+pub async fn handle_localhost_callback<T, S>(
165165+ handle: CallbackHandle,
166166+ flow_client: &super::client::OAuthClient<T, S>,
167167+ cfg: &LoopbackConfig,
168168+) -> crate::error::Result<super::client::OAuthSession<T, S>>
169169+where
170170+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
171171+ S: ClientAuthStore + Send + Sync + 'static,
172172+{
173173+ // Await callback or timeout
174174+ let mut callback_rx = handle.callback_rx;
175175+ let cb = tokio::time::timeout(
176176+ std::time::Duration::from_millis(cfg.timeout_ms),
177177+ callback_rx.recv(),
178178+ )
179179+ .await;
180180+ // trigger shutdown
181181+ let _ = handle.server_stop.send(());
182182+ if let Ok(Some(cb)) = cb {
183183+ // Handle callback and create a session
184184+ Ok(flow_client.callback(cb).await?)
185185+ } else {
186186+ Err(OAuthError::Callback(CallbackError::Timeout))
187187+ }
188188+}
189189+190190+impl<T, S> OAuthClient<T, S>
191191+where
192192+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
193193+ S: ClientAuthStore + Send + Sync + 'static,
194194+{
195195+ /// Drive the full OAuth flow using a local loopback server.
196196+ ///
197197+ /// This uses localhost OAuth and an ephemeral in-process web server to
198198+ /// handle the OAuth callback redirect. It has a bunch of nice friendly
199199+ /// defaults to help you get started and will basically drive the *entire*
200200+ /// callback flow itself.
201201+ ///
202202+ /// Best used for development and for small CLI applications that don't
203203+ /// require long session lengths. For long-running unattended sessions,
204204+ /// app passwords (via CredentialSession in the jacquard crate) remain
205205+ /// the best option. For more complex OAuth, or if you want more control
206206+ /// over the process, use the other methods on OAuthClient.
207207+ ///
208208+ /// 'input' parameter is what you type in the login box (usually, your handle)
209209+ /// for it to look up your PDS and redirect to its authentication interface.
210210+ ///
211211+ /// If the `browser-open` feature is enabled, this will open a web browser
212212+ /// for you to authenticate with your PDS. It will also print the
213213+ /// callback url to the console for you to copy.
214214+ pub async fn login_with_local_server(
215215+ &self,
216216+ input: impl AsRef<str>,
217217+ opts: AuthorizeOptions<'_>,
218218+ cfg: LoopbackConfig,
219219+ ) -> crate::error::Result<super::client::OAuthSession<T, S>> {
220220+ let port = match cfg.port {
221221+ LoopbackPort::Fixed(p) => p,
222222+ LoopbackPort::Ephemeral => 0,
223223+ };
224224+ // TODO: fix this to it also accepts ipv6 and properly finds a free port
225225+ let bind_addr: SocketAddr = format!("0.0.0.0:{}", port)
226226+ .parse()
227227+ .expect("invalid loopback host/port");
228228+ let (local_addr, handle) = one_shot_server(bind_addr);
229229+ println!("Listening on {}", local_addr);
230230+231231+ let client_data = self.build_localhost_client_data(&cfg, &opts, local_addr);
232232+ // Build client using store and resolver
233233+ let flow_client = OAuthClient::new_with_shared(
234234+ self.registry.store.clone(),
235235+ self.client.clone(),
236236+ client_data,
237237+ );
238238+239239+ // Start auth and get authorization URL
240240+ let auth_url = flow_client.start_auth(input.as_ref(), opts).await?;
241241+ // Print URL for copy/paste
242242+ println!("To authenticate with your PDS, visit:\n{}\n", auth_url);
243243+ // Optionally open browser
244244+ if cfg.open_browser {
245245+ let _ = try_open_in_browser(&auth_url);
246246+ }
247247+248248+ handle_localhost_callback(handle, &flow_client, &cfg).await
249249+ }
250250+251251+ /// Builds a [`crate::session::ClientData`] for use with the local loopback server method of OAuth.
252252+ pub fn build_localhost_client_data(
253253+ &self,
254254+ cfg: &LoopbackConfig,
255255+ opts: &AuthorizeOptions<'_>,
256256+ local_addr: SocketAddr,
257257+ ) -> crate::session::ClientData<'static> {
258258+ let redirect_uri = format!("http://{}:{}/oauth/callback", cfg.host, local_addr.port(),);
259259+ let redirect = Uri::parse(redirect_uri).unwrap();
260260+261261+ let scopes = if opts.scopes.is_empty() {
262262+ Some(self.registry.client_data.config.scopes.clone())
263263+ } else {
264264+ Some(opts.scopes.clone().into_static())
265265+ };
266266+267267+ crate::session::ClientData {
268268+ keyset: self.registry.client_data.keyset.clone(),
269269+ config: AtprotoClientMetadata::new_localhost(Some(vec![redirect]), scopes),
270270+ }
271271+ .into_static()
272272+ }
273273+}
+1116
src-tauri/vendor/jacquard-oauth/src/request.rs
···11+use chrono::{TimeDelta, Utc};
22+use http::{Method, Request, StatusCode};
33+use jacquard_common::{
44+ CowStr, IntoStatic,
55+ cowstr::ToCowStr,
66+ http_client::HttpClient,
77+ session::SessionStoreError,
88+ types::{
99+ did::Did,
1010+ string::{AtStrError, Datetime},
1111+ },
1212+};
1313+use jacquard_identity::resolver::IdentityError;
1414+use serde::Serialize;
1515+use serde_json::Value;
1616+use smol_str::ToSmolStr;
1717+1818+use jose_jwa::Signing;
1919+2020+use crate::{
2121+ FALLBACK_ALG,
2222+ atproto::atproto_client_metadata,
2323+ dpop::DpopExt,
2424+ jose::jwt::{RegisteredClaims, RegisteredClaimsAud},
2525+ keyset::Keyset,
2626+ resolver::OAuthResolver,
2727+ scopes::Scope,
2828+ session::{
2929+ AuthRequestData, ClientData, ClientSessionData, DpopClientData, DpopDataSource, DpopReqData,
3030+ },
3131+ types::{
3232+ AuthorizationCodeChallengeMethod, AuthorizationResponseType, AuthorizeOptionPrompt,
3333+ OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthParResponse,
3434+ OAuthTokenResponse, ParParameters, RefreshRequestParameters, RevocationRequestParameters,
3535+ TokenGrantType, TokenRequestParameters, TokenSet,
3636+ },
3737+ utils::{compare_algos, generate_dpop_key, generate_nonce, generate_pkce},
3838+};
3939+4040+// https://datatracker.ietf.org/doc/html/rfc7523#section-2.2
4141+const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
4242+ "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
4343+4444+use smol_str::SmolStr;
4545+4646+/// Convenience alias for a heap-allocated, thread-safe, `'static` error value.
4747+pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
4848+4949+/// OAuth request error for token operations and auth flows
5050+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
5151+#[error("{kind}")]
5252+pub struct RequestError {
5353+ #[diagnostic_source]
5454+ kind: RequestErrorKind,
5555+ #[source]
5656+ source: Option<BoxError>,
5757+ #[help]
5858+ help: Option<SmolStr>,
5959+ context: Option<SmolStr>,
6060+ url: Option<SmolStr>,
6161+ details: Option<SmolStr>,
6262+ location: Option<SmolStr>,
6363+}
6464+6565+/// Error categories for OAuth request operations
6666+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
6767+#[non_exhaustive]
6868+pub enum RequestErrorKind {
6969+ /// No endpoint available
7070+ #[error("no {0} endpoint available")]
7171+ #[diagnostic(
7272+ code(jacquard_oauth::request::no_endpoint),
7373+ help("server does not advertise this endpoint")
7474+ )]
7575+ NoEndpoint(SmolStr),
7676+7777+ /// Token response verification failed
7878+ #[error("token response verification failed")]
7979+ #[diagnostic(code(jacquard_oauth::request::token_verification))]
8080+ TokenVerification,
8181+8282+ /// Unsupported authentication method
8383+ #[error("unsupported authentication method")]
8484+ #[diagnostic(
8585+ code(jacquard_oauth::request::unsupported_auth_method),
8686+ help(
8787+ "server must support `private_key_jwt` or `none`; configure client metadata accordingly"
8888+ )
8989+ )]
9090+ UnsupportedAuthMethod,
9191+9292+ /// No refresh token available
9393+ #[error("no refresh token available")]
9494+ #[diagnostic(code(jacquard_oauth::request::no_refresh_token))]
9595+ NoRefreshToken,
9696+9797+ /// Invalid DID
9898+ #[error("failed to parse DID")]
9999+ #[diagnostic(code(jacquard_oauth::request::invalid_did))]
100100+ InvalidDid,
101101+102102+ /// DPoP client error
103103+ #[error("dpop error")]
104104+ #[diagnostic(code(jacquard_oauth::request::dpop))]
105105+ Dpop,
106106+107107+ /// Session storage error
108108+ #[error("storage error")]
109109+ #[diagnostic(code(jacquard_oauth::request::storage))]
110110+ Storage,
111111+112112+ /// Resolver error
113113+ #[error("resolver error")]
114114+ #[diagnostic(code(jacquard_oauth::request::resolver))]
115115+ Resolver,
116116+117117+ /// HTTP build error
118118+ #[error("http build error")]
119119+ #[diagnostic(code(jacquard_oauth::request::http_build))]
120120+ HttpBuild,
121121+122122+ /// HTTP status error
123123+ #[error("http status: {0}")]
124124+ #[diagnostic(
125125+ code(jacquard_oauth::request::http_status),
126126+ help("see server response for details")
127127+ )]
128128+ HttpStatus(StatusCode),
129129+130130+ /// HTTP status with error body
131131+ #[error("http status: {status}, body: {body:?}")]
132132+ #[diagnostic(
133133+ code(jacquard_oauth::request::http_status_body),
134134+ help("server returned error JSON; inspect fields like `error`, `error_description`")
135135+ )]
136136+ HttpStatusWithBody {
137137+ /// HTTP status code returned by the server.
138138+ status: StatusCode,
139139+ /// Parsed JSON body containing OAuth error fields such as `error` and `error_description`.
140140+ body: Value,
141141+ },
142142+143143+ /// Identity resolution error
144144+ #[error("identity error")]
145145+ #[diagnostic(code(jacquard_oauth::request::identity))]
146146+ Identity,
147147+148148+ /// Keyset error
149149+ #[error("keyset error")]
150150+ #[diagnostic(code(jacquard_oauth::request::keyset))]
151151+ Keyset,
152152+153153+ /// Form serialization error
154154+ #[error("form serialization error")]
155155+ #[diagnostic(code(jacquard_oauth::request::serde_form))]
156156+ SerdeHtmlForm,
157157+158158+ /// JSON error
159159+ #[error("json error")]
160160+ #[diagnostic(code(jacquard_oauth::request::serde_json))]
161161+ SerdeJson,
162162+163163+ /// Atproto metadata error
164164+ #[error("atproto error")]
165165+ #[diagnostic(code(jacquard_oauth::request::atproto))]
166166+ Atproto,
167167+}
168168+169169+impl RequestError {
170170+ /// Create a new error with the given kind and optional source
171171+ pub fn new(kind: RequestErrorKind, source: Option<BoxError>) -> Self {
172172+ Self {
173173+ kind,
174174+ source,
175175+ help: None,
176176+ context: None,
177177+ url: None,
178178+ details: None,
179179+ location: None,
180180+ }
181181+ }
182182+183183+ /// Get the error kind
184184+ pub fn kind(&self) -> &RequestErrorKind {
185185+ &self.kind
186186+ }
187187+188188+ /// Get the source error if present
189189+ pub fn source_err(&self) -> Option<&BoxError> {
190190+ self.source.as_ref()
191191+ }
192192+193193+ /// Get the context string if present
194194+ pub fn context(&self) -> Option<&str> {
195195+ self.context.as_ref().map(|s| s.as_str())
196196+ }
197197+198198+ /// Get the URL if present
199199+ pub fn url(&self) -> Option<&str> {
200200+ self.url.as_ref().map(|s| s.as_str())
201201+ }
202202+203203+ /// Get the details if present
204204+ pub fn details(&self) -> Option<&str> {
205205+ self.details.as_ref().map(|s| s.as_str())
206206+ }
207207+208208+ /// Get the location if present
209209+ pub fn location(&self) -> Option<&str> {
210210+ self.location.as_ref().map(|s| s.as_str())
211211+ }
212212+213213+ /// Add help text to this error
214214+ pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self {
215215+ self.help = Some(help.into());
216216+ self
217217+ }
218218+219219+ /// Add context to this error
220220+ pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
221221+ self.context = Some(context.into());
222222+ self
223223+ }
224224+225225+ /// Add URL to this error
226226+ pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self {
227227+ self.url = Some(url.into());
228228+ self
229229+ }
230230+231231+ /// Add details to this error
232232+ pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self {
233233+ self.details = Some(details.into());
234234+ self
235235+ }
236236+237237+ /// Add location to this error
238238+ pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self {
239239+ self.location = Some(location.into());
240240+ self
241241+ }
242242+243243+ // Constructors for each kind
244244+245245+ /// Create a no endpoint error
246246+ pub fn no_endpoint(endpoint: impl Into<SmolStr>) -> Self {
247247+ Self::new(RequestErrorKind::NoEndpoint(endpoint.into()), None)
248248+ }
249249+250250+ /// Create a token verification error
251251+ pub fn token_verification() -> Self {
252252+ Self::new(RequestErrorKind::TokenVerification, None)
253253+ }
254254+255255+ /// Create an unsupported authentication method error
256256+ pub fn unsupported_auth_method() -> Self {
257257+ Self::new(RequestErrorKind::UnsupportedAuthMethod, None)
258258+ }
259259+260260+ /// Create a no refresh token error
261261+ pub fn no_refresh_token() -> Self {
262262+ Self::new(RequestErrorKind::NoRefreshToken, None)
263263+ }
264264+265265+ /// Create an invalid DID error
266266+ pub fn invalid_did(source: impl std::error::Error + Send + Sync + 'static) -> Self {
267267+ Self::new(RequestErrorKind::InvalidDid, Some(Box::new(source)))
268268+ }
269269+270270+ /// Create a DPoP error
271271+ pub fn dpop(source: impl std::error::Error + Send + Sync + 'static) -> Self {
272272+ Self::new(RequestErrorKind::Dpop, Some(Box::new(source)))
273273+ }
274274+275275+ /// Create a storage error
276276+ pub fn storage(source: impl std::error::Error + Send + Sync + 'static) -> Self {
277277+ Self::new(RequestErrorKind::Storage, Some(Box::new(source)))
278278+ }
279279+280280+ /// Create a resolver error
281281+ pub fn resolver(source: impl std::error::Error + Send + Sync + 'static) -> Self {
282282+ Self::new(RequestErrorKind::Resolver, Some(Box::new(source)))
283283+ }
284284+285285+ /// Create an HTTP build error
286286+ pub fn http_build(source: impl std::error::Error + Send + Sync + 'static) -> Self {
287287+ Self::new(RequestErrorKind::HttpBuild, Some(Box::new(source)))
288288+ }
289289+290290+ /// Create an HTTP status error
291291+ pub fn http_status(status: StatusCode) -> Self {
292292+ Self::new(RequestErrorKind::HttpStatus(status), None)
293293+ }
294294+295295+ /// Create an HTTP status with body error
296296+ pub fn http_status_with_body(status: StatusCode, body: Value) -> Self {
297297+ Self::new(RequestErrorKind::HttpStatusWithBody { status, body }, None)
298298+ }
299299+300300+ /// Create an identity error
301301+ pub fn identity(source: impl std::error::Error + Send + Sync + 'static) -> Self {
302302+ Self::new(RequestErrorKind::Identity, Some(Box::new(source)))
303303+ }
304304+305305+ /// Create a keyset error
306306+ pub fn keyset(source: impl std::error::Error + Send + Sync + 'static) -> Self {
307307+ Self::new(RequestErrorKind::Keyset, Some(Box::new(source)))
308308+ }
309309+310310+ /// Create an atproto metadata error
311311+ pub fn atproto(source: impl std::error::Error + Send + Sync + 'static) -> Self {
312312+ Self::new(RequestErrorKind::Atproto, Some(Box::new(source)))
313313+ }
314314+315315+ /// Returns true if this error indicates permanent auth failure
316316+ /// (token revoked, refresh_token expired, etc.)
317317+ ///
318318+ /// When this returns true, the session should be cleared from storage
319319+ /// rather than retried.
320320+ pub fn is_permanent(&self) -> bool {
321321+ match &self.kind {
322322+ RequestErrorKind::NoRefreshToken => true,
323323+ RequestErrorKind::HttpStatusWithBody { body, .. } => body
324324+ .get("error")
325325+ .and_then(|e| e.as_str())
326326+ .is_some_and(|e| matches!(e, "invalid_grant" | "access_denied")),
327327+ _ => false,
328328+ }
329329+ }
330330+}
331331+332332+// From impls for common error types
333333+334334+impl From<AtStrError> for RequestError {
335335+ fn from(e: AtStrError) -> Self {
336336+ let msg = smol_str::format_smolstr!("{:?}", e);
337337+ Self::new(RequestErrorKind::InvalidDid, Some(Box::new(e)))
338338+ .with_context(msg)
339339+ .with_help("ensure DID is correctly formatted (e.g., did:plc:abc123)")
340340+ }
341341+}
342342+343343+impl From<crate::dpop::DpopError> for RequestError {
344344+ fn from(e: crate::dpop::DpopError) -> Self {
345345+ let msg = smol_str::format_smolstr!("{:?}", e);
346346+ Self::new(RequestErrorKind::Dpop, Some(Box::new(e)))
347347+ .with_context(msg)
348348+ .with_help("check DPoP key configuration and nonce handling")
349349+ }
350350+}
351351+352352+impl From<SessionStoreError> for RequestError {
353353+ fn from(e: SessionStoreError) -> Self {
354354+ let msg = smol_str::format_smolstr!("{:?}", e);
355355+ Self::new(RequestErrorKind::Storage, Some(Box::new(e)))
356356+ .with_context(msg)
357357+ .with_help("verify session store is accessible and writable")
358358+ }
359359+}
360360+361361+impl From<crate::resolver::ResolverError> for RequestError {
362362+ fn from(e: crate::resolver::ResolverError) -> Self {
363363+ let msg = smol_str::format_smolstr!("{:?}", e);
364364+ Self::new(RequestErrorKind::Resolver, Some(Box::new(e)))
365365+ .with_context(msg)
366366+ .with_help("check identity resolution and OAuth metadata endpoints")
367367+ }
368368+}
369369+370370+impl From<http::Error> for RequestError {
371371+ fn from(e: http::Error) -> Self {
372372+ let msg = smol_str::format_smolstr!("{:?}", e);
373373+ Self::new(RequestErrorKind::HttpBuild, Some(Box::new(e)))
374374+ .with_context(msg)
375375+ .with_help("verify request URIs and headers are valid")
376376+ }
377377+}
378378+379379+impl From<IdentityError> for RequestError {
380380+ fn from(e: IdentityError) -> Self {
381381+ let msg = smol_str::format_smolstr!("{:?}", e);
382382+ Self::new(RequestErrorKind::Identity, Some(Box::new(e)))
383383+ .with_context(msg)
384384+ .with_help("check handle/DID is valid and identity resolver is configured")
385385+ }
386386+}
387387+388388+impl From<crate::keyset::Error> for RequestError {
389389+ fn from(e: crate::keyset::Error) -> Self {
390390+ let msg = smol_str::format_smolstr!("{:?}", e);
391391+ Self::new(RequestErrorKind::Keyset, Some(Box::new(e)))
392392+ .with_context(msg)
393393+ .with_help("verify keyset configuration and signing algorithm support")
394394+ }
395395+}
396396+397397+impl From<serde_html_form::ser::Error> for RequestError {
398398+ fn from(e: serde_html_form::ser::Error) -> Self {
399399+ let msg = smol_str::format_smolstr!("{:?}", e);
400400+ Self::new(RequestErrorKind::SerdeHtmlForm, Some(Box::new(e)))
401401+ .with_context(msg)
402402+ .with_help("check OAuth request parameters are serializable")
403403+ }
404404+}
405405+406406+impl From<serde_json::Error> for RequestError {
407407+ fn from(e: serde_json::Error) -> Self {
408408+ let msg = smol_str::format_smolstr!("{:?}", e);
409409+ Self::new(RequestErrorKind::SerdeJson, Some(Box::new(e)))
410410+ .with_context(msg)
411411+ .with_help("verify OAuth response body is valid JSON")
412412+ }
413413+}
414414+415415+impl From<crate::atproto::Error> for RequestError {
416416+ fn from(e: crate::atproto::Error) -> Self {
417417+ let msg = smol_str::format_smolstr!("{:?}", e);
418418+ Self::new(RequestErrorKind::Atproto, Some(Box::new(e)))
419419+ .with_context(msg)
420420+ .with_help("ensure client metadata matches atproto requirements")
421421+ }
422422+}
423423+424424+/// Convenience `Result` type for OAuth request operations, defaulting to [`RequestError`].
425425+pub type Result<T> = core::result::Result<T, RequestError>;
426426+427427+/// Represents the different OAuth token-endpoint request types sent by this crate.
428428+#[allow(dead_code)]
429429+pub enum OAuthRequest<'a> {
430430+ /// Standard authorization-code token exchange.
431431+ Token(TokenRequestParameters<'a>),
432432+ /// Refresh-token grant to obtain a fresh access token.
433433+ Refresh(RefreshRequestParameters<'a>),
434434+ /// Token revocation request (RFC 7009).
435435+ Revocation(RevocationRequestParameters<'a>),
436436+ /// Token introspection request (RFC 7662).
437437+ Introspection,
438438+ /// Pushed authorization request (RFC 9126) for pre-registering auth parameters.
439439+ PushedAuthorizationRequest(ParParameters<'a>),
440440+}
441441+442442+impl OAuthRequest<'_> {
443443+ /// Return a human-readable name for this request variant, used in error messages.
444444+ pub fn name(&self) -> CowStr<'static> {
445445+ CowStr::new_static(match self {
446446+ Self::Token(_) => "token",
447447+ Self::Refresh(_) => "refresh",
448448+ Self::Revocation(_) => "revocation",
449449+ Self::Introspection => "introspection",
450450+ Self::PushedAuthorizationRequest(_) => "pushed_authorization_request",
451451+ })
452452+ }
453453+ /// Returns the HTTP status code that a successful response to this request should carry.
454454+ pub fn expected_status(&self) -> StatusCode {
455455+ match self {
456456+ Self::Token(_) | Self::Refresh(_) => StatusCode::OK,
457457+ Self::PushedAuthorizationRequest(_) => StatusCode::CREATED,
458458+ // Unlike https://datatracker.ietf.org/doc/html/rfc7009#section-2.2, oauth-provider seems to return `204`.
459459+ Self::Revocation(_) => StatusCode::NO_CONTENT,
460460+ _ => unimplemented!(),
461461+ }
462462+ }
463463+}
464464+465465+/// The serialized body of an OAuth token-endpoint request.
466466+#[derive(Debug, Serialize)]
467467+pub struct RequestPayload<'a, T>
468468+where
469469+ T: Serialize,
470470+{
471471+ /// The OAuth `client_id` advertised in the client metadata document.
472472+ client_id: CowStr<'a>,
473473+ /// The assertion type URI; set to `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`
474474+ /// when using `private_key_jwt` client authentication.
475475+ #[serde(skip_serializing_if = "Option::is_none")]
476476+ client_assertion_type: Option<CowStr<'a>>,
477477+ /// A JWT signed with the client's private key, proving client identity to the server.
478478+ #[serde(skip_serializing_if = "Option::is_none")]
479479+ client_assertion: Option<CowStr<'a>>,
480480+ /// The grant-specific parameters (token request, refresh, PAR, etc.) flattened into the body.
481481+ #[serde(flatten)]
482482+ parameters: T,
483483+}
484484+485485+/// Bundled OAuth metadata needed to perform token-endpoint operations.
486486+///
487487+/// Aggregates the server's authorization server metadata, the client's own registered metadata,
488488+/// and the optional signing keyset into a single value that is passed to helper functions such
489489+/// as [`par`], [`exchange_code`], [`refresh`], and [`revoke`].
490490+#[derive(Debug, Clone)]
491491+pub struct OAuthMetadata {
492492+ /// Metadata fetched from the authorization server's `/.well-known/oauth-authorization-server` document.
493493+ pub server_metadata: OAuthAuthorizationServerMetadata<'static>,
494494+ /// This client's registered metadata, derived from [`crate::atproto::AtprotoClientMetadata`].
495495+ pub client_metadata: OAuthClientMetadata<'static>,
496496+ /// Optional signing keyset; required for `private_key_jwt` client authentication.
497497+ pub keyset: Option<Keyset>,
498498+}
499499+500500+impl OAuthMetadata {
501501+ /// Fetch server metadata and assemble an `OAuthMetadata` from an active session context.
502502+ ///
503503+ /// Contacts the authorization server recorded in `session_data` to retrieve its current
504504+ /// metadata, then combines it with the client configuration. This is the preferred way to
505505+ /// build an `OAuthMetadata` during token refresh or revocation.
506506+ pub async fn new<'r, T: HttpClient + OAuthResolver + Send + Sync>(
507507+ client: &T,
508508+ ClientData { keyset, config }: &ClientData<'r>,
509509+ session_data: &ClientSessionData<'r>,
510510+ ) -> Result<Self> {
511511+ Ok(OAuthMetadata {
512512+ server_metadata: client
513513+ .get_authorization_server_metadata(&session_data.authserver_url)
514514+ .await?,
515515+ client_metadata: atproto_client_metadata(config.clone(), &keyset)
516516+ .unwrap()
517517+ .into_static(),
518518+ keyset: keyset.clone(),
519519+ })
520520+ }
521521+}
522522+523523+/// Perform a Pushed Authorization Request (PAR) and return the resulting state for the auth flow.
524524+///
525525+/// Generates a PKCE code challenge, a fresh DPoP key, and a random `state` token, then POSTs
526526+/// them to the authorization server's PAR endpoint. The returned [`AuthRequestData`] must be
527527+/// persisted (e.g., in the auth store) so it can be retrieved and verified during
528528+/// [`crate::client::OAuthClient::callback`].
529529+#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all, fields(login_hint = login_hint.as_ref().map(|h| h.as_ref()))))]
530530+pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>(
531531+ client: &T,
532532+ login_hint: Option<CowStr<'r>>,
533533+ prompt: Option<AuthorizeOptionPrompt>,
534534+ metadata: &OAuthMetadata,
535535+ state: Option<CowStr<'r>>,
536536+) -> crate::request::Result<AuthRequestData<'r>> {
537537+ let state = if let Some(state) = state {
538538+ state
539539+ } else {
540540+ generate_nonce()
541541+ };
542542+ let (code_challenge, verifier) = generate_pkce();
543543+544544+ let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else {
545545+ return Err(RequestError::token_verification());
546546+ };
547547+ let mut dpop_data = DpopReqData {
548548+ dpop_key,
549549+ dpop_authserver_nonce: None,
550550+ };
551551+ let parameters = ParParameters {
552552+ response_type: AuthorizationResponseType::Code,
553553+ redirect_uri: metadata.client_metadata.redirect_uris[0].to_cowstr(),
554554+ state: state.clone(),
555555+ scope: metadata.client_metadata.scope.clone(),
556556+ response_mode: None,
557557+ code_challenge,
558558+ code_challenge_method: AuthorizationCodeChallengeMethod::S256,
559559+ login_hint: login_hint,
560560+ prompt: prompt.map(CowStr::from),
561561+ };
562562+563563+ if metadata
564564+ .server_metadata
565565+ .pushed_authorization_request_endpoint
566566+ .is_some()
567567+ {
568568+ let par_response = oauth_request::<OAuthParResponse, T, DpopReqData>(
569569+ &client,
570570+ &mut dpop_data,
571571+ OAuthRequest::PushedAuthorizationRequest(parameters),
572572+ metadata,
573573+ )
574574+ .await?;
575575+576576+ let scopes = if let Some(scope) = &metadata.client_metadata.scope {
577577+ Scope::parse_multiple_reduced(&scope)
578578+ .expect("Failed to parse scopes")
579579+ .into_static()
580580+ } else {
581581+ vec![]
582582+ };
583583+ let auth_req_data = AuthRequestData {
584584+ state,
585585+ authserver_url: metadata.server_metadata.issuer.clone(),
586586+ account_did: None,
587587+ scopes,
588588+ request_uri: par_response.request_uri.to_cowstr().into_static(),
589589+ authserver_token_endpoint: metadata.server_metadata.token_endpoint.clone(),
590590+ authserver_revocation_endpoint: metadata.server_metadata.revocation_endpoint.clone(),
591591+ pkce_verifier: verifier,
592592+ dpop_data,
593593+ };
594594+595595+ Ok(auth_req_data)
596596+ } else if metadata
597597+ .server_metadata
598598+ .require_pushed_authorization_requests
599599+ == Some(true)
600600+ {
601601+ Err(RequestError::no_endpoint("pushed_authorization_request"))
602602+ } else {
603603+ todo!("use of PAR is mandatory")
604604+ }
605605+}
606606+607607+/// Exchange a refresh token for a fresh token set and update the session data in place.
608608+#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all, fields(did = %session_data.account_did)))]
609609+pub async fn refresh<'r, T>(
610610+ client: &T,
611611+ mut session_data: ClientSessionData<'r>,
612612+ metadata: &OAuthMetadata,
613613+) -> Result<ClientSessionData<'r>>
614614+where
615615+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
616616+{
617617+ let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else {
618618+ return Err(RequestError::no_refresh_token());
619619+ };
620620+621621+ // /!\ IMPORTANT /!\
622622+ //
623623+ // The "sub" MUST be a DID, whose issuer authority is indeed the server we
624624+ // are trying to obtain credentials from. Note that we are doing this
625625+ // *before* we actually try to refresh the token:
626626+ // 1) To avoid unnecessary refresh
627627+ // 2) So that the refresh is the last async operation, ensuring as few
628628+ // async operations happen before the result gets a chance to be stored.
629629+ let aud = client
630630+ .verify_issuer(&metadata.server_metadata, &session_data.token_set.sub)
631631+ .await?;
632632+ let iss = metadata.server_metadata.issuer.clone();
633633+634634+ let response = oauth_request::<OAuthTokenResponse, T, DpopClientData>(
635635+ client,
636636+ &mut session_data.dpop_data,
637637+ OAuthRequest::Refresh(RefreshRequestParameters {
638638+ grant_type: TokenGrantType::RefreshToken,
639639+ refresh_token: refresh_token.clone(),
640640+ scope: None,
641641+ }),
642642+ metadata,
643643+ )
644644+ .await?;
645645+646646+ let expires_at = response.expires_in.and_then(|expires_in| {
647647+ let now = Datetime::now();
648648+ now.as_ref()
649649+ .checked_add_signed(TimeDelta::seconds(expires_in))
650650+ .map(Datetime::new)
651651+ });
652652+653653+ session_data.update_with_tokens(TokenSet {
654654+ iss,
655655+ sub: session_data.token_set.sub.clone(),
656656+ aud: CowStr::Owned(aud.to_smolstr()),
657657+ scope: response.scope.map(CowStr::Owned),
658658+ access_token: CowStr::Owned(response.access_token),
659659+ refresh_token: response.refresh_token.map(CowStr::Owned),
660660+ token_type: response.token_type,
661661+ expires_at,
662662+ });
663663+664664+ Ok(session_data)
665665+}
666666+667667+/// Exchange an authorization code for a token set and return a fully-verified [`TokenSet`].
668668+///
669669+/// Per the AT Protocol OAuth spec, the `sub` claim in the token response **must** be verified
670670+/// against the expected authorization server issuer before the token can be trusted. This
671671+/// function performs that verification as part of the exchange, so callers receive a token
672672+/// set that is safe to persist.
673673+#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))]
674674+pub async fn exchange_code<'r, T, D>(
675675+ client: &T,
676676+ data_source: &'r mut D,
677677+ code: &str,
678678+ verifier: &str,
679679+ metadata: &OAuthMetadata,
680680+) -> Result<TokenSet<'r>>
681681+where
682682+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
683683+ D: DpopDataSource,
684684+{
685685+ let token_response = oauth_request::<OAuthTokenResponse, T, D>(
686686+ client,
687687+ data_source,
688688+ OAuthRequest::Token(TokenRequestParameters {
689689+ grant_type: TokenGrantType::AuthorizationCode,
690690+ code: code.into(),
691691+ redirect_uri: CowStr::Owned(
692692+ metadata.client_metadata.redirect_uris[0]
693693+ .clone()
694694+ .to_smolstr(),
695695+ ),
696696+ code_verifier: verifier.into(),
697697+ }),
698698+ metadata,
699699+ )
700700+ .await?;
701701+ let Some(sub) = token_response.sub else {
702702+ return Err(RequestError::token_verification());
703703+ };
704704+ let sub = Did::new_owned(sub)?;
705705+ let iss = metadata.server_metadata.issuer.clone();
706706+ // /!\ IMPORTANT /!\
707707+ //
708708+ // The token_response MUST always be valid before the "sub" it contains
709709+ // can be trusted (see Atproto's OAuth spec for details).
710710+ let aud = client
711711+ .verify_issuer(&metadata.server_metadata, &sub)
712712+ .await?;
713713+714714+ let expires_at = token_response.expires_in.and_then(|expires_in| {
715715+ Datetime::now()
716716+ .as_ref()
717717+ .checked_add_signed(TimeDelta::seconds(expires_in))
718718+ .map(Datetime::new)
719719+ });
720720+ Ok(TokenSet {
721721+ iss,
722722+ sub,
723723+ aud: CowStr::Owned(aud.to_smolstr()),
724724+ scope: token_response.scope.map(CowStr::Owned),
725725+ access_token: CowStr::Owned(token_response.access_token),
726726+ refresh_token: token_response.refresh_token.map(CowStr::Owned),
727727+ token_type: token_response.token_type,
728728+ expires_at,
729729+ })
730730+}
731731+732732+/// Send a token revocation request (RFC 7009) to the authorization server.
733733+///
734734+/// This function is called by [`crate::client::OAuthSession::logout`] when a revocation endpoint is advertised
735735+/// by the server. The caller is responsible for deleting the session from local storage regardless
736736+/// of whether revocation succeeds.
737737+#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))]
738738+pub async fn revoke<'r, T, D>(
739739+ client: &T,
740740+ data_source: &'r mut D,
741741+ token: &str,
742742+ metadata: &OAuthMetadata,
743743+) -> Result<()>
744744+where
745745+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
746746+ D: DpopDataSource,
747747+{
748748+ oauth_request::<(), T, D>(
749749+ client,
750750+ data_source,
751751+ OAuthRequest::Revocation(RevocationRequestParameters {
752752+ token: token.into(),
753753+ }),
754754+ metadata,
755755+ )
756756+ .await?;
757757+ Ok(())
758758+}
759759+760760+/// Low-level function for sending an OAuth token-endpoint request and deserializing the response.
761761+///
762762+/// Selects the correct server endpoint for `request`, builds the form-encoded body with
763763+/// client authentication, performs the DPoP-wrapped HTTP POST, and deserializes the response
764764+/// body into `O`. The type parameter `O` is inferred from the call site; use `()` for requests
765765+/// where the response body is empty (e.g., revocation).
766766+pub async fn oauth_request<'de: 'r, 'r, O, T, D>(
767767+ client: &T,
768768+ data_source: &'r mut D,
769769+ request: OAuthRequest<'r>,
770770+ metadata: &OAuthMetadata,
771771+) -> Result<O>
772772+where
773773+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
774774+ O: serde::de::DeserializeOwned,
775775+ D: DpopDataSource,
776776+{
777777+ let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else {
778778+ return Err(RequestError::no_endpoint(request.name()));
779779+ };
780780+ let client_assertions = build_auth(
781781+ metadata.keyset.as_ref(),
782782+ &metadata.server_metadata,
783783+ &metadata.client_metadata,
784784+ )?;
785785+ let body = match &request {
786786+ OAuthRequest::Token(params) => build_oauth_req_body(client_assertions, params)?,
787787+ OAuthRequest::Refresh(params) => build_oauth_req_body(client_assertions, params)?,
788788+ OAuthRequest::Revocation(params) => build_oauth_req_body(client_assertions, params)?,
789789+ OAuthRequest::PushedAuthorizationRequest(params) => {
790790+ build_oauth_req_body(client_assertions, params)?
791791+ }
792792+ _ => unimplemented!(),
793793+ };
794794+ let req = Request::builder()
795795+ .uri(url.to_string())
796796+ .method(Method::POST)
797797+ .header("Content-Type", "application/x-www-form-urlencoded")
798798+ .body(body.into_bytes())?;
799799+ let res = client.dpop_server_call(data_source).send(req).await?;
800800+ if res.status() == request.expected_status() {
801801+ let body = res.body();
802802+ if body.is_empty() {
803803+ // since an empty body cannot be deserialized, use “null” temporarily to allow deserialization to `()`.
804804+ Ok(serde_json::from_slice(b"null")?)
805805+ } else {
806806+ let output: O = serde_json::from_slice(body)?;
807807+ Ok(output)
808808+ }
809809+ } else if res.status().is_client_error() {
810810+ Err(RequestError::http_status_with_body(
811811+ res.status(),
812812+ serde_json::from_slice(res.body())?,
813813+ ))
814814+ } else {
815815+ Err(RequestError::http_status(res.status()))
816816+ }
817817+}
818818+819819+#[inline]
820820+fn endpoint_for_req<'a, 'r>(
821821+ server_metadata: &'r OAuthAuthorizationServerMetadata<'a>,
822822+ request: &'r OAuthRequest,
823823+) -> Option<&'r CowStr<'a>> {
824824+ match request {
825825+ OAuthRequest::Token(_) | OAuthRequest::Refresh(_) => Some(&server_metadata.token_endpoint),
826826+ OAuthRequest::Revocation(_) => server_metadata.revocation_endpoint.as_ref(),
827827+ OAuthRequest::Introspection => server_metadata.introspection_endpoint.as_ref(),
828828+ OAuthRequest::PushedAuthorizationRequest(_) => server_metadata
829829+ .pushed_authorization_request_endpoint
830830+ .as_ref(),
831831+ }
832832+}
833833+834834+#[inline]
835835+fn build_oauth_req_body<'a, S>(client_assertions: ClientAuth<'a>, parameters: S) -> Result<String>
836836+where
837837+ S: Serialize,
838838+{
839839+ Ok(serde_html_form::to_string(RequestPayload {
840840+ client_id: client_assertions.client_id,
841841+ client_assertion_type: client_assertions.assertion_type,
842842+ client_assertion: client_assertions.assertion,
843843+ parameters,
844844+ })?)
845845+}
846846+847847+/// Client identity fields appended to every token-endpoint request body.
848848+///
849849+/// Encapsulates the result of choosing a client authentication method (`none` vs.
850850+/// `private_key_jwt`). The `build_auth` helper selects the appropriate variant based
851851+/// on server capabilities and client configuration.
852852+#[derive(Debug, Clone, Default)]
853853+pub struct ClientAuth<'a> {
854854+ /// The OAuth `client_id` for this client.
855855+ client_id: CowStr<'a>,
856856+ /// Either absent (for `none` auth) or `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
857857+ assertion_type: Option<CowStr<'a>>,
858858+ /// A signed JWT proving client identity; present only for `private_key_jwt` auth.
859859+ assertion: Option<CowStr<'a>>,
860860+}
861861+862862+impl<'s> ClientAuth<'s> {
863863+ /// Construct a `ClientAuth` with only a `client_id` and no assertion (the `none` method).
864864+ pub fn new_id(client_id: CowStr<'s>) -> Self {
865865+ Self {
866866+ client_id,
867867+ assertion_type: None,
868868+ assertion: None,
869869+ }
870870+ }
871871+}
872872+873873+fn build_auth<'a>(
874874+ keyset: Option<&Keyset>,
875875+ server_metadata: &OAuthAuthorizationServerMetadata<'a>,
876876+ client_metadata: &OAuthClientMetadata<'a>,
877877+) -> Result<ClientAuth<'a>> {
878878+ let method_supported = server_metadata
879879+ .token_endpoint_auth_methods_supported
880880+ .as_ref();
881881+882882+ let client_id = client_metadata.client_id.to_cowstr().into_static();
883883+ if let Some(method) = client_metadata.token_endpoint_auth_method.as_ref() {
884884+ match (*method).as_ref() {
885885+ "private_key_jwt"
886886+ if method_supported
887887+ .as_ref()
888888+ .is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) =>
889889+ {
890890+ if let Some(keyset) = &keyset {
891891+ let mut alg_strs = server_metadata
892892+ .token_endpoint_auth_signing_alg_values_supported
893893+ .clone()
894894+ .unwrap_or(vec![FALLBACK_ALG.into()]);
895895+ alg_strs.sort_by(compare_algos);
896896+ let algs: Vec<Signing> = alg_strs
897897+ .iter()
898898+ .filter_map(|s| crate::keyset::parse_signing_alg(s))
899899+ .collect();
900900+ let iat = Utc::now().timestamp();
901901+ return Ok(ClientAuth {
902902+ client_id: client_id.clone(),
903903+ assertion_type: Some(CowStr::new_static(CLIENT_ASSERTION_TYPE_JWT_BEARER)),
904904+ assertion: Some(
905905+ keyset.create_jwt(
906906+ &algs,
907907+ // https://datatracker.ietf.org/doc/html/rfc7523#section-3
908908+ RegisteredClaims {
909909+ iss: Some(client_id.clone()),
910910+ sub: Some(client_id),
911911+ aud: Some(RegisteredClaimsAud::Single(
912912+ server_metadata.issuer.clone(),
913913+ )),
914914+ exp: Some(iat + 60),
915915+ // "iat" is required and **MUST** be less than one minute
916916+ // https://datatracker.ietf.org/doc/html/rfc9101
917917+ iat: Some(iat),
918918+ // atproto oauth-provider requires "jti" to be present
919919+ jti: Some(generate_nonce()),
920920+ ..Default::default()
921921+ }
922922+ .into(),
923923+ )?,
924924+ ),
925925+ });
926926+ }
927927+ }
928928+ "none"
929929+ if method_supported
930930+ .as_ref()
931931+ .is_some_and(|v| v.contains(&CowStr::new_static("none"))) =>
932932+ {
933933+ return Ok(ClientAuth::new_id(client_id));
934934+ }
935935+ _ => {}
936936+ }
937937+ }
938938+939939+ Err(RequestError::unsupported_auth_method())
940940+}
941941+942942+#[cfg(test)]
943943+mod tests {
944944+ use super::*;
945945+ use crate::types::{OAuthAuthorizationServerMetadata, OAuthClientMetadata};
946946+ use bytes::Bytes;
947947+ use http::{Response as HttpResponse, StatusCode};
948948+ use jacquard_common::{deps::fluent_uri::Uri, http_client::HttpClient, types::string::Did};
949949+ use jacquard_identity::resolver::IdentityResolver;
950950+ use std::sync::Arc;
951951+ use tokio::sync::Mutex;
952952+953953+ #[derive(Clone, Default)]
954954+ struct MockClient {
955955+ resp: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>,
956956+ }
957957+958958+ impl HttpClient for MockClient {
959959+ type Error = std::convert::Infallible;
960960+ fn send_http(
961961+ &self,
962962+ _request: http::Request<Vec<u8>>,
963963+ ) -> impl core::future::Future<
964964+ Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
965965+ > + Send {
966966+ let resp = self.resp.clone();
967967+ async move { Ok(resp.lock().await.take().unwrap()) }
968968+ }
969969+ }
970970+971971+ // IdentityResolver methods won't be called in these tests; provide stubs.
972972+ impl IdentityResolver for MockClient {
973973+ fn options(&self) -> &jacquard_identity::resolver::ResolverOptions {
974974+ use std::sync::LazyLock;
975975+ static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> =
976976+ LazyLock::new(|| jacquard_identity::resolver::ResolverOptions::default());
977977+ &OPTS
978978+ }
979979+ async fn resolve_handle(
980980+ &self,
981981+ _handle: &jacquard_common::types::string::Handle<'_>,
982982+ ) -> std::result::Result<Did<'static>, jacquard_identity::resolver::IdentityError> {
983983+ Ok(Did::new_static("did:plc:alice").unwrap())
984984+ }
985985+ async fn resolve_did_doc(
986986+ &self,
987987+ _did: &Did<'_>,
988988+ ) -> std::result::Result<
989989+ jacquard_identity::resolver::DidDocResponse,
990990+ jacquard_identity::resolver::IdentityError,
991991+ > {
992992+ let doc = serde_json::json!({
993993+ "id": "did:plc:alice",
994994+ "service": [{
995995+ "id": "#pds",
996996+ "type": "AtprotoPersonalDataServer",
997997+ "serviceEndpoint": "https://pds"
998998+ }]
999999+ });
10001000+ let buf = Bytes::from(serde_json::to_vec(&doc).unwrap());
10011001+ Ok(jacquard_identity::resolver::DidDocResponse {
10021002+ buffer: buf,
10031003+ status: StatusCode::OK,
10041004+ requested: None,
10051005+ })
10061006+ }
10071007+ }
10081008+10091009+ // Allow using DPoP helpers on MockClient
10101010+ impl crate::dpop::DpopExt for MockClient {}
10111011+ impl crate::resolver::OAuthResolver for MockClient {}
10121012+10131013+ fn base_metadata() -> OAuthMetadata {
10141014+ let mut server = OAuthAuthorizationServerMetadata::default();
10151015+ server.issuer = CowStr::from("https://issuer");
10161016+ server.authorization_endpoint = CowStr::from("https://issuer/authorize");
10171017+ server.token_endpoint = CowStr::from("https://issuer/token");
10181018+ server.token_endpoint_auth_methods_supported = Some(vec![CowStr::from("none")]);
10191019+ OAuthMetadata {
10201020+ server_metadata: server,
10211021+ client_metadata: OAuthClientMetadata {
10221022+ client_id: CowStr::new_static("https://client"),
10231023+ client_uri: None,
10241024+ redirect_uris: vec![CowStr::new_static("https://client/cb")],
10251025+ scope: Some(CowStr::from("atproto")),
10261026+ grant_types: None,
10271027+ response_types: vec![CowStr::new_static("code")],
10281028+ application_type: Some(CowStr::new_static("web")),
10291029+ token_endpoint_auth_method: Some(CowStr::from("none")),
10301030+ dpop_bound_access_tokens: None,
10311031+ jwks_uri: None,
10321032+ jwks: None,
10331033+ token_endpoint_auth_signing_alg: None,
10341034+ client_name: None,
10351035+ privacy_policy_uri: None,
10361036+ tos_uri: None,
10371037+ logo_uri: None,
10381038+ },
10391039+ keyset: None,
10401040+ }
10411041+ }
10421042+10431043+ #[tokio::test]
10441044+ async fn par_missing_endpoint() {
10451045+ let mut meta = base_metadata();
10461046+ meta.server_metadata.require_pushed_authorization_requests = Some(true);
10471047+ meta.server_metadata.pushed_authorization_request_endpoint = None;
10481048+ // require_pushed_authorization_requests is true and no endpoint
10491049+ let err = super::par(&MockClient::default(), None, None, &meta, None)
10501050+ .await
10511051+ .unwrap_err();
10521052+ assert!(
10531053+ matches!(err.kind(), RequestErrorKind::NoEndpoint(name) if name == "pushed_authorization_request")
10541054+ );
10551055+ }
10561056+10571057+ #[tokio::test]
10581058+ async fn refresh_no_refresh_token() {
10591059+ let client = MockClient::default();
10601060+ let meta = base_metadata();
10611061+ let session = ClientSessionData {
10621062+ account_did: Did::new_static("did:plc:alice").unwrap(),
10631063+ session_id: CowStr::from("state"),
10641064+ host_url: Uri::parse("https://pds").expect("valid").to_owned(),
10651065+ authserver_url: CowStr::new_static("https://issuer"),
10661066+ authserver_token_endpoint: CowStr::from("https://issuer/token"),
10671067+ authserver_revocation_endpoint: None,
10681068+ scopes: vec![],
10691069+ dpop_data: DpopClientData {
10701070+ dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
10711071+ dpop_authserver_nonce: CowStr::from(""),
10721072+ dpop_host_nonce: CowStr::from(""),
10731073+ },
10741074+ token_set: crate::types::TokenSet {
10751075+ iss: CowStr::from("https://issuer"),
10761076+ sub: Did::new_static("did:plc:alice").unwrap(),
10771077+ aud: CowStr::from("https://pds"),
10781078+ scope: None,
10791079+ refresh_token: None,
10801080+ access_token: CowStr::from("abc"),
10811081+ token_type: crate::types::OAuthTokenType::DPoP,
10821082+ expires_at: None,
10831083+ },
10841084+ };
10851085+ let err = super::refresh(&client, session, &meta).await.unwrap_err();
10861086+ assert!(matches!(err.kind(), RequestErrorKind::NoRefreshToken));
10871087+ }
10881088+10891089+ #[tokio::test]
10901090+ async fn exchange_code_missing_sub() {
10911091+ let client = MockClient::default();
10921092+ // set mock HTTP response body: token response without `sub`
10931093+ *client.resp.lock().await = Some(
10941094+ HttpResponse::builder()
10951095+ .status(StatusCode::OK)
10961096+ .body(
10971097+ serde_json::to_vec(&serde_json::json!({
10981098+ "access_token":"tok",
10991099+ "token_type":"DPoP",
11001100+ "expires_in": 3600
11011101+ }))
11021102+ .unwrap(),
11031103+ )
11041104+ .unwrap(),
11051105+ );
11061106+ let meta = base_metadata();
11071107+ let mut dpop = DpopReqData {
11081108+ dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
11091109+ dpop_authserver_nonce: None,
11101110+ };
11111111+ let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta)
11121112+ .await
11131113+ .unwrap_err();
11141114+ assert!(matches!(err.kind(), RequestErrorKind::TokenVerification));
11151115+ }
11161116+}
+954
src-tauri/vendor/jacquard-oauth/src/resolver.rs
···11+#[cfg(not(target_arch = "wasm32"))]
22+use std::future::Future;
33+44+use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata};
55+use http::{Request, StatusCode};
66+use jacquard_common::CowStr;
77+use jacquard_common::IntoStatic;
88+#[allow(unused_imports)]
99+use jacquard_common::cowstr::ToCowStr;
1010+use jacquard_common::deps::fluent_uri::Uri;
1111+use jacquard_common::types::did_doc::DidDocument;
1212+use jacquard_common::types::ident::AtIdentifier;
1313+use jacquard_common::{http_client::HttpClient, types::did::Did};
1414+use jacquard_identity::resolver::{IdentityError, IdentityResolver};
1515+use smol_str::SmolStr;
1616+1717+/// Convenience alias for a heap-allocated, thread-safe, `'static` error value.
1818+pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
1919+2020+/// OAuth resolver error for identity and metadata resolution
2121+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
2222+#[error("{kind}")]
2323+pub struct ResolverError {
2424+ #[diagnostic_source]
2525+ kind: ResolverErrorKind,
2626+ #[source]
2727+ source: Option<BoxError>,
2828+ #[help]
2929+ help: Option<SmolStr>,
3030+ context: Option<SmolStr>,
3131+ url: Option<SmolStr>,
3232+ details: Option<SmolStr>,
3333+ location: Option<SmolStr>,
3434+}
3535+3636+/// Error categories for OAuth resolver operations
3737+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
3838+#[non_exhaustive]
3939+pub enum ResolverErrorKind {
4040+ /// Resource not found
4141+ #[error("resource not found")]
4242+ #[diagnostic(
4343+ code(jacquard_oauth::resolver::not_found),
4444+ help("check the base URL or identifier")
4545+ )]
4646+ NotFound,
4747+4848+ /// Invalid AT identifier
4949+ #[error("invalid at identifier: {0}")]
5050+ #[diagnostic(
5151+ code(jacquard_oauth::resolver::at_identifier),
5252+ help("ensure a valid handle or DID was provided")
5353+ )]
5454+ AtIdentifier(SmolStr),
5555+5656+ /// Invalid DID
5757+ #[error("invalid did: {0}")]
5858+ #[diagnostic(
5959+ code(jacquard_oauth::resolver::did),
6060+ help("ensure DID is correctly formed (did:plc or did:web)")
6161+ )]
6262+ Did(SmolStr),
6363+6464+ /// Invalid DID document
6565+ #[error("invalid did document: {0}")]
6666+ #[diagnostic(
6767+ code(jacquard_oauth::resolver::did_document),
6868+ help("verify the DID document structure and service entries")
6969+ )]
7070+ DidDocument(SmolStr),
7171+7272+ /// Protected resource metadata is invalid
7373+ #[error("protected resource metadata is invalid: {0}")]
7474+ #[diagnostic(
7575+ code(jacquard_oauth::resolver::protected_resource_metadata),
7676+ help("PDS must advertise an authorization server in its protected resource metadata")
7777+ )]
7878+ ProtectedResourceMetadata(SmolStr),
7979+8080+ /// Authorization server metadata is invalid
8181+ #[error("authorization server metadata is invalid: {0}")]
8282+ #[diagnostic(
8383+ code(jacquard_oauth::resolver::authorization_server_metadata),
8484+ help("issuer must match and include the PDS resource")
8585+ )]
8686+ AuthorizationServerMetadata(SmolStr),
8787+8888+ /// Identity resolution error
8989+ #[error("error resolving identity")]
9090+ #[diagnostic(code(jacquard_oauth::resolver::identity))]
9191+ Identity,
9292+9393+ /// Unsupported DID method
9494+ #[error("unsupported did method: {0:?}")]
9595+ #[diagnostic(
9696+ code(jacquard_oauth::resolver::unsupported_did_method),
9797+ help("supported DID methods: did:web, did:plc")
9898+ )]
9999+ UnsupportedDidMethod(Did<'static>),
100100+101101+ /// HTTP transport error
102102+ #[error("transport error")]
103103+ #[diagnostic(code(jacquard_oauth::resolver::transport))]
104104+ Transport,
105105+106106+ /// HTTP status error
107107+ #[error("http status: {0}")]
108108+ #[diagnostic(
109109+ code(jacquard_oauth::resolver::http_status),
110110+ help("check well-known paths and server configuration")
111111+ )]
112112+ HttpStatus(StatusCode),
113113+114114+ /// JSON serialization error
115115+ #[error("json error")]
116116+ #[diagnostic(code(jacquard_oauth::resolver::serde_json))]
117117+ SerdeJson,
118118+119119+ /// Form serialization error
120120+ #[error("form serialization error")]
121121+ #[diagnostic(code(jacquard_oauth::resolver::serde_form))]
122122+ SerdeHtmlForm,
123123+124124+ /// URL parsing error
125125+ #[error("url parsing error")]
126126+ #[diagnostic(code(jacquard_oauth::resolver::url))]
127127+ Uri,
128128+}
129129+130130+impl ResolverError {
131131+ /// Create a new error with the given kind and optional source
132132+ pub fn new(kind: ResolverErrorKind, source: Option<BoxError>) -> Self {
133133+ Self {
134134+ kind,
135135+ source,
136136+ help: None,
137137+ context: None,
138138+ url: None,
139139+ details: None,
140140+ location: None,
141141+ }
142142+ }
143143+144144+ /// Get the error kind
145145+ pub fn kind(&self) -> &ResolverErrorKind {
146146+ &self.kind
147147+ }
148148+149149+ /// Get the source error if present
150150+ pub fn source_err(&self) -> Option<&BoxError> {
151151+ self.source.as_ref()
152152+ }
153153+154154+ /// Get the context string if present
155155+ pub fn context(&self) -> Option<&str> {
156156+ self.context.as_ref().map(|s| s.as_str())
157157+ }
158158+159159+ /// Get the URL if present
160160+ pub fn url(&self) -> Option<&str> {
161161+ self.url.as_ref().map(|s| s.as_str())
162162+ }
163163+164164+ /// Get the details if present
165165+ pub fn details(&self) -> Option<&str> {
166166+ self.details.as_ref().map(|s| s.as_str())
167167+ }
168168+169169+ /// Get the location if present
170170+ pub fn location(&self) -> Option<&str> {
171171+ self.location.as_ref().map(|s| s.as_str())
172172+ }
173173+174174+ /// Add help text to this error
175175+ pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self {
176176+ self.help = Some(help.into());
177177+ self
178178+ }
179179+180180+ /// Add context to this error
181181+ pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
182182+ self.context = Some(context.into());
183183+ self
184184+ }
185185+186186+ /// Add URL to this error
187187+ pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self {
188188+ self.url = Some(url.into());
189189+ self
190190+ }
191191+192192+ /// Add details to this error
193193+ pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self {
194194+ self.details = Some(details.into());
195195+ self
196196+ }
197197+198198+ /// Add location to this error
199199+ pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self {
200200+ self.location = Some(location.into());
201201+ self
202202+ }
203203+204204+ // Constructors for each kind
205205+206206+ /// Create a not found error
207207+ pub fn not_found() -> Self {
208208+ Self::new(ResolverErrorKind::NotFound, None)
209209+ }
210210+211211+ /// Create an invalid AT identifier error
212212+ pub fn at_identifier(msg: impl Into<SmolStr>) -> Self {
213213+ Self::new(ResolverErrorKind::AtIdentifier(msg.into()), None)
214214+ }
215215+216216+ /// Create an invalid DID error
217217+ pub fn did(msg: impl Into<SmolStr>) -> Self {
218218+ Self::new(ResolverErrorKind::Did(msg.into()), None)
219219+ }
220220+221221+ /// Create an invalid DID document error
222222+ pub fn did_document(msg: impl Into<SmolStr>) -> Self {
223223+ Self::new(ResolverErrorKind::DidDocument(msg.into()), None)
224224+ }
225225+226226+ /// Create a protected resource metadata error
227227+ pub fn protected_resource_metadata(msg: impl Into<SmolStr>) -> Self {
228228+ Self::new(
229229+ ResolverErrorKind::ProtectedResourceMetadata(msg.into()),
230230+ None,
231231+ )
232232+ }
233233+234234+ /// Create an authorization server metadata error
235235+ pub fn authorization_server_metadata(msg: impl Into<SmolStr>) -> Self {
236236+ Self::new(
237237+ ResolverErrorKind::AuthorizationServerMetadata(msg.into()),
238238+ None,
239239+ )
240240+ }
241241+242242+ /// Create an identity resolution error
243243+ pub fn identity(source: impl std::error::Error + Send + Sync + 'static) -> Self {
244244+ Self::new(ResolverErrorKind::Identity, Some(Box::new(source)))
245245+ }
246246+247247+ /// Create an unsupported DID method error
248248+ pub fn unsupported_did_method(did: Did<'static>) -> Self {
249249+ Self::new(ResolverErrorKind::UnsupportedDidMethod(did), None)
250250+ }
251251+252252+ /// Create a transport error
253253+ pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self {
254254+ Self::new(ResolverErrorKind::Transport, Some(Box::new(source)))
255255+ }
256256+257257+ /// Create an HTTP status error
258258+ pub fn http_status(status: StatusCode) -> Self {
259259+ Self::new(ResolverErrorKind::HttpStatus(status), None)
260260+ }
261261+}
262262+263263+/// Result type for resolver operations
264264+pub type Result<T> = std::result::Result<T, ResolverError>;
265265+266266+// From impls for common error types
267267+268268+impl From<IdentityError> for ResolverError {
269269+ fn from(e: IdentityError) -> Self {
270270+ let msg = smol_str::format_smolstr!("{:?}", e);
271271+ Self::new(ResolverErrorKind::Identity, Some(Box::new(e)))
272272+ .with_context(msg)
273273+ .with_help("verify handle/DID is valid and resolver configuration")
274274+ }
275275+}
276276+277277+impl From<jacquard_common::error::ClientError> for ResolverError {
278278+ fn from(e: jacquard_common::error::ClientError) -> Self {
279279+ let msg = smol_str::format_smolstr!("{:?}", e);
280280+ Self::new(ResolverErrorKind::Transport, Some(Box::new(e)))
281281+ .with_context(msg)
282282+ .with_help("check network connectivity and well-known endpoint availability")
283283+ }
284284+}
285285+286286+impl From<serde_json::Error> for ResolverError {
287287+ fn from(e: serde_json::Error) -> Self {
288288+ let msg = smol_str::format_smolstr!("{:?}", e);
289289+ Self::new(ResolverErrorKind::SerdeJson, Some(Box::new(e)))
290290+ .with_context(msg)
291291+ .with_help("verify OAuth metadata response format is valid JSON")
292292+ }
293293+}
294294+295295+impl From<serde_html_form::ser::Error> for ResolverError {
296296+ fn from(e: serde_html_form::ser::Error) -> Self {
297297+ let msg = smol_str::format_smolstr!("{:?}", e);
298298+ Self::new(ResolverErrorKind::SerdeHtmlForm, Some(Box::new(e)))
299299+ .with_context(msg)
300300+ .with_help("check form parameters are serializable")
301301+ }
302302+}
303303+304304+impl From<jacquard_common::deps::fluent_uri::ParseError> for ResolverError {
305305+ fn from(e: jacquard_common::deps::fluent_uri::ParseError) -> Self {
306306+ let msg = smol_str::format_smolstr!("{:?}", e);
307307+ Self::new(ResolverErrorKind::Uri, Some(Box::new(e)))
308308+ .with_context(msg)
309309+ .with_help("ensure URIs are well-formed (e.g., https://example.com)")
310310+ }
311311+}
312312+313313+// // Deprecated - for compatibility with old TransportError usage
314314+// #[allow(deprecated)]
315315+// impl From<jacquard_common::error::TransportError> for ResolverError {
316316+// fn from(e: jacquard_common::error::TransportError) -> Self {
317317+// Self::transport(e)
318318+// }
319319+// }
320320+321321+#[cfg(not(target_arch = "wasm32"))]
322322+async fn verify_issuer_impl<T: OAuthResolver + Sync + ?Sized>(
323323+ resolver: &T,
324324+ server_metadata: &OAuthAuthorizationServerMetadata<'_>,
325325+ sub: &Did<'_>,
326326+) -> Result<Uri<String>> {
327327+ let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?;
328328+ if metadata.issuer != server_metadata.issuer {
329329+ return Err(ResolverError::authorization_server_metadata(
330330+ "issuer mismatch",
331331+ ));
332332+ }
333333+ Ok(identity
334334+ .pds_endpoint()
335335+ .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?)
336336+}
337337+338338+#[cfg(target_arch = "wasm32")]
339339+async fn verify_issuer_impl<T: OAuthResolver + ?Sized>(
340340+ resolver: &T,
341341+ server_metadata: &OAuthAuthorizationServerMetadata<'_>,
342342+ sub: &Did<'_>,
343343+) -> Result<Uri<String>> {
344344+ let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?;
345345+ if metadata.issuer != server_metadata.issuer {
346346+ return Err(ResolverError::authorization_server_metadata(
347347+ "issuer mismatch",
348348+ ));
349349+ }
350350+ Ok(identity
351351+ .pds_endpoint()
352352+ .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?)
353353+}
354354+355355+#[cfg(not(target_arch = "wasm32"))]
356356+async fn resolve_oauth_impl<T: OAuthResolver + Sync + ?Sized>(
357357+ resolver: &T,
358358+ input: &str,
359359+) -> Result<(
360360+ OAuthAuthorizationServerMetadata<'static>,
361361+ Option<DidDocument<'static>>,
362362+)> {
363363+ // Allow using an entryway, or PDS url, directly as login input (e.g.
364364+ // when the user forgot their handle, or when the handle does not
365365+ // resolve to a DID)
366366+ Ok(if input.starts_with("https://") {
367367+ let uri = Uri::parse(input)
368368+ .map_err(|e| {
369369+ let err = ResolverError::new(ResolverErrorKind::Uri, Some(Box::new(e)));
370370+ err.with_context("failed to parse service URL")
371371+ })?
372372+ .to_owned();
373373+ (
374374+ resolver.resolve_from_service(&uri.as_str().into()).await?,
375375+ None,
376376+ )
377377+ } else {
378378+ let (metadata, identity) = resolver.resolve_from_identity(input).await?;
379379+ (metadata, Some(identity))
380380+ })
381381+}
382382+383383+#[cfg(target_arch = "wasm32")]
384384+async fn resolve_oauth_impl<T: OAuthResolver + ?Sized>(
385385+ resolver: &T,
386386+ input: &str,
387387+) -> Result<(
388388+ OAuthAuthorizationServerMetadata<'static>,
389389+ Option<DidDocument<'static>>,
390390+)> {
391391+ // Allow using an entryway, or PDS url, directly as login input (e.g.
392392+ // when the user forgot their handle, or when the handle does not
393393+ // resolve to a DID)
394394+ Ok(if input.starts_with("https://") {
395395+ let uri = Uri::parse(input)
396396+ .map_err(|e| {
397397+ let err = ResolverError::new(ResolverErrorKind::Uri, Some(Box::new(e)));
398398+ err.with_context("failed to parse service URL")
399399+ })?
400400+ .to_owned();
401401+ (
402402+ resolver.resolve_from_service(&uri.as_str().into()).await?,
403403+ None,
404404+ )
405405+ } else {
406406+ let (metadata, identity) = resolver.resolve_from_identity(input).await?;
407407+ (metadata, Some(identity))
408408+ })
409409+}
410410+411411+#[cfg(not(target_arch = "wasm32"))]
412412+async fn resolve_from_service_impl<T: OAuthResolver + Sync + ?Sized>(
413413+ resolver: &T,
414414+ input: &CowStr<'_>,
415415+) -> Result<OAuthAuthorizationServerMetadata<'static>> {
416416+ // Assume first that input is a PDS URL (as required by ATPROTO)
417417+ if let Ok(metadata) = resolver.get_resource_server_metadata(input).await {
418418+ return Ok(metadata);
419419+ }
420420+ // Fallback to trying to fetch as an issuer (Entryway)
421421+ resolver.get_authorization_server_metadata(input).await
422422+}
423423+424424+#[cfg(target_arch = "wasm32")]
425425+async fn resolve_from_service_impl<T: OAuthResolver + ?Sized>(
426426+ resolver: &T,
427427+ input: &CowStr<'_>,
428428+) -> Result<OAuthAuthorizationServerMetadata<'static>> {
429429+ // Assume first that input is a PDS URL (as required by ATPROTO)
430430+ if let Ok(metadata) = resolver.get_resource_server_metadata(input).await {
431431+ return Ok(metadata);
432432+ }
433433+ // Fallback to trying to fetch as an issuer (Entryway)
434434+ resolver.get_authorization_server_metadata(input).await
435435+}
436436+437437+#[cfg(not(target_arch = "wasm32"))]
438438+async fn resolve_from_identity_impl<T: OAuthResolver + Sync + ?Sized>(
439439+ resolver: &T,
440440+ input: &str,
441441+) -> Result<(
442442+ OAuthAuthorizationServerMetadata<'static>,
443443+ DidDocument<'static>,
444444+)> {
445445+ let actor = AtIdentifier::new(input)
446446+ .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?;
447447+ let identity = resolver.resolve_ident_owned(&actor).await?;
448448+ if let Some(pds) = &identity.pds_endpoint() {
449449+ use jacquard_common::cowstr::ToCowStr;
450450+451451+ let metadata = resolver
452452+ .get_resource_server_metadata(&pds.to_cowstr())
453453+ .await?;
454454+ Ok((metadata, identity))
455455+ } else {
456456+ Err(ResolverError::did_document("Did doc lacking pds"))
457457+ }
458458+}
459459+460460+#[cfg(target_arch = "wasm32")]
461461+async fn resolve_from_identity_impl<T: OAuthResolver + ?Sized>(
462462+ resolver: &T,
463463+ input: &str,
464464+) -> Result<(
465465+ OAuthAuthorizationServerMetadata<'static>,
466466+ DidDocument<'static>,
467467+)> {
468468+ let actor = AtIdentifier::new(input)
469469+ .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?;
470470+ let identity = resolver.resolve_ident_owned(&actor).await?;
471471+ if let Some(pds) = &identity.pds_endpoint() {
472472+ let metadata = resolver
473473+ .get_resource_server_metadata(&pds.to_cowstr())
474474+ .await?;
475475+ Ok((metadata, identity))
476476+ } else {
477477+ Err(ResolverError::did_document("Did doc lacking pds"))
478478+ }
479479+}
480480+481481+#[cfg(not(target_arch = "wasm32"))]
482482+async fn get_authorization_server_metadata_impl<T: HttpClient + Sync + ?Sized>(
483483+ client: &T,
484484+ issuer: &CowStr<'_>,
485485+) -> Result<OAuthAuthorizationServerMetadata<'static>> {
486486+ let mut md = resolve_authorization_server(client, issuer).await?;
487487+ md.issuer = issuer.clone().into_static();
488488+ Ok(md)
489489+}
490490+491491+#[cfg(target_arch = "wasm32")]
492492+async fn get_authorization_server_metadata_impl<T: HttpClient + ?Sized>(
493493+ client: &T,
494494+ issuer: &CowStr<'_>,
495495+) -> Result<OAuthAuthorizationServerMetadata<'static>> {
496496+ let mut md = resolve_authorization_server(client, issuer).await?;
497497+ md.issuer = issuer.clone().into_static();
498498+ Ok(md)
499499+}
500500+501501+#[cfg(not(target_arch = "wasm32"))]
502502+async fn get_resource_server_metadata_impl<T: OAuthResolver + Sync + ?Sized>(
503503+ resolver: &T,
504504+ pds: &CowStr<'_>,
505505+) -> Result<OAuthAuthorizationServerMetadata<'static>> {
506506+ let rs_metadata = resolve_protected_resource_info(resolver, pds).await?;
507507+ // ATPROTO requires one, and only one, authorization server entry
508508+ // > That document MUST contain a single item in the authorization_servers array.
509509+ // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
510510+ let issuer = match &rs_metadata.authorization_servers {
511511+ Some(servers) if !servers.is_empty() => {
512512+ if servers.len() > 1 {
513513+ return Err(ResolverError::protected_resource_metadata(
514514+ smol_str::format_smolstr!(
515515+ "unable to determine authorization server for PDS: {pds}"
516516+ ),
517517+ ));
518518+ }
519519+ &servers[0]
520520+ }
521521+ _ => {
522522+ return Err(ResolverError::protected_resource_metadata(
523523+ smol_str::format_smolstr!("no authorization server found for PDS: {pds}"),
524524+ ));
525525+ }
526526+ };
527527+ let as_metadata = resolver.get_authorization_server_metadata(issuer).await?;
528528+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
529529+ if let Some(protected_resources) = &as_metadata.protected_resources {
530530+ let resource_url = rs_metadata
531531+ .resource
532532+ .strip_suffix('/')
533533+ .unwrap_or(rs_metadata.resource.as_str());
534534+ if !protected_resources.contains(&CowStr::Borrowed(resource_url)) {
535535+ return Err(ResolverError::authorization_server_metadata(
536536+ smol_str::format_smolstr!(
537537+ "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}",
538538+ rs_metadata.resource,
539539+ protected_resources
540540+ ),
541541+ ));
542542+ }
543543+ }
544544+545545+ // TODO: atproot specific validation?
546546+ // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
547547+ //
548548+ // eg.
549549+ // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html
550550+ // if as_metadata.client_id_metadata_document_supported != Some(true) {
551551+ // return Err(Error::AuthorizationServerMetadata(format!(
552552+ // "authorization server does not support client_id_metadata_document: {issuer}"
553553+ // )));
554554+ // }
555555+556556+ Ok(as_metadata)
557557+}
558558+559559+#[cfg(target_arch = "wasm32")]
560560+async fn get_resource_server_metadata_impl<T: OAuthResolver + ?Sized>(
561561+ resolver: &T,
562562+ pds: &CowStr<'_>,
563563+) -> Result<OAuthAuthorizationServerMetadata<'static>> {
564564+ let rs_metadata = resolve_protected_resource_info(resolver, pds).await?;
565565+ // ATPROTO requires one, and only one, authorization server entry
566566+ // > That document MUST contain a single item in the authorization_servers array.
567567+ // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
568568+ let issuer = match &rs_metadata.authorization_servers {
569569+ Some(servers) if !servers.is_empty() => {
570570+ if servers.len() > 1 {
571571+ return Err(ResolverError::protected_resource_metadata(
572572+ smol_str::format_smolstr!(
573573+ "unable to determine authorization server for PDS: {pds}"
574574+ ),
575575+ ));
576576+ }
577577+ &servers[0]
578578+ }
579579+ _ => {
580580+ return Err(ResolverError::protected_resource_metadata(
581581+ smol_str::format_smolstr!("no authorization server found for PDS: {pds}"),
582582+ ));
583583+ }
584584+ };
585585+ let as_metadata = resolver.get_authorization_server_metadata(issuer).await?;
586586+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
587587+ if let Some(protected_resources) = &as_metadata.protected_resources {
588588+ let resource_url = rs_metadata
589589+ .resource
590590+ .strip_suffix('/')
591591+ .unwrap_or(rs_metadata.resource.as_str());
592592+ if !protected_resources.contains(&CowStr::Borrowed(resource_url)) {
593593+ return Err(ResolverError::authorization_server_metadata(
594594+ smol_str::format_smolstr!(
595595+ "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}",
596596+ rs_metadata.resource,
597597+ protected_resources
598598+ ),
599599+ ));
600600+ }
601601+ }
602602+603603+ // TODO: atproot specific validation?
604604+ // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
605605+ //
606606+ // eg.
607607+ // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html
608608+ // if as_metadata.client_id_metadata_document_supported != Some(true) {
609609+ // return Err(Error::AuthorizationServerMetadata(format!(
610610+ // "authorization server does not support client_id_metadata_document: {issuer}"
611611+ // )));
612612+ // }
613613+614614+ Ok(as_metadata)
615615+}
616616+617617+/// 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).
627627+#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
628628+pub trait OAuthResolver: IdentityResolver + HttpClient {
629629+ /// Verify that the authorization server in `server_metadata` is the correct issuer for `sub`.
630630+ #[cfg(not(target_arch = "wasm32"))]
631631+ fn verify_issuer(
632632+ &self,
633633+ server_metadata: &OAuthAuthorizationServerMetadata<'_>,
634634+ sub: &Did<'_>,
635635+ ) -> impl Future<Output = Result<Uri<String>>> + Send
636636+ where
637637+ Self: Sync,
638638+ {
639639+ verify_issuer_impl(self, server_metadata, sub)
640640+ }
641641+642642+ /// Verify that the authorization server in `server_metadata` is the correct issuer for `sub`.
643643+ #[cfg(target_arch = "wasm32")]
644644+ fn verify_issuer(
645645+ &self,
646646+ server_metadata: &OAuthAuthorizationServerMetadata<'_>,
647647+ sub: &Did<'_>,
648648+ ) -> impl Future<Output = Result<Uri<String>>> {
649649+ verify_issuer_impl(self, server_metadata, sub)
650650+ }
651651+652652+ /// 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.
658658+ #[cfg(not(target_arch = "wasm32"))]
659659+ fn resolve_oauth(
660660+ &self,
661661+ input: &str,
662662+ ) -> impl Future<
663663+ Output = Result<(
664664+ OAuthAuthorizationServerMetadata<'static>,
665665+ Option<DidDocument<'static>>,
666666+ )>,
667667+ > + Send
668668+ where
669669+ Self: Sync,
670670+ {
671671+ resolve_oauth_impl(self, input)
672672+ }
673673+674674+ /// 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.
680680+ #[cfg(target_arch = "wasm32")]
681681+ fn resolve_oauth(
682682+ &self,
683683+ input: &str,
684684+ ) -> impl Future<
685685+ Output = Result<(
686686+ OAuthAuthorizationServerMetadata<'static>,
687687+ Option<DidDocument<'static>>,
688688+ )>,
689689+ > {
690690+ resolve_oauth_impl(self, input)
691691+ }
692692+693693+ /// 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.
697697+ #[cfg(not(target_arch = "wasm32"))]
698698+ fn resolve_from_service(
699699+ &self,
700700+ input: &CowStr<'_>,
701701+ ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send
702702+ where
703703+ Self: Sync,
704704+ {
705705+ resolve_from_service_impl(self, input)
706706+ }
707707+708708+ /// 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.
712712+ #[cfg(target_arch = "wasm32")]
713713+ fn resolve_from_service(
714714+ &self,
715715+ input: &CowStr<'_>,
716716+ ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> {
717717+ resolve_from_service_impl(self, input)
718718+ }
719719+720720+ /// Resolve an AT identifier (handle or DID) to its authorization server metadata and DID document.
721721+ #[cfg(not(target_arch = "wasm32"))]
722722+ fn resolve_from_identity(
723723+ &self,
724724+ input: &str,
725725+ ) -> impl Future<
726726+ Output = Result<(
727727+ OAuthAuthorizationServerMetadata<'static>,
728728+ DidDocument<'static>,
729729+ )>,
730730+ > + Send
731731+ where
732732+ Self: Sync,
733733+ {
734734+ resolve_from_identity_impl(self, input)
735735+ }
736736+737737+ /// Resolve an AT identifier to its authorization server metadata and DID document.
738738+ #[cfg(target_arch = "wasm32")]
739739+ fn resolve_from_identity(
740740+ &self,
741741+ input: &str,
742742+ ) -> impl Future<
743743+ Output = Result<(
744744+ OAuthAuthorizationServerMetadata<'static>,
745745+ DidDocument<'static>,
746746+ )>,
747747+ > {
748748+ resolve_from_identity_impl(self, input)
749749+ }
750750+751751+ /// 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.
755755+ #[cfg(not(target_arch = "wasm32"))]
756756+ fn get_authorization_server_metadata(
757757+ &self,
758758+ issuer: &CowStr<'_>,
759759+ ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send
760760+ where
761761+ Self: Sync,
762762+ {
763763+ get_authorization_server_metadata_impl(self, issuer)
764764+ }
765765+766766+ /// 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.
770770+ #[cfg(target_arch = "wasm32")]
771771+ fn get_authorization_server_metadata(
772772+ &self,
773773+ issuer: &CowStr<'_>,
774774+ ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> {
775775+ get_authorization_server_metadata_impl(self, issuer)
776776+ }
777777+778778+ /// Resolve a PDS base URL to its authorization server metadata.
779779+ #[cfg(not(target_arch = "wasm32"))]
780780+ fn get_resource_server_metadata(
781781+ &self,
782782+ pds: &CowStr<'_>,
783783+ ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send
784784+ where
785785+ Self: Sync,
786786+ {
787787+ get_resource_server_metadata_impl(self, pds)
788788+ }
789789+790790+ /// Resolve a PDS base URL to its authorization server metadata.
791791+ #[cfg(target_arch = "wasm32")]
792792+ fn get_resource_server_metadata(
793793+ &self,
794794+ pds: &CowStr<'_>,
795795+ ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> {
796796+ get_resource_server_metadata_impl(self, pds)
797797+ }
798798+}
799799+800800+/// 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.
804804+pub async fn resolve_authorization_server<T: HttpClient + ?Sized>(
805805+ client: &T,
806806+ server: &CowStr<'_>,
807807+) -> Result<OAuthAuthorizationServerMetadata<'static>> {
808808+ let url = format!(
809809+ "{}/.well-known/oauth-authorization-server",
810810+ server.trim_end_matches("/")
811811+ );
812812+813813+ let req = Request::builder()
814814+ .uri(url)
815815+ .body(Vec::new())
816816+ .map_err(|e| ResolverError::transport(e))?;
817817+ let res = client
818818+ .send_http(req)
819819+ .await
820820+ .map_err(|e| ResolverError::transport(e))?;
821821+ if res.status() == StatusCode::OK {
822822+ let metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body())?;
823823+ // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3
824824+ if metadata.issuer == server.as_str() {
825825+ Ok(metadata.into_static())
826826+ } else {
827827+ Err(ResolverError::authorization_server_metadata(
828828+ smol_str::format_smolstr!("invalid issuer: {}", metadata.issuer),
829829+ ))
830830+ }
831831+ } else {
832832+ Err(ResolverError::http_status(res.status()))
833833+ }
834834+}
835835+836836+/// 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.
840840+pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>(
841841+ client: &T,
842842+ server: &CowStr<'_>,
843843+) -> Result<OAuthProtectedResourceMetadata<'static>> {
844844+ let url = format!(
845845+ "{}/.well-known/oauth-protected-resource",
846846+ server.trim_end_matches("/")
847847+ );
848848+849849+ let req = Request::builder()
850850+ .uri(url)
851851+ .body(Vec::new())
852852+ .map_err(|e| ResolverError::transport(e))?;
853853+ let res = client
854854+ .send_http(req)
855855+ .await
856856+ .map_err(|e| ResolverError::transport(e))?;
857857+ if res.status() == StatusCode::OK {
858858+ let metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body())?;
859859+ // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3
860860+ if metadata.resource == server.as_str() {
861861+ Ok(metadata.into_static())
862862+ } else {
863863+ Err(ResolverError::authorization_server_metadata(
864864+ smol_str::format_smolstr!("invalid resource: {}", metadata.resource),
865865+ ))
866866+ }
867867+ } else {
868868+ Err(ResolverError::http_status(res.status()))
869869+ }
870870+}
871871+872872+impl OAuthResolver for jacquard_identity::JacquardResolver {}
873873+874874+#[cfg(test)]
875875+mod tests {
876876+ use core::future::Future;
877877+ use std::{convert::Infallible, sync::Arc};
878878+879879+ use super::*;
880880+ use http::{Request as HttpRequest, Response as HttpResponse, StatusCode};
881881+ use jacquard_common::http_client::HttpClient;
882882+ use tokio::sync::Mutex;
883883+884884+ #[derive(Default, Clone)]
885885+ struct MockHttp {
886886+ next: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>,
887887+ }
888888+889889+ impl HttpClient for MockHttp {
890890+ type Error = Infallible;
891891+ fn send_http(
892892+ &self,
893893+ _request: HttpRequest<Vec<u8>>,
894894+ ) -> impl Future<Output = core::result::Result<HttpResponse<Vec<u8>>, Self::Error>> + Send
895895+ {
896896+ let next = self.next.clone();
897897+ async move { Ok(next.lock().await.take().unwrap()) }
898898+ }
899899+ }
900900+901901+ #[tokio::test]
902902+ async fn authorization_server_http_status() {
903903+ let client = MockHttp::default();
904904+ *client.next.lock().await = Some(
905905+ HttpResponse::builder()
906906+ .status(StatusCode::NOT_FOUND)
907907+ .body(Vec::new())
908908+ .unwrap(),
909909+ );
910910+ let issuer = CowStr::new_static("https://issuer");
911911+ let err = super::resolve_authorization_server(&client, &issuer)
912912+ .await
913913+ .unwrap_err();
914914+ assert!(matches!(
915915+ err.kind(),
916916+ ResolverErrorKind::HttpStatus(StatusCode::NOT_FOUND)
917917+ ));
918918+ }
919919+920920+ #[tokio::test]
921921+ async fn authorization_server_bad_json() {
922922+ let client = MockHttp::default();
923923+ *client.next.lock().await = Some(
924924+ HttpResponse::builder()
925925+ .status(StatusCode::OK)
926926+ .body(b"{not json}".to_vec())
927927+ .unwrap(),
928928+ );
929929+ let issuer = CowStr::new_static("https://issuer");
930930+ let err = super::resolve_authorization_server(&client, &issuer)
931931+ .await
932932+ .unwrap_err();
933933+ assert!(matches!(err.kind(), ResolverErrorKind::SerdeJson));
934934+ }
935935+936936+ #[test]
937937+ fn issuer_plain_string_equality() {
938938+ // AC5.1: Matching issuer strings pass comparison
939939+ let issuer1 = CowStr::new_static("https://issuer.example.com");
940940+ let issuer2 = CowStr::new_static("https://issuer.example.com");
941941+ assert_eq!(issuer1, issuer2);
942942+943943+ // AC5.2: Semantically equivalent but string-different issuers fail comparison
944944+ // fluent-uri preserves exact input, so these should NOT be equal
945945+ let issuer_no_slash = CowStr::new_static("https://issuer.example.com");
946946+ let issuer_with_slash = CowStr::new_static("https://issuer.example.com/");
947947+ assert_ne!(issuer_no_slash, issuer_with_slash);
948948+949949+ // AC5.2: Different query/path parameters should also not be equal
950950+ let issuer_base = CowStr::new_static("https://issuer.example.com");
951951+ let issuer_with_path = CowStr::new_static("https://issuer.example.com/path");
952952+ assert_ne!(issuer_base, issuer_with_path);
953953+ }
954954+}
+2020
src-tauri/vendor/jacquard-oauth/src/scopes.rs
···11+//! AT Protocol OAuth scopes
22+//!
33+//! Derived from <https://tangled.org/smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs>
44+//!
55+//! This module provides comprehensive support for AT Protocol OAuth scopes,
66+//! including parsing, serialization, normalization, and permission checking.
77+//!
88+//! Scopes in AT Protocol follow a prefix-based format with optional query parameters:
99+//! - `account`: Access to account information (email, repo, status)
1010+//! - `identity`: Access to identity information (handle)
1111+//! - `blob`: Access to blob operations with mime type constraints
1212+//! - `repo`: Repository operations with collection and action constraints
1313+//! - `rpc`: RPC method access with lexicon and audience constraints
1414+//! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used
1515+//! - `transition`: Migration operations (generic or email)
1616+//!
1717+//! Standard OpenID Connect scopes (no suffixes or query parameters):
1818+//! - `openid`: Required for OpenID Connect authentication
1919+//! - `profile`: Access to user profile information
2020+//! - `email`: Access to user email address
2121+2222+use std::collections::{BTreeMap, BTreeSet};
2323+use std::fmt;
2424+use std::str::FromStr;
2525+2626+use jacquard_common::types::did::Did;
2727+use jacquard_common::types::nsid::Nsid;
2828+use jacquard_common::types::string::AtStrError;
2929+use jacquard_common::{CowStr, IntoStatic};
3030+use serde::de::Visitor;
3131+use serde::{Deserialize, Serialize};
3232+use smol_str::{SmolStr, ToSmolStr};
3333+3434+/// Represents an AT Protocol OAuth scope
3535+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
3636+pub enum Scope<'s> {
3737+ /// Account scope for accessing account information
3838+ Account(AccountScope),
3939+ /// Identity scope for accessing identity information
4040+ Identity(IdentityScope),
4141+ /// Blob scope for blob operations with mime type constraints
4242+ Blob(BlobScope<'s>),
4343+ /// Repository scope for collection operations
4444+ Repo(RepoScope<'s>),
4545+ /// RPC scope for method access
4646+ Rpc(RpcScope<'s>),
4747+ /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used
4848+ Atproto,
4949+ /// Transition scope for migration operations
5050+ Transition(TransitionScope),
5151+ /// OpenID Connect scope - required for OpenID Connect authentication
5252+ OpenId,
5353+ /// Profile scope - access to user profile information
5454+ Profile,
5555+ /// Email scope - access to user email address
5656+ Email,
5757+}
5858+5959+impl Serialize for Scope<'_> {
6060+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
6161+ where
6262+ S: serde::Serializer,
6363+ {
6464+ serializer.serialize_str(&self.to_string_normalized())
6565+ }
6666+}
6767+6868+impl<'de> Deserialize<'de> for Scope<'_> {
6969+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
7070+ where
7171+ D: serde::Deserializer<'de>,
7272+ {
7373+ struct ScopeVisitor;
7474+7575+ impl Visitor<'_> for ScopeVisitor {
7676+ type Value = Scope<'static>;
7777+7878+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
7979+ write!(formatter, "a scope string")
8080+ }
8181+ fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
8282+ where
8383+ E: serde::de::Error,
8484+ {
8585+ Scope::parse(v)
8686+ .map(|s| s.into_static())
8787+ .map_err(|e| serde::de::Error::custom(format!("{:?}", e)))
8888+ }
8989+ }
9090+ deserializer.deserialize_str(ScopeVisitor)
9191+ }
9292+}
9393+9494+impl IntoStatic for Scope<'_> {
9595+ type Output = Scope<'static>;
9696+9797+ fn into_static(self) -> Self::Output {
9898+ match self {
9999+ Scope::Account(scope) => Scope::Account(scope),
100100+ Scope::Identity(scope) => Scope::Identity(scope),
101101+ Scope::Blob(scope) => Scope::Blob(scope.into_static()),
102102+ Scope::Repo(scope) => Scope::Repo(scope.into_static()),
103103+ Scope::Rpc(scope) => Scope::Rpc(scope.into_static()),
104104+ Scope::Atproto => Scope::Atproto,
105105+ Scope::Transition(scope) => Scope::Transition(scope),
106106+ Scope::OpenId => Scope::OpenId,
107107+ Scope::Profile => Scope::Profile,
108108+ Scope::Email => Scope::Email,
109109+ }
110110+ }
111111+}
112112+113113+/// Account scope attributes
114114+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
115115+pub struct AccountScope {
116116+ /// The account resource type
117117+ pub resource: AccountResource,
118118+ /// The action permission level
119119+ pub action: AccountAction,
120120+}
121121+122122+/// Account resource types
123123+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124124+pub enum AccountResource {
125125+ /// Email access
126126+ Email,
127127+ /// Repository access
128128+ Repo,
129129+ /// Status access
130130+ Status,
131131+}
132132+133133+/// Account action permissions
134134+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135135+pub enum AccountAction {
136136+ /// Read-only access
137137+ Read,
138138+ /// Management access (includes read)
139139+ Manage,
140140+}
141141+142142+/// Identity scope attributes
143143+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
144144+pub enum IdentityScope {
145145+ /// Handle access
146146+ Handle,
147147+ /// All identity access (wildcard)
148148+ All,
149149+}
150150+151151+/// Transition scope types
152152+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
153153+pub enum TransitionScope {
154154+ /// Generic transition operations
155155+ Generic,
156156+ /// Bluesky chat / DM transition operations
157157+ ChatBsky,
158158+ /// Email transition operations
159159+ Email,
160160+}
161161+162162+/// Blob scope with mime type constraints
163163+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
164164+pub struct BlobScope<'s> {
165165+ /// Accepted mime types
166166+ pub accept: BTreeSet<MimePattern<'s>>,
167167+}
168168+169169+impl IntoStatic for BlobScope<'_> {
170170+ type Output = BlobScope<'static>;
171171+172172+ fn into_static(self) -> Self::Output {
173173+ BlobScope {
174174+ accept: self.accept.into_iter().map(|p| p.into_static()).collect(),
175175+ }
176176+ }
177177+}
178178+179179+/// MIME type pattern for blob scope
180180+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
181181+pub enum MimePattern<'s> {
182182+ /// Match all types
183183+ All,
184184+ /// Match all subtypes of a type (e.g., "image/*")
185185+ TypeWildcard(CowStr<'s>),
186186+ /// Exact mime type match
187187+ Exact(CowStr<'s>),
188188+}
189189+190190+impl IntoStatic for MimePattern<'_> {
191191+ type Output = MimePattern<'static>;
192192+193193+ fn into_static(self) -> Self::Output {
194194+ match self {
195195+ MimePattern::All => MimePattern::All,
196196+ MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()),
197197+ MimePattern::Exact(s) => MimePattern::Exact(s.into_static()),
198198+ }
199199+ }
200200+}
201201+202202+/// Repository scope with collection and action constraints
203203+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
204204+pub struct RepoScope<'s> {
205205+ /// Collection NSID or wildcard
206206+ pub collection: RepoCollection<'s>,
207207+ /// Allowed actions
208208+ pub actions: BTreeSet<RepoAction>,
209209+}
210210+211211+impl IntoStatic for RepoScope<'_> {
212212+ type Output = RepoScope<'static>;
213213+214214+ fn into_static(self) -> Self::Output {
215215+ RepoScope {
216216+ collection: self.collection.into_static(),
217217+ actions: self.actions,
218218+ }
219219+ }
220220+}
221221+222222+/// Repository collection identifier
223223+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
224224+pub enum RepoCollection<'s> {
225225+ /// All collections (wildcard)
226226+ All,
227227+ /// Specific collection NSID
228228+ Nsid(Nsid<'s>),
229229+}
230230+231231+impl IntoStatic for RepoCollection<'_> {
232232+ type Output = RepoCollection<'static>;
233233+234234+ fn into_static(self) -> Self::Output {
235235+ match self {
236236+ RepoCollection::All => RepoCollection::All,
237237+ RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()),
238238+ }
239239+ }
240240+}
241241+242242+/// Repository actions
243243+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
244244+pub enum RepoAction {
245245+ /// Create records
246246+ Create,
247247+ /// Update records
248248+ Update,
249249+ /// Delete records
250250+ Delete,
251251+}
252252+253253+/// RPC scope with lexicon method and audience constraints
254254+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
255255+pub struct RpcScope<'s> {
256256+ /// Lexicon methods (NSIDs or wildcard)
257257+ pub lxm: BTreeSet<RpcLexicon<'s>>,
258258+ /// Audiences (DIDs or wildcard)
259259+ pub aud: BTreeSet<RpcAudience<'s>>,
260260+}
261261+262262+impl IntoStatic for RpcScope<'_> {
263263+ type Output = RpcScope<'static>;
264264+265265+ fn into_static(self) -> Self::Output {
266266+ RpcScope {
267267+ lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(),
268268+ aud: self.aud.into_iter().map(|s| s.into_static()).collect(),
269269+ }
270270+ }
271271+}
272272+273273+/// RPC lexicon identifier
274274+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
275275+pub enum RpcLexicon<'s> {
276276+ /// All lexicons (wildcard)
277277+ All,
278278+ /// Specific lexicon NSID
279279+ Nsid(Nsid<'s>),
280280+}
281281+282282+impl IntoStatic for RpcLexicon<'_> {
283283+ type Output = RpcLexicon<'static>;
284284+285285+ fn into_static(self) -> Self::Output {
286286+ match self {
287287+ RpcLexicon::All => RpcLexicon::All,
288288+ RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()),
289289+ }
290290+ }
291291+}
292292+293293+/// RPC audience identifier
294294+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
295295+pub enum RpcAudience<'s> {
296296+ /// All audiences (wildcard)
297297+ All,
298298+ /// Specific DID
299299+ Did(Did<'s>),
300300+}
301301+302302+impl IntoStatic for RpcAudience<'_> {
303303+ type Output = RpcAudience<'static>;
304304+305305+ fn into_static(self) -> Self::Output {
306306+ match self {
307307+ RpcAudience::All => RpcAudience::All,
308308+ RpcAudience::Did(did) => RpcAudience::Did(did.into_static()),
309309+ }
310310+ }
311311+}
312312+313313+impl<'s> Scope<'s> {
314314+ /// Parse multiple space-separated scopes from a string
315315+ ///
316316+ /// # Examples
317317+ /// ```
318318+ /// # use jacquard_oauth::scopes::Scope;
319319+ /// let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
320320+ /// assert_eq!(scopes.len(), 2);
321321+ /// ```
322322+ pub fn parse_multiple(s: &'s str) -> Result<Vec<Self>, ParseError> {
323323+ if s.trim().is_empty() {
324324+ return Ok(Vec::new());
325325+ }
326326+327327+ let mut scopes = Vec::new();
328328+ for scope_str in s.split_whitespace() {
329329+ scopes.push(Self::parse(scope_str)?);
330330+ }
331331+332332+ Ok(scopes)
333333+ }
334334+335335+ /// Parse multiple space-separated scopes and return the minimal set needed
336336+ ///
337337+ /// This method removes duplicate scopes and scopes that are already granted
338338+ /// by other scopes in the list, returning only the minimal set of scopes needed.
339339+ ///
340340+ /// # Examples
341341+ /// ```
342342+ /// # use jacquard_oauth::scopes::Scope;
343343+ /// // repo:* grants repo:foo.bar, so only repo:* is kept
344344+ /// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
345345+ /// assert_eq!(scopes.len(), 2); // atproto and repo:*
346346+ /// ```
347347+ pub fn parse_multiple_reduced(s: &'s str) -> Result<Vec<Self>, ParseError> {
348348+ let all_scopes = Self::parse_multiple(s)?;
349349+350350+ if all_scopes.is_empty() {
351351+ return Ok(Vec::new());
352352+ }
353353+354354+ let mut result: Vec<Self> = Vec::new();
355355+356356+ for scope in all_scopes {
357357+ // Check if this scope is already granted by something in the result
358358+ let mut is_granted = false;
359359+ for existing in &result {
360360+ if existing.grants(&scope) && existing != &scope {
361361+ is_granted = true;
362362+ break;
363363+ }
364364+ }
365365+366366+ if is_granted {
367367+ continue; // Skip this scope, it's already covered
368368+ }
369369+370370+ // Check if this scope grants any existing scopes in the result
371371+ let mut indices_to_remove = Vec::new();
372372+ for (i, existing) in result.iter().enumerate() {
373373+ if scope.grants(existing) && &scope != existing {
374374+ indices_to_remove.push(i);
375375+ }
376376+ }
377377+378378+ // Remove scopes that are granted by the new scope (in reverse order to maintain indices)
379379+ for i in indices_to_remove.into_iter().rev() {
380380+ result.remove(i);
381381+ }
382382+383383+ // Add the new scope if it's not a duplicate
384384+ if !result.contains(&scope) {
385385+ result.push(scope);
386386+ }
387387+ }
388388+389389+ Ok(result)
390390+ }
391391+392392+ /// Serialize a list of scopes into a space-separated OAuth scopes string
393393+ ///
394394+ /// The scopes are sorted alphabetically by their string representation to ensure
395395+ /// consistent output regardless of input order.
396396+ ///
397397+ /// # Examples
398398+ /// ```
399399+ /// # use jacquard_oauth::scopes::Scope;
400400+ /// let scopes = vec![
401401+ /// Scope::parse("repo:*").unwrap(),
402402+ /// Scope::parse("atproto").unwrap(),
403403+ /// Scope::parse("account:email").unwrap(),
404404+ /// ];
405405+ /// let result = Scope::serialize_multiple(&scopes);
406406+ /// assert_eq!(result, "account:email atproto repo:*");
407407+ /// ```
408408+ pub fn serialize_multiple(scopes: &[Self]) -> CowStr<'static> {
409409+ if scopes.is_empty() {
410410+ return CowStr::default();
411411+ }
412412+413413+ let mut serialized: Vec<String> = scopes
414414+ .iter()
415415+ .map(|scope| scope.to_string_normalized())
416416+ .collect();
417417+418418+ serialized.sort();
419419+ serialized.join(" ").into()
420420+ }
421421+422422+ /// Remove a scope from a list of scopes
423423+ ///
424424+ /// Returns a new vector with all instances of the specified scope removed.
425425+ /// If the scope doesn't exist in the list, returns a copy of the original list.
426426+ ///
427427+ /// # Examples
428428+ /// ```
429429+ /// # use jacquard_oauth::scopes::Scope;
430430+ /// let scopes = vec![
431431+ /// Scope::parse("repo:*").unwrap(),
432432+ /// Scope::parse("atproto").unwrap(),
433433+ /// Scope::parse("account:email").unwrap(),
434434+ /// ];
435435+ /// let to_remove = Scope::parse("atproto").unwrap();
436436+ /// let result = Scope::remove_scope(&scopes, &to_remove);
437437+ /// assert_eq!(result.len(), 2);
438438+ /// assert!(!result.contains(&to_remove));
439439+ /// ```
440440+ pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> {
441441+ scopes
442442+ .iter()
443443+ .filter(|s| *s != scope_to_remove)
444444+ .cloned()
445445+ .collect()
446446+ }
447447+448448+ /// Parse a scope from a string
449449+ pub fn parse(s: &'s str) -> Result<Self, ParseError> {
450450+ // Determine the prefix first by checking for known prefixes
451451+ let prefixes = [
452452+ "account",
453453+ "identity",
454454+ "blob",
455455+ "repo",
456456+ "rpc",
457457+ "atproto",
458458+ "transition",
459459+ "openid",
460460+ "profile",
461461+ "email",
462462+ ];
463463+ let mut found_prefix = None;
464464+ let mut suffix = None;
465465+466466+ for prefix in &prefixes {
467467+ if let Some(remainder) = s.strip_prefix(prefix)
468468+ && (remainder.is_empty()
469469+ || remainder.starts_with(':')
470470+ || remainder.starts_with('?'))
471471+ {
472472+ found_prefix = Some(*prefix);
473473+ if let Some(stripped) = remainder.strip_prefix(':') {
474474+ suffix = Some(stripped);
475475+ } else if remainder.starts_with('?') {
476476+ suffix = Some(remainder);
477477+ } else {
478478+ suffix = None;
479479+ }
480480+ break;
481481+ }
482482+ }
483483+484484+ let prefix = found_prefix.ok_or_else(|| {
485485+ // If no known prefix found, extract what looks like a prefix for error reporting
486486+ let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len());
487487+ ParseError::UnknownPrefix(s[..end].to_string())
488488+ })?;
489489+490490+ match prefix {
491491+ "account" => Self::parse_account(suffix),
492492+ "identity" => Self::parse_identity(suffix),
493493+ "blob" => Self::parse_blob(suffix),
494494+ "repo" => Self::parse_repo(suffix),
495495+ "rpc" => Self::parse_rpc(suffix),
496496+ "atproto" => Self::parse_atproto(suffix),
497497+ "transition" => Self::parse_transition(suffix),
498498+ "openid" => Self::parse_openid(suffix),
499499+ "profile" => Self::parse_profile(suffix),
500500+ "email" => Self::parse_email(suffix),
501501+ _ => Err(ParseError::UnknownPrefix(prefix.to_string())),
502502+ }
503503+ }
504504+505505+ fn parse_account(suffix: Option<&'s str>) -> Result<Self, ParseError> {
506506+ let (resource_str, params) = match suffix {
507507+ Some(s) => {
508508+ if let Some(pos) = s.find('?') {
509509+ (&s[..pos], Some(&s[pos + 1..]))
510510+ } else {
511511+ (s, None)
512512+ }
513513+ }
514514+ None => return Err(ParseError::MissingResource),
515515+ };
516516+517517+ let resource = match resource_str {
518518+ "email" => AccountResource::Email,
519519+ "repo" => AccountResource::Repo,
520520+ "status" => AccountResource::Status,
521521+ _ => return Err(ParseError::InvalidResource(resource_str.to_string())),
522522+ };
523523+524524+ let action = if let Some(params) = params {
525525+ let parsed_params = parse_query_string(params);
526526+ match parsed_params
527527+ .get("action")
528528+ .and_then(|v| v.first())
529529+ .map(|s| s.as_ref())
530530+ {
531531+ Some("read") => AccountAction::Read,
532532+ Some("manage") => AccountAction::Manage,
533533+ Some(other) => return Err(ParseError::InvalidAction(other.to_string())),
534534+ None => AccountAction::Read,
535535+ }
536536+ } else {
537537+ AccountAction::Read
538538+ };
539539+540540+ Ok(Scope::Account(AccountScope { resource, action }))
541541+ }
542542+543543+ fn parse_identity(suffix: Option<&'s str>) -> Result<Self, ParseError> {
544544+ let scope = match suffix {
545545+ Some("handle") => IdentityScope::Handle,
546546+ Some("*") => IdentityScope::All,
547547+ Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
548548+ None => return Err(ParseError::MissingResource),
549549+ };
550550+551551+ Ok(Scope::Identity(scope))
552552+ }
553553+554554+ fn parse_blob(suffix: Option<&'s str>) -> Result<Self, ParseError> {
555555+ let mut accept = BTreeSet::new();
556556+557557+ match suffix {
558558+ Some(s) if s.starts_with('?') => {
559559+ let params = parse_query_string(&s[1..]);
560560+ if let Some(values) = params.get("accept") {
561561+ for value in values {
562562+ accept.insert(MimePattern::from_str(value)?);
563563+ }
564564+ }
565565+ }
566566+ Some(s) => {
567567+ accept.insert(MimePattern::from_str(s)?);
568568+ }
569569+ None => {
570570+ accept.insert(MimePattern::All);
571571+ }
572572+ }
573573+574574+ if accept.is_empty() {
575575+ accept.insert(MimePattern::All);
576576+ }
577577+578578+ Ok(Scope::Blob(BlobScope { accept }))
579579+ }
580580+581581+ fn parse_repo(suffix: Option<&'s str>) -> Result<Self, ParseError> {
582582+ let (collection_str, params) = match suffix {
583583+ Some(s) => {
584584+ if let Some(pos) = s.find('?') {
585585+ (Some(&s[..pos]), Some(&s[pos + 1..]))
586586+ } else {
587587+ (Some(s), None)
588588+ }
589589+ }
590590+ None => (None, None),
591591+ };
592592+593593+ let collection = match collection_str {
594594+ Some("*") | None => RepoCollection::All,
595595+ Some(nsid) => RepoCollection::Nsid(Nsid::new(nsid)?),
596596+ };
597597+598598+ let mut actions = BTreeSet::new();
599599+ if let Some(params) = params {
600600+ let parsed_params = parse_query_string(params);
601601+ if let Some(values) = parsed_params.get("action") {
602602+ for value in values {
603603+ match value.as_ref() {
604604+ "create" => {
605605+ actions.insert(RepoAction::Create);
606606+ }
607607+ "update" => {
608608+ actions.insert(RepoAction::Update);
609609+ }
610610+ "delete" => {
611611+ actions.insert(RepoAction::Delete);
612612+ }
613613+ "*" => {
614614+ actions.insert(RepoAction::Create);
615615+ actions.insert(RepoAction::Update);
616616+ actions.insert(RepoAction::Delete);
617617+ }
618618+ other => return Err(ParseError::InvalidAction(other.to_string())),
619619+ }
620620+ }
621621+ }
622622+ }
623623+624624+ if actions.is_empty() {
625625+ actions.insert(RepoAction::Create);
626626+ actions.insert(RepoAction::Update);
627627+ actions.insert(RepoAction::Delete);
628628+ }
629629+630630+ Ok(Scope::Repo(RepoScope {
631631+ collection,
632632+ actions,
633633+ }))
634634+ }
635635+636636+ fn parse_rpc(suffix: Option<&'s str>) -> Result<Self, ParseError> {
637637+ let mut lxm = BTreeSet::new();
638638+ let mut aud = BTreeSet::new();
639639+640640+ match suffix {
641641+ Some("*") => {
642642+ lxm.insert(RpcLexicon::All);
643643+ aud.insert(RpcAudience::All);
644644+ }
645645+ Some(s) if s.starts_with('?') => {
646646+ let params = parse_query_string(&s[1..]);
647647+648648+ if let Some(values) = params.get("lxm") {
649649+ for value in values {
650650+ if value.as_ref() == "*" {
651651+ lxm.insert(RpcLexicon::All);
652652+ } else {
653653+ lxm.insert(RpcLexicon::Nsid(Nsid::new(value)?.into_static()));
654654+ }
655655+ }
656656+ }
657657+658658+ if let Some(values) = params.get("aud") {
659659+ for value in values {
660660+ if value.as_ref() == "*" {
661661+ aud.insert(RpcAudience::All);
662662+ } else {
663663+ aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
664664+ }
665665+ }
666666+ }
667667+ }
668668+ Some(s) => {
669669+ // Check if there's a query string in the suffix
670670+ if let Some(pos) = s.find('?') {
671671+ let nsid = &s[..pos];
672672+ let params = parse_query_string(&s[pos + 1..]);
673673+674674+ lxm.insert(RpcLexicon::Nsid(Nsid::new(nsid)?.into_static()));
675675+676676+ if let Some(values) = params.get("aud") {
677677+ for value in values {
678678+ if value.as_ref() == "*" {
679679+ aud.insert(RpcAudience::All);
680680+ } else {
681681+ aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
682682+ }
683683+ }
684684+ }
685685+ } else {
686686+ lxm.insert(RpcLexicon::Nsid(Nsid::new(s)?.into_static()));
687687+ }
688688+ }
689689+ None => {}
690690+ }
691691+692692+ if lxm.is_empty() {
693693+ lxm.insert(RpcLexicon::All);
694694+ }
695695+ if aud.is_empty() {
696696+ aud.insert(RpcAudience::All);
697697+ }
698698+699699+ Ok(Scope::Rpc(RpcScope { lxm, aud }))
700700+ }
701701+702702+ fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> {
703703+ if suffix.is_some() {
704704+ return Err(ParseError::InvalidResource(
705705+ "atproto scope does not accept suffixes".to_string(),
706706+ ));
707707+ }
708708+ Ok(Scope::Atproto)
709709+ }
710710+711711+ fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> {
712712+ let scope = match suffix {
713713+ Some("generic") => TransitionScope::Generic,
714714+ Some("chat.bsky") => TransitionScope::ChatBsky,
715715+ Some("email") => TransitionScope::Email,
716716+ Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
717717+ None => return Err(ParseError::MissingResource),
718718+ };
719719+720720+ Ok(Scope::Transition(scope))
721721+ }
722722+723723+ fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
724724+ if suffix.is_some() {
725725+ return Err(ParseError::InvalidResource(
726726+ "openid scope does not accept suffixes".to_string(),
727727+ ));
728728+ }
729729+ Ok(Scope::OpenId)
730730+ }
731731+732732+ fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> {
733733+ if suffix.is_some() {
734734+ return Err(ParseError::InvalidResource(
735735+ "profile scope does not accept suffixes".to_string(),
736736+ ));
737737+ }
738738+ Ok(Scope::Profile)
739739+ }
740740+741741+ fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> {
742742+ if suffix.is_some() {
743743+ return Err(ParseError::InvalidResource(
744744+ "email scope does not accept suffixes".to_string(),
745745+ ));
746746+ }
747747+ Ok(Scope::Email)
748748+ }
749749+750750+ /// Convert the scope to its normalized string representation
751751+ pub fn to_string_normalized(&self) -> String {
752752+ match self {
753753+ Scope::Account(scope) => {
754754+ let resource = match scope.resource {
755755+ AccountResource::Email => "email",
756756+ AccountResource::Repo => "repo",
757757+ AccountResource::Status => "status",
758758+ };
759759+760760+ match scope.action {
761761+ AccountAction::Read => format!("account:{}", resource),
762762+ AccountAction::Manage => format!("account:{}?action=manage", resource),
763763+ }
764764+ }
765765+ Scope::Identity(scope) => match scope {
766766+ IdentityScope::Handle => "identity:handle".to_string(),
767767+ IdentityScope::All => "identity:*".to_string(),
768768+ },
769769+ Scope::Blob(scope) => {
770770+ if scope.accept.len() == 1 {
771771+ if let Some(pattern) = scope.accept.iter().next() {
772772+ match pattern {
773773+ MimePattern::All => "blob:*/*".to_string(),
774774+ MimePattern::TypeWildcard(t) => format!("blob:{}/*", t),
775775+ MimePattern::Exact(mime) => format!("blob:{}", mime),
776776+ }
777777+ } else {
778778+ "blob:*/*".to_string()
779779+ }
780780+ } else {
781781+ let mut params = Vec::new();
782782+ for pattern in &scope.accept {
783783+ match pattern {
784784+ MimePattern::All => params.push("accept=*/*".to_string()),
785785+ MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)),
786786+ MimePattern::Exact(mime) => params.push(format!("accept={}", mime)),
787787+ }
788788+ }
789789+ params.sort();
790790+ format!("blob?{}", params.join("&"))
791791+ }
792792+ }
793793+ Scope::Repo(scope) => {
794794+ let collection = match &scope.collection {
795795+ RepoCollection::All => "*",
796796+ RepoCollection::Nsid(nsid) => nsid,
797797+ };
798798+799799+ if scope.actions.len() == 3 {
800800+ format!("repo:{}", collection)
801801+ } else {
802802+ let mut params = Vec::new();
803803+ for action in &scope.actions {
804804+ match action {
805805+ RepoAction::Create => params.push("action=create"),
806806+ RepoAction::Update => params.push("action=update"),
807807+ RepoAction::Delete => params.push("action=delete"),
808808+ }
809809+ }
810810+ format!("repo:{}?{}", collection, params.join("&"))
811811+ }
812812+ }
813813+ Scope::Rpc(scope) => {
814814+ if scope.lxm.len() == 1
815815+ && scope.lxm.contains(&RpcLexicon::All)
816816+ && scope.aud.len() == 1
817817+ && scope.aud.contains(&RpcAudience::All)
818818+ {
819819+ "rpc:*".to_string()
820820+ } else if scope.lxm.len() == 1
821821+ && scope.aud.len() == 1
822822+ && scope.aud.contains(&RpcAudience::All)
823823+ {
824824+ if let Some(lxm) = scope.lxm.iter().next() {
825825+ match lxm {
826826+ RpcLexicon::All => "rpc:*".to_string(),
827827+ RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid),
828828+ }
829829+ } else {
830830+ "rpc:*".to_string()
831831+ }
832832+ } else {
833833+ let mut params = Vec::new();
834834+835835+ for lxm in &scope.lxm {
836836+ match lxm {
837837+ RpcLexicon::All => params.push("lxm=*".to_string()),
838838+ RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)),
839839+ }
840840+ }
841841+842842+ for aud in &scope.aud {
843843+ match aud {
844844+ RpcAudience::All => params.push("aud=*".to_string()),
845845+ RpcAudience::Did(did) => params.push(format!("aud={}", did)),
846846+ }
847847+ }
848848+849849+ params.sort();
850850+851851+ if params.is_empty() {
852852+ "rpc:*".to_string()
853853+ } else {
854854+ format!("rpc?{}", params.join("&"))
855855+ }
856856+ }
857857+ }
858858+ Scope::Atproto => "atproto".to_string(),
859859+ Scope::Transition(scope) => match scope {
860860+ TransitionScope::Generic => "transition:generic".to_string(),
861861+ TransitionScope::ChatBsky => "transition:chat.bsky".to_string(),
862862+ TransitionScope::Email => "transition:email".to_string(),
863863+ },
864864+ Scope::OpenId => "openid".to_string(),
865865+ Scope::Profile => "profile".to_string(),
866866+ Scope::Email => "email".to_string(),
867867+ }
868868+ }
869869+870870+ /// Check if this scope grants the permissions of another scope
871871+ pub fn grants(&self, other: &Scope) -> bool {
872872+ match (self, other) {
873873+ // Atproto only grants itself (it's a required scope, not a permission grant)
874874+ (Scope::Atproto, Scope::Atproto) => true,
875875+ (Scope::Atproto, _) => false,
876876+ // Nothing else grants atproto
877877+ (_, Scope::Atproto) => false,
878878+ // Transition scopes only grant themselves
879879+ (Scope::Transition(a), Scope::Transition(b)) => a == b,
880880+ // Other scopes don't grant transition scopes
881881+ (_, Scope::Transition(_)) => false,
882882+ (Scope::Transition(_), _) => false,
883883+ // OpenID Connect scopes only grant themselves
884884+ (Scope::OpenId, Scope::OpenId) => true,
885885+ (Scope::OpenId, _) => false,
886886+ (_, Scope::OpenId) => false,
887887+ (Scope::Profile, Scope::Profile) => true,
888888+ (Scope::Profile, _) => false,
889889+ (_, Scope::Profile) => false,
890890+ (Scope::Email, Scope::Email) => true,
891891+ (Scope::Email, _) => false,
892892+ (_, Scope::Email) => false,
893893+ (Scope::Account(a), Scope::Account(b)) => {
894894+ a.resource == b.resource
895895+ && matches!(
896896+ (a.action, b.action),
897897+ (AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read)
898898+ )
899899+ }
900900+ (Scope::Identity(a), Scope::Identity(b)) => matches!(
901901+ (a, b),
902902+ (IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle)
903903+ ),
904904+ (Scope::Blob(a), Scope::Blob(b)) => {
905905+ for b_pattern in &b.accept {
906906+ let mut granted = false;
907907+ for a_pattern in &a.accept {
908908+ if a_pattern.grants(b_pattern) {
909909+ granted = true;
910910+ break;
911911+ }
912912+ }
913913+ if !granted {
914914+ return false;
915915+ }
916916+ }
917917+ true
918918+ }
919919+ (Scope::Repo(a), Scope::Repo(b)) => {
920920+ let collection_match = match (&a.collection, &b.collection) {
921921+ (RepoCollection::All, _) => true,
922922+ (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => {
923923+ a_nsid == b_nsid
924924+ }
925925+ _ => false,
926926+ };
927927+928928+ if !collection_match {
929929+ return false;
930930+ }
931931+932932+ b.actions.is_subset(&a.actions) || a.actions.len() == 3
933933+ }
934934+ (Scope::Rpc(a), Scope::Rpc(b)) => {
935935+ let lxm_match = if a.lxm.contains(&RpcLexicon::All) {
936936+ true
937937+ } else {
938938+ b.lxm.iter().all(|b_lxm| match b_lxm {
939939+ RpcLexicon::All => false,
940940+ RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm),
941941+ })
942942+ };
943943+944944+ let aud_match = if a.aud.contains(&RpcAudience::All) {
945945+ true
946946+ } else {
947947+ b.aud.iter().all(|b_aud| match b_aud {
948948+ RpcAudience::All => false,
949949+ RpcAudience::Did(_) => a.aud.contains(b_aud),
950950+ })
951951+ };
952952+953953+ lxm_match && aud_match
954954+ }
955955+ _ => false,
956956+ }
957957+ }
958958+}
959959+960960+impl MimePattern<'_> {
961961+ fn grants(&self, other: &MimePattern) -> bool {
962962+ match (self, other) {
963963+ (MimePattern::All, _) => true,
964964+ (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => {
965965+ a_type == b_type
966966+ }
967967+ (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => {
968968+ b_mime.starts_with(&format!("{}/", a_type))
969969+ }
970970+ (MimePattern::Exact(a), MimePattern::Exact(b)) => a == b,
971971+ _ => false,
972972+ }
973973+ }
974974+}
975975+976976+impl FromStr for MimePattern<'_> {
977977+ type Err = ParseError;
978978+979979+ fn from_str(s: &str) -> Result<Self, Self::Err> {
980980+ if s == "*/*" {
981981+ Ok(MimePattern::All)
982982+ } else if let Some(stripped) = s.strip_suffix("/*") {
983983+ Ok(MimePattern::TypeWildcard(CowStr::Owned(
984984+ stripped.to_smolstr(),
985985+ )))
986986+ } else if s.contains('/') {
987987+ Ok(MimePattern::Exact(CowStr::Owned(s.to_smolstr())))
988988+ } else {
989989+ Err(ParseError::InvalidMimeType(s.to_string()))
990990+ }
991991+ }
992992+}
993993+994994+impl FromStr for Scope<'_> {
995995+ type Err = ParseError;
996996+997997+ fn from_str(s: &str) -> Result<Scope<'static>, Self::Err> {
998998+ match Scope::parse(s) {
999999+ Ok(parsed) => Ok(parsed.into_static()),
10001000+ Err(e) => Err(e),
10011001+ }
10021002+ }
10031003+}
10041004+10051005+impl fmt::Display for Scope<'_> {
10061006+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10071007+ write!(f, "{}", self.to_string_normalized())
10081008+ }
10091009+}
10101010+10111011+/// Parse a query string into a map of keys to lists of values
10121012+fn parse_query_string(query: &str) -> BTreeMap<SmolStr, Vec<CowStr<'static>>> {
10131013+ let mut params = BTreeMap::new();
10141014+10151015+ for pair in query.split('&') {
10161016+ if let Some(pos) = pair.find('=') {
10171017+ let key = &pair[..pos];
10181018+ let value = &pair[pos + 1..];
10191019+ params
10201020+ .entry(key.to_smolstr())
10211021+ .or_insert_with(Vec::new)
10221022+ .push(CowStr::Owned(value.to_smolstr()));
10231023+ }
10241024+ }
10251025+10261026+ params
10271027+}
10281028+10291029+/// Error type for scope parsing
10301030+#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
10311031+#[non_exhaustive]
10321032+pub enum ParseError {
10331033+ /// Unknown scope prefix
10341034+ UnknownPrefix(String),
10351035+ /// Missing required resource
10361036+ MissingResource,
10371037+ /// Invalid resource type
10381038+ InvalidResource(String),
10391039+ /// Invalid action type
10401040+ InvalidAction(String),
10411041+ /// Invalid MIME type
10421042+ InvalidMimeType(String),
10431043+ /// An AT Protocol string type (DID, NSID, etc.) failed validation during scope parsing.
10441044+ ParseError(#[from] AtStrError),
10451045+}
10461046+10471047+impl fmt::Display for ParseError {
10481048+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10491049+ match self {
10501050+ ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix),
10511051+ ParseError::MissingResource => write!(f, "Missing required resource"),
10521052+ ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource),
10531053+ ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action),
10541054+ ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime),
10551055+ ParseError::ParseError(err) => write!(f, "Parse error: {}", err),
10561056+ }
10571057+ }
10581058+}
10591059+10601060+#[cfg(test)]
10611061+mod tests {
10621062+ use super::*;
10631063+10641064+ #[test]
10651065+ fn test_account_scope_parsing() {
10661066+ let scope = Scope::parse("account:email").unwrap();
10671067+ assert_eq!(
10681068+ scope,
10691069+ Scope::Account(AccountScope {
10701070+ resource: AccountResource::Email,
10711071+ action: AccountAction::Read,
10721072+ })
10731073+ );
10741074+10751075+ let scope = Scope::parse("account:repo?action=manage").unwrap();
10761076+ assert_eq!(
10771077+ scope,
10781078+ Scope::Account(AccountScope {
10791079+ resource: AccountResource::Repo,
10801080+ action: AccountAction::Manage,
10811081+ })
10821082+ );
10831083+10841084+ let scope = Scope::parse("account:status?action=read").unwrap();
10851085+ assert_eq!(
10861086+ scope,
10871087+ Scope::Account(AccountScope {
10881088+ resource: AccountResource::Status,
10891089+ action: AccountAction::Read,
10901090+ })
10911091+ );
10921092+ }
10931093+10941094+ #[test]
10951095+ fn test_identity_scope_parsing() {
10961096+ let scope = Scope::parse("identity:handle").unwrap();
10971097+ assert_eq!(scope, Scope::Identity(IdentityScope::Handle));
10981098+10991099+ let scope = Scope::parse("identity:*").unwrap();
11001100+ assert_eq!(scope, Scope::Identity(IdentityScope::All));
11011101+ }
11021102+11031103+ #[test]
11041104+ fn test_blob_scope_parsing() {
11051105+ let scope = Scope::parse("blob:*/*").unwrap();
11061106+ let mut accept = BTreeSet::new();
11071107+ accept.insert(MimePattern::All);
11081108+ assert_eq!(scope, Scope::Blob(BlobScope { accept }));
11091109+11101110+ let scope = Scope::parse("blob:image/png").unwrap();
11111111+ let mut accept = BTreeSet::new();
11121112+ accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
11131113+ assert_eq!(scope, Scope::Blob(BlobScope { accept }));
11141114+11151115+ let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap();
11161116+ let mut accept = BTreeSet::new();
11171117+ accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
11181118+ accept.insert(MimePattern::Exact(CowStr::new_static("image/jpeg")));
11191119+ assert_eq!(scope, Scope::Blob(BlobScope { accept }));
11201120+11211121+ let scope = Scope::parse("blob:image/*").unwrap();
11221122+ let mut accept = BTreeSet::new();
11231123+ accept.insert(MimePattern::TypeWildcard(CowStr::new_static("image")));
11241124+ assert_eq!(scope, Scope::Blob(BlobScope { accept }));
11251125+ }
11261126+11271127+ #[test]
11281128+ fn test_repo_scope_parsing() {
11291129+ let scope = Scope::parse("repo:*?action=create").unwrap();
11301130+ let mut actions = BTreeSet::new();
11311131+ actions.insert(RepoAction::Create);
11321132+ assert_eq!(
11331133+ scope,
11341134+ Scope::Repo(RepoScope {
11351135+ collection: RepoCollection::All,
11361136+ actions,
11371137+ })
11381138+ );
11391139+11401140+ let scope = Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap();
11411141+ let mut actions = BTreeSet::new();
11421142+ actions.insert(RepoAction::Create);
11431143+ actions.insert(RepoAction::Update);
11441144+ assert_eq!(
11451145+ scope,
11461146+ Scope::Repo(RepoScope {
11471147+ collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
11481148+ actions,
11491149+ })
11501150+ );
11511151+11521152+ let scope = Scope::parse("repo:app.bsky.feed.post").unwrap();
11531153+ let mut actions = BTreeSet::new();
11541154+ actions.insert(RepoAction::Create);
11551155+ actions.insert(RepoAction::Update);
11561156+ actions.insert(RepoAction::Delete);
11571157+ assert_eq!(
11581158+ scope,
11591159+ Scope::Repo(RepoScope {
11601160+ collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
11611161+ actions,
11621162+ })
11631163+ );
11641164+ }
11651165+11661166+ #[test]
11671167+ fn test_rpc_scope_parsing() {
11681168+ let scope = Scope::parse("rpc:*").unwrap();
11691169+ let mut lxm = BTreeSet::new();
11701170+ let mut aud = BTreeSet::new();
11711171+ lxm.insert(RpcLexicon::All);
11721172+ aud.insert(RpcAudience::All);
11731173+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
11741174+11751175+ let scope = Scope::parse("rpc:com.example.service").unwrap();
11761176+ let mut lxm = BTreeSet::new();
11771177+ let mut aud = BTreeSet::new();
11781178+ lxm.insert(RpcLexicon::Nsid(
11791179+ Nsid::new_static("com.example.service").unwrap(),
11801180+ ));
11811181+ aud.insert(RpcAudience::All);
11821182+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
11831183+11841184+ let scope =
11851185+ Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap();
11861186+ let mut lxm = BTreeSet::new();
11871187+ let mut aud = BTreeSet::new();
11881188+ lxm.insert(RpcLexicon::Nsid(
11891189+ Nsid::new_static("com.example.service").unwrap(),
11901190+ ));
11911191+ aud.insert(RpcAudience::Did(
11921192+ Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
11931193+ ));
11941194+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
11951195+11961196+ let scope =
11971197+ Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g")
11981198+ .unwrap();
11991199+ let mut lxm = BTreeSet::new();
12001200+ let mut aud = BTreeSet::new();
12011201+ lxm.insert(RpcLexicon::Nsid(
12021202+ Nsid::new_static("com.example.method1").unwrap(),
12031203+ ));
12041204+ lxm.insert(RpcLexicon::Nsid(
12051205+ Nsid::new_static("com.example.method2").unwrap(),
12061206+ ));
12071207+ aud.insert(RpcAudience::Did(
12081208+ Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
12091209+ ));
12101210+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
12111211+ }
12121212+12131213+ #[test]
12141214+ fn test_scope_normalization() {
12151215+ let tests = vec![
12161216+ ("account:email", "account:email"),
12171217+ ("account:email?action=read", "account:email"),
12181218+ ("account:email?action=manage", "account:email?action=manage"),
12191219+ ("blob:image/png", "blob:image/png"),
12201220+ (
12211221+ "blob?accept=image/jpeg&accept=image/png",
12221222+ "blob?accept=image/jpeg&accept=image/png",
12231223+ ),
12241224+ ("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"),
12251225+ (
12261226+ "repo:app.bsky.feed.post?action=create",
12271227+ "repo:app.bsky.feed.post?action=create",
12281228+ ),
12291229+ ("rpc:*", "rpc:*"),
12301230+ ];
12311231+12321232+ for (input, expected) in tests {
12331233+ let scope = Scope::parse(input).unwrap();
12341234+ assert_eq!(scope.to_string_normalized(), expected);
12351235+ }
12361236+ }
12371237+12381238+ #[test]
12391239+ fn test_account_scope_grants() {
12401240+ let manage = Scope::parse("account:email?action=manage").unwrap();
12411241+ let read = Scope::parse("account:email?action=read").unwrap();
12421242+ let other_read = Scope::parse("account:repo?action=read").unwrap();
12431243+12441244+ assert!(manage.grants(&read));
12451245+ assert!(manage.grants(&manage));
12461246+ assert!(!read.grants(&manage));
12471247+ assert!(read.grants(&read));
12481248+ assert!(!read.grants(&other_read));
12491249+ }
12501250+12511251+ #[test]
12521252+ fn test_identity_scope_grants() {
12531253+ let all = Scope::parse("identity:*").unwrap();
12541254+ let handle = Scope::parse("identity:handle").unwrap();
12551255+12561256+ assert!(all.grants(&handle));
12571257+ assert!(all.grants(&all));
12581258+ assert!(!handle.grants(&all));
12591259+ assert!(handle.grants(&handle));
12601260+ }
12611261+12621262+ #[test]
12631263+ fn test_blob_scope_grants() {
12641264+ let all = Scope::parse("blob:*/*").unwrap();
12651265+ let image_all = Scope::parse("blob:image/*").unwrap();
12661266+ let image_png = Scope::parse("blob:image/png").unwrap();
12671267+ let text_plain = Scope::parse("blob:text/plain").unwrap();
12681268+12691269+ assert!(all.grants(&image_all));
12701270+ assert!(all.grants(&image_png));
12711271+ assert!(all.grants(&text_plain));
12721272+ assert!(image_all.grants(&image_png));
12731273+ assert!(!image_all.grants(&text_plain));
12741274+ assert!(!image_png.grants(&image_all));
12751275+ }
12761276+12771277+ #[test]
12781278+ fn test_repo_scope_grants() {
12791279+ let all_all = Scope::parse("repo:*").unwrap();
12801280+ let all_create = Scope::parse("repo:*?action=create").unwrap();
12811281+ let specific_all = Scope::parse("repo:app.bsky.feed.post").unwrap();
12821282+ let specific_create = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap();
12831283+ let other_create = Scope::parse("repo:pub.leaflet.publication?action=create").unwrap();
12841284+12851285+ assert!(all_all.grants(&all_create));
12861286+ assert!(all_all.grants(&specific_all));
12871287+ assert!(all_all.grants(&specific_create));
12881288+ assert!(all_create.grants(&all_create));
12891289+ assert!(!all_create.grants(&specific_all));
12901290+ assert!(specific_all.grants(&specific_create));
12911291+ assert!(!specific_create.grants(&specific_all));
12921292+ assert!(!specific_create.grants(&other_create));
12931293+ }
12941294+12951295+ #[test]
12961296+ fn test_rpc_scope_grants() {
12971297+ let all = Scope::parse("rpc:*").unwrap();
12981298+ let specific_lxm = Scope::parse("rpc:com.example.service").unwrap();
12991299+ let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
13001300+13011301+ assert!(all.grants(&specific_lxm));
13021302+ assert!(all.grants(&specific_both));
13031303+ assert!(specific_lxm.grants(&specific_both));
13041304+ assert!(!specific_both.grants(&specific_lxm));
13051305+ assert!(!specific_both.grants(&all));
13061306+ }
13071307+13081308+ #[test]
13091309+ fn test_cross_scope_grants() {
13101310+ let account = Scope::parse("account:email").unwrap();
13111311+ let identity = Scope::parse("identity:handle").unwrap();
13121312+13131313+ assert!(!account.grants(&identity));
13141314+ assert!(!identity.grants(&account));
13151315+ }
13161316+13171317+ #[test]
13181318+ fn test_parse_errors() {
13191319+ assert!(matches!(
13201320+ Scope::parse("unknown:test"),
13211321+ Err(ParseError::UnknownPrefix(_))
13221322+ ));
13231323+13241324+ assert!(matches!(
13251325+ Scope::parse("account"),
13261326+ Err(ParseError::MissingResource)
13271327+ ));
13281328+13291329+ assert!(matches!(
13301330+ Scope::parse("account:invalid"),
13311331+ Err(ParseError::InvalidResource(_))
13321332+ ));
13331333+13341334+ assert!(matches!(
13351335+ Scope::parse("account:email?action=invalid"),
13361336+ Err(ParseError::InvalidAction(_))
13371337+ ));
13381338+ }
13391339+13401340+ #[test]
13411341+ fn test_query_parameter_sorting() {
13421342+ let scope =
13431343+ Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap();
13441344+ let normalized = scope.to_string_normalized();
13451345+ assert!(normalized.contains("accept=application/pdf"));
13461346+ assert!(normalized.contains("accept=image/jpeg"));
13471347+ assert!(normalized.contains("accept=image/png"));
13481348+ let pdf_pos = normalized.find("accept=application/pdf").unwrap();
13491349+ let jpeg_pos = normalized.find("accept=image/jpeg").unwrap();
13501350+ let png_pos = normalized.find("accept=image/png").unwrap();
13511351+ assert!(pdf_pos < jpeg_pos);
13521352+ assert!(jpeg_pos < png_pos);
13531353+ }
13541354+13551355+ #[test]
13561356+ fn test_repo_action_wildcard() {
13571357+ let scope = Scope::parse("repo:app.bsky.feed.post?action=*").unwrap();
13581358+ let mut actions = BTreeSet::new();
13591359+ actions.insert(RepoAction::Create);
13601360+ actions.insert(RepoAction::Update);
13611361+ actions.insert(RepoAction::Delete);
13621362+ assert_eq!(
13631363+ scope,
13641364+ Scope::Repo(RepoScope {
13651365+ collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
13661366+ actions,
13671367+ })
13681368+ );
13691369+ }
13701370+13711371+ #[test]
13721372+ fn test_multiple_blob_accepts() {
13731373+ let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap();
13741374+ assert!(scope.grants(&Scope::parse("blob:image/png").unwrap()));
13751375+ assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap()));
13761376+ assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap()));
13771377+ }
13781378+13791379+ #[test]
13801380+ fn test_rpc_default_wildcards() {
13811381+ let scope = Scope::parse("rpc").unwrap();
13821382+ let mut lxm = BTreeSet::new();
13831383+ let mut aud = BTreeSet::new();
13841384+ lxm.insert(RpcLexicon::All);
13851385+ aud.insert(RpcAudience::All);
13861386+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
13871387+ }
13881388+13891389+ #[test]
13901390+ fn test_atproto_scope_parsing() {
13911391+ let scope = Scope::parse("atproto").unwrap();
13921392+ assert_eq!(scope, Scope::Atproto);
13931393+13941394+ // Atproto should not accept suffixes
13951395+ assert!(Scope::parse("atproto:something").is_err());
13961396+ assert!(Scope::parse("atproto?param=value").is_err());
13971397+ }
13981398+13991399+ #[test]
14001400+ fn test_transition_scope_parsing() {
14011401+ let scope = Scope::parse("transition:generic").unwrap();
14021402+ assert_eq!(scope, Scope::Transition(TransitionScope::Generic));
14031403+14041404+ let scope = Scope::parse("transition:chat.bsky").unwrap();
14051405+ assert_eq!(scope, Scope::Transition(TransitionScope::ChatBsky));
14061406+14071407+ let scope = Scope::parse("transition:email").unwrap();
14081408+ assert_eq!(scope, Scope::Transition(TransitionScope::Email));
14091409+14101410+ // Test invalid transition types
14111411+ assert!(matches!(
14121412+ Scope::parse("transition:invalid"),
14131413+ Err(ParseError::InvalidResource(_))
14141414+ ));
14151415+14161416+ // Test missing suffix
14171417+ assert!(matches!(
14181418+ Scope::parse("transition"),
14191419+ Err(ParseError::MissingResource)
14201420+ ));
14211421+14221422+ // Test transition doesn't accept query parameters
14231423+ assert!(matches!(
14241424+ Scope::parse("transition:generic?param=value"),
14251425+ Err(ParseError::InvalidResource(_))
14261426+ ));
14271427+ }
14281428+14291429+ #[test]
14301430+ fn test_atproto_scope_normalization() {
14311431+ let scope = Scope::parse("atproto").unwrap();
14321432+ assert_eq!(scope.to_string_normalized(), "atproto");
14331433+ }
14341434+14351435+ #[test]
14361436+ fn test_transition_scope_normalization() {
14371437+ let tests = vec![
14381438+ ("transition:generic", "transition:generic"),
14391439+ ("transition:email", "transition:email"),
14401440+ ];
14411441+14421442+ for (input, expected) in tests {
14431443+ let scope = Scope::parse(input).unwrap();
14441444+ assert_eq!(scope.to_string_normalized(), expected);
14451445+ }
14461446+ }
14471447+14481448+ #[test]
14491449+ fn test_atproto_scope_grants() {
14501450+ let atproto = Scope::parse("atproto").unwrap();
14511451+ let account = Scope::parse("account:email").unwrap();
14521452+ let identity = Scope::parse("identity:handle").unwrap();
14531453+ let blob = Scope::parse("blob:image/png").unwrap();
14541454+ let repo = Scope::parse("repo:app.bsky.feed.post").unwrap();
14551455+ let rpc = Scope::parse("rpc:com.example.service").unwrap();
14561456+ let transition_generic = Scope::parse("transition:generic").unwrap();
14571457+ let transition_email = Scope::parse("transition:email").unwrap();
14581458+14591459+ // Atproto only grants itself (it's a required scope, not a permission grant)
14601460+ assert!(atproto.grants(&atproto));
14611461+ assert!(!atproto.grants(&account));
14621462+ assert!(!atproto.grants(&identity));
14631463+ assert!(!atproto.grants(&blob));
14641464+ assert!(!atproto.grants(&repo));
14651465+ assert!(!atproto.grants(&rpc));
14661466+ assert!(!atproto.grants(&transition_generic));
14671467+ assert!(!atproto.grants(&transition_email));
14681468+14691469+ // Nothing else grants atproto
14701470+ assert!(!account.grants(&atproto));
14711471+ assert!(!identity.grants(&atproto));
14721472+ assert!(!blob.grants(&atproto));
14731473+ assert!(!repo.grants(&atproto));
14741474+ assert!(!rpc.grants(&atproto));
14751475+ assert!(!transition_generic.grants(&atproto));
14761476+ assert!(!transition_email.grants(&atproto));
14771477+ }
14781478+14791479+ #[test]
14801480+ fn test_transition_scope_grants() {
14811481+ let transition_generic = Scope::parse("transition:generic").unwrap();
14821482+ let transition_email = Scope::parse("transition:email").unwrap();
14831483+ let account = Scope::parse("account:email").unwrap();
14841484+14851485+ // Transition scopes only grant themselves
14861486+ assert!(transition_generic.grants(&transition_generic));
14871487+ assert!(transition_email.grants(&transition_email));
14881488+ assert!(!transition_generic.grants(&transition_email));
14891489+ assert!(!transition_email.grants(&transition_generic));
14901490+14911491+ // Transition scopes don't grant other scope types
14921492+ assert!(!transition_generic.grants(&account));
14931493+ assert!(!transition_email.grants(&account));
14941494+14951495+ // Other scopes don't grant transition scopes
14961496+ assert!(!account.grants(&transition_generic));
14971497+ assert!(!account.grants(&transition_email));
14981498+ }
14991499+15001500+ #[test]
15011501+ fn test_parse_multiple() {
15021502+ // Test parsing multiple scopes
15031503+ let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
15041504+ assert_eq!(scopes.len(), 2);
15051505+ assert_eq!(scopes[0], Scope::Atproto);
15061506+ assert_eq!(
15071507+ scopes[1],
15081508+ Scope::Repo(RepoScope {
15091509+ collection: RepoCollection::All,
15101510+ actions: {
15111511+ let mut actions = BTreeSet::new();
15121512+ actions.insert(RepoAction::Create);
15131513+ actions.insert(RepoAction::Update);
15141514+ actions.insert(RepoAction::Delete);
15151515+ actions
15161516+ }
15171517+ })
15181518+ );
15191519+15201520+ // Test with more scopes
15211521+ let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap();
15221522+ assert_eq!(scopes.len(), 3);
15231523+ assert!(matches!(scopes[0], Scope::Account(_)));
15241524+ assert!(matches!(scopes[1], Scope::Identity(_)));
15251525+ assert!(matches!(scopes[2], Scope::Blob(_)));
15261526+15271527+ // Test with complex scopes
15281528+ let scopes = Scope::parse_multiple(
15291529+ "account:email?action=manage repo:app.bsky.feed.post?action=create transition:email",
15301530+ )
15311531+ .unwrap();
15321532+ assert_eq!(scopes.len(), 3);
15331533+15341534+ // Test empty string
15351535+ let scopes = Scope::parse_multiple("").unwrap();
15361536+ assert_eq!(scopes.len(), 0);
15371537+15381538+ // Test whitespace only
15391539+ let scopes = Scope::parse_multiple(" ").unwrap();
15401540+ assert_eq!(scopes.len(), 0);
15411541+15421542+ // Test with extra whitespace
15431543+ let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap();
15441544+ assert_eq!(scopes.len(), 2);
15451545+15461546+ // Test single scope
15471547+ let scopes = Scope::parse_multiple("atproto").unwrap();
15481548+ assert_eq!(scopes.len(), 1);
15491549+ assert_eq!(scopes[0], Scope::Atproto);
15501550+15511551+ // Test error propagation
15521552+ assert!(Scope::parse_multiple("atproto invalid:scope").is_err());
15531553+ assert!(Scope::parse_multiple("account:invalid repo:*").is_err());
15541554+ }
15551555+15561556+ #[test]
15571557+ fn test_parse_multiple_reduced() {
15581558+ // Test repo scope reduction - wildcard grants specific
15591559+ let scopes =
15601560+ Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
15611561+ assert_eq!(scopes.len(), 2);
15621562+ assert!(scopes.contains(&Scope::Atproto));
15631563+ assert!(scopes.contains(&Scope::Repo(RepoScope {
15641564+ collection: RepoCollection::All,
15651565+ actions: {
15661566+ let mut actions = BTreeSet::new();
15671567+ actions.insert(RepoAction::Create);
15681568+ actions.insert(RepoAction::Update);
15691569+ actions.insert(RepoAction::Delete);
15701570+ actions
15711571+ }
15721572+ })));
15731573+15741574+ // Test reverse order - should get same result
15751575+ let scopes =
15761576+ Scope::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post").unwrap();
15771577+ assert_eq!(scopes.len(), 2);
15781578+ assert!(scopes.contains(&Scope::Atproto));
15791579+ assert!(scopes.contains(&Scope::Repo(RepoScope {
15801580+ collection: RepoCollection::All,
15811581+ actions: {
15821582+ let mut actions = BTreeSet::new();
15831583+ actions.insert(RepoAction::Create);
15841584+ actions.insert(RepoAction::Update);
15851585+ actions.insert(RepoAction::Delete);
15861586+ actions
15871587+ }
15881588+ })));
15891589+15901590+ // Test account scope reduction - manage grants read
15911591+ let scopes =
15921592+ Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap();
15931593+ assert_eq!(scopes.len(), 1);
15941594+ assert_eq!(
15951595+ scopes[0],
15961596+ Scope::Account(AccountScope {
15971597+ resource: AccountResource::Email,
15981598+ action: AccountAction::Manage,
15991599+ })
16001600+ );
16011601+16021602+ // Test identity scope reduction - wildcard grants specific
16031603+ let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap();
16041604+ assert_eq!(scopes.len(), 1);
16051605+ assert_eq!(scopes[0], Scope::Identity(IdentityScope::All));
16061606+16071607+ // Test blob scope reduction - wildcard grants specific
16081608+ let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap();
16091609+ assert_eq!(scopes.len(), 1);
16101610+ let mut accept = BTreeSet::new();
16111611+ accept.insert(MimePattern::All);
16121612+ assert_eq!(scopes[0], Scope::Blob(BlobScope { accept }));
16131613+16141614+ // Test no reduction needed - different scope types
16151615+ let scopes =
16161616+ Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap();
16171617+ assert_eq!(scopes.len(), 3);
16181618+16191619+ // Test repo action reduction
16201620+ let scopes = Scope::parse_multiple_reduced(
16211621+ "repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post",
16221622+ )
16231623+ .unwrap();
16241624+ assert_eq!(scopes.len(), 1);
16251625+ assert_eq!(
16261626+ scopes[0],
16271627+ Scope::Repo(RepoScope {
16281628+ collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
16291629+ actions: {
16301630+ let mut actions = BTreeSet::new();
16311631+ actions.insert(RepoAction::Create);
16321632+ actions.insert(RepoAction::Update);
16331633+ actions.insert(RepoAction::Delete);
16341634+ actions
16351635+ }
16361636+ })
16371637+ );
16381638+16391639+ // Test RPC scope reduction
16401640+ let scopes = Scope::parse_multiple_reduced(
16411641+ "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*",
16421642+ )
16431643+ .unwrap();
16441644+ assert_eq!(scopes.len(), 1);
16451645+ assert_eq!(
16461646+ scopes[0],
16471647+ Scope::Rpc(RpcScope {
16481648+ lxm: {
16491649+ let mut lxm = BTreeSet::new();
16501650+ lxm.insert(RpcLexicon::All);
16511651+ lxm
16521652+ },
16531653+ aud: {
16541654+ let mut aud = BTreeSet::new();
16551655+ aud.insert(RpcAudience::All);
16561656+ aud
16571657+ }
16581658+ })
16591659+ );
16601660+16611661+ // Test duplicate removal
16621662+ let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap();
16631663+ assert_eq!(scopes.len(), 1);
16641664+ assert_eq!(scopes[0], Scope::Atproto);
16651665+16661666+ // Test transition scopes - only grant themselves
16671667+ let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap();
16681668+ assert_eq!(scopes.len(), 2);
16691669+ assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic)));
16701670+ assert!(scopes.contains(&Scope::Transition(TransitionScope::Email)));
16711671+16721672+ // Test empty input
16731673+ let scopes = Scope::parse_multiple_reduced("").unwrap();
16741674+ assert_eq!(scopes.len(), 0);
16751675+16761676+ // Test complex scenario with multiple reductions
16771677+ let scopes = Scope::parse_multiple_reduced(
16781678+ "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle"
16791679+ ).unwrap();
16801680+ assert_eq!(scopes.len(), 3);
16811681+ // Should have: account:email?action=manage, account:repo, identity:*
16821682+ assert!(scopes.contains(&Scope::Account(AccountScope {
16831683+ resource: AccountResource::Email,
16841684+ action: AccountAction::Manage,
16851685+ })));
16861686+ assert!(scopes.contains(&Scope::Account(AccountScope {
16871687+ resource: AccountResource::Repo,
16881688+ action: AccountAction::Read,
16891689+ })));
16901690+ assert!(scopes.contains(&Scope::Identity(IdentityScope::All)));
16911691+16921692+ // Test that atproto doesn't grant other scopes (per recent change)
16931693+ let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap();
16941694+ assert_eq!(scopes.len(), 3);
16951695+ assert!(scopes.contains(&Scope::Atproto));
16961696+ assert!(scopes.contains(&Scope::Account(AccountScope {
16971697+ resource: AccountResource::Email,
16981698+ action: AccountAction::Read,
16991699+ })));
17001700+ assert!(scopes.contains(&Scope::Repo(RepoScope {
17011701+ collection: RepoCollection::All,
17021702+ actions: {
17031703+ let mut actions = BTreeSet::new();
17041704+ actions.insert(RepoAction::Create);
17051705+ actions.insert(RepoAction::Update);
17061706+ actions.insert(RepoAction::Delete);
17071707+ actions
17081708+ }
17091709+ })));
17101710+ }
17111711+17121712+ #[test]
17131713+ fn test_openid_connect_scope_parsing() {
17141714+ // Test OpenID scope
17151715+ let scope = Scope::parse("openid").unwrap();
17161716+ assert_eq!(scope, Scope::OpenId);
17171717+17181718+ // Test Profile scope
17191719+ let scope = Scope::parse("profile").unwrap();
17201720+ assert_eq!(scope, Scope::Profile);
17211721+17221722+ // Test Email scope
17231723+ let scope = Scope::parse("email").unwrap();
17241724+ assert_eq!(scope, Scope::Email);
17251725+17261726+ // Test that they don't accept suffixes
17271727+ assert!(Scope::parse("openid:something").is_err());
17281728+ assert!(Scope::parse("profile:something").is_err());
17291729+ assert!(Scope::parse("email:something").is_err());
17301730+17311731+ // Test that they don't accept query parameters
17321732+ assert!(Scope::parse("openid?param=value").is_err());
17331733+ assert!(Scope::parse("profile?param=value").is_err());
17341734+ assert!(Scope::parse("email?param=value").is_err());
17351735+ }
17361736+17371737+ #[test]
17381738+ fn test_openid_connect_scope_normalization() {
17391739+ let scope = Scope::parse("openid").unwrap();
17401740+ assert_eq!(scope.to_string_normalized(), "openid");
17411741+17421742+ let scope = Scope::parse("profile").unwrap();
17431743+ assert_eq!(scope.to_string_normalized(), "profile");
17441744+17451745+ let scope = Scope::parse("email").unwrap();
17461746+ assert_eq!(scope.to_string_normalized(), "email");
17471747+ }
17481748+17491749+ #[test]
17501750+ fn test_openid_connect_scope_grants() {
17511751+ let openid = Scope::parse("openid").unwrap();
17521752+ let profile = Scope::parse("profile").unwrap();
17531753+ let email = Scope::parse("email").unwrap();
17541754+ let account = Scope::parse("account:email").unwrap();
17551755+17561756+ // OpenID Connect scopes only grant themselves
17571757+ assert!(openid.grants(&openid));
17581758+ assert!(!openid.grants(&profile));
17591759+ assert!(!openid.grants(&email));
17601760+ assert!(!openid.grants(&account));
17611761+17621762+ assert!(profile.grants(&profile));
17631763+ assert!(!profile.grants(&openid));
17641764+ assert!(!profile.grants(&email));
17651765+ assert!(!profile.grants(&account));
17661766+17671767+ assert!(email.grants(&email));
17681768+ assert!(!email.grants(&openid));
17691769+ assert!(!email.grants(&profile));
17701770+ assert!(!email.grants(&account));
17711771+17721772+ // Other scopes don't grant OpenID Connect scopes
17731773+ assert!(!account.grants(&openid));
17741774+ assert!(!account.grants(&profile));
17751775+ assert!(!account.grants(&email));
17761776+ }
17771777+17781778+ #[test]
17791779+ fn test_parse_multiple_with_openid_connect() {
17801780+ let scopes = Scope::parse_multiple("openid profile email atproto").unwrap();
17811781+ assert_eq!(scopes.len(), 4);
17821782+ assert_eq!(scopes[0], Scope::OpenId);
17831783+ assert_eq!(scopes[1], Scope::Profile);
17841784+ assert_eq!(scopes[2], Scope::Email);
17851785+ assert_eq!(scopes[3], Scope::Atproto);
17861786+17871787+ // Test with mixed scopes
17881788+ let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap();
17891789+ assert_eq!(scopes.len(), 4);
17901790+ assert!(scopes.contains(&Scope::OpenId));
17911791+ assert!(scopes.contains(&Scope::Profile));
17921792+ }
17931793+17941794+ #[test]
17951795+ fn test_parse_multiple_reduced_with_openid_connect() {
17961796+ // OpenID Connect scopes don't grant each other, so no reduction
17971797+ let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap();
17981798+ assert_eq!(scopes.len(), 3);
17991799+ assert!(scopes.contains(&Scope::OpenId));
18001800+ assert!(scopes.contains(&Scope::Profile));
18011801+ assert!(scopes.contains(&Scope::Email));
18021802+18031803+ // Mixed with other scopes
18041804+ let scopes = Scope::parse_multiple_reduced(
18051805+ "openid account:email account:email?action=manage profile",
18061806+ )
18071807+ .unwrap();
18081808+ assert_eq!(scopes.len(), 3);
18091809+ assert!(scopes.contains(&Scope::OpenId));
18101810+ assert!(scopes.contains(&Scope::Profile));
18111811+ assert!(scopes.contains(&Scope::Account(AccountScope {
18121812+ resource: AccountResource::Email,
18131813+ action: AccountAction::Manage,
18141814+ })));
18151815+ }
18161816+18171817+ #[test]
18181818+ fn test_serialize_multiple() {
18191819+ // Test empty list
18201820+ let scopes: Vec<Scope> = vec![];
18211821+ assert_eq!(Scope::serialize_multiple(&scopes), "");
18221822+18231823+ // Test single scope
18241824+ let scopes = vec![Scope::Atproto];
18251825+ assert_eq!(Scope::serialize_multiple(&scopes), "atproto");
18261826+18271827+ // Test multiple scopes - should be sorted alphabetically
18281828+ let scopes = vec![
18291829+ Scope::parse("repo:*").unwrap(),
18301830+ Scope::Atproto,
18311831+ Scope::parse("account:email").unwrap(),
18321832+ ];
18331833+ assert_eq!(
18341834+ Scope::serialize_multiple(&scopes),
18351835+ "account:email atproto repo:*"
18361836+ );
18371837+18381838+ // Test that sorting is consistent regardless of input order
18391839+ let scopes = vec![
18401840+ Scope::parse("identity:handle").unwrap(),
18411841+ Scope::parse("blob:image/png").unwrap(),
18421842+ Scope::parse("account:repo?action=manage").unwrap(),
18431843+ ];
18441844+ assert_eq!(
18451845+ Scope::serialize_multiple(&scopes),
18461846+ "account:repo?action=manage blob:image/png identity:handle"
18471847+ );
18481848+18491849+ // Test with OpenID Connect scopes
18501850+ let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto];
18511851+ assert_eq!(
18521852+ Scope::serialize_multiple(&scopes),
18531853+ "atproto email openid profile"
18541854+ );
18551855+18561856+ // Test with complex scopes including query parameters
18571857+ let scopes = vec![
18581858+ Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method")
18591859+ .unwrap(),
18601860+ Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(),
18611861+ Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(),
18621862+ ];
18631863+ let result = Scope::serialize_multiple(&scopes);
18641864+ // The result should be sorted alphabetically
18651865+ // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..."
18661866+ assert!(result.starts_with("blob:"));
18671867+ assert!(result.contains(" repo:"));
18681868+ assert!(
18691869+ result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service")
18701870+ );
18711871+18721872+ // Test with transition scopes
18731873+ let scopes = vec![
18741874+ Scope::Transition(TransitionScope::ChatBsky),
18751875+ Scope::Transition(TransitionScope::Email),
18761876+ Scope::Transition(TransitionScope::Generic),
18771877+ Scope::Atproto,
18781878+ ];
18791879+ assert_eq!(
18801880+ Scope::serialize_multiple(&scopes),
18811881+ "atproto transition:chat.bsky transition:email transition:generic"
18821882+ );
18831883+18841884+ // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed)
18851885+ let scopes = vec![
18861886+ Scope::Atproto,
18871887+ Scope::Atproto,
18881888+ Scope::parse("account:email").unwrap(),
18891889+ ];
18901890+ assert_eq!(
18911891+ Scope::serialize_multiple(&scopes),
18921892+ "account:email atproto atproto"
18931893+ );
18941894+18951895+ // Test normalization is preserved in serialization
18961896+ let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()];
18971897+ // Should normalize query parameters alphabetically
18981898+ assert_eq!(
18991899+ Scope::serialize_multiple(&scopes),
19001900+ "blob?accept=image/jpeg&accept=image/png"
19011901+ );
19021902+ }
19031903+19041904+ #[test]
19051905+ fn test_serialize_multiple_roundtrip() {
19061906+ // Test that parse_multiple and serialize_multiple are inverses (when sorted)
19071907+ let original = "account:email atproto blob:image/png identity:handle repo:*";
19081908+ let scopes = Scope::parse_multiple(original).unwrap();
19091909+ let serialized = Scope::serialize_multiple(&scopes);
19101910+ assert_eq!(serialized, original);
19111911+19121912+ // Test with complex scopes
19131913+ let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*";
19141914+ let scopes = Scope::parse_multiple(original).unwrap();
19151915+ let serialized = Scope::serialize_multiple(&scopes);
19161916+ // Parse again to verify it's valid
19171917+ let reparsed = Scope::parse_multiple(&serialized).unwrap();
19181918+ assert_eq!(scopes, reparsed);
19191919+19201920+ // Test with OpenID Connect scopes
19211921+ let original = "email openid profile";
19221922+ let scopes = Scope::parse_multiple(original).unwrap();
19231923+ let serialized = Scope::serialize_multiple(&scopes);
19241924+ assert_eq!(serialized, original);
19251925+ }
19261926+19271927+ #[test]
19281928+ fn test_remove_scope() {
19291929+ // Test removing a scope that exists
19301930+ let scopes = vec![
19311931+ Scope::parse("repo:*").unwrap(),
19321932+ Scope::Atproto,
19331933+ Scope::parse("account:email").unwrap(),
19341934+ ];
19351935+ let to_remove = Scope::Atproto;
19361936+ let result = Scope::remove_scope(&scopes, &to_remove);
19371937+ assert_eq!(result.len(), 2);
19381938+ assert!(!result.contains(&to_remove));
19391939+ assert!(result.contains(&Scope::parse("repo:*").unwrap()));
19401940+ assert!(result.contains(&Scope::parse("account:email").unwrap()));
19411941+19421942+ // Test removing a scope that doesn't exist
19431943+ let scopes = vec![
19441944+ Scope::parse("repo:*").unwrap(),
19451945+ Scope::parse("account:email").unwrap(),
19461946+ ];
19471947+ let to_remove = Scope::parse("identity:handle").unwrap();
19481948+ let result = Scope::remove_scope(&scopes, &to_remove);
19491949+ assert_eq!(result.len(), 2);
19501950+ assert_eq!(result, scopes);
19511951+19521952+ // Test removing from empty list
19531953+ let scopes: Vec<Scope> = vec![];
19541954+ let to_remove = Scope::Atproto;
19551955+ let result = Scope::remove_scope(&scopes, &to_remove);
19561956+ assert_eq!(result.len(), 0);
19571957+19581958+ // Test removing all instances of a duplicate scope
19591959+ let scopes = vec![
19601960+ Scope::Atproto,
19611961+ Scope::parse("account:email").unwrap(),
19621962+ Scope::Atproto,
19631963+ Scope::parse("repo:*").unwrap(),
19641964+ Scope::Atproto,
19651965+ ];
19661966+ let to_remove = Scope::Atproto;
19671967+ let result = Scope::remove_scope(&scopes, &to_remove);
19681968+ assert_eq!(result.len(), 2);
19691969+ assert!(!result.contains(&to_remove));
19701970+ assert!(result.contains(&Scope::parse("account:email").unwrap()));
19711971+ assert!(result.contains(&Scope::parse("repo:*").unwrap()));
19721972+19731973+ // Test removing complex scopes with query parameters
19741974+ let scopes = vec![
19751975+ Scope::parse("account:email?action=manage").unwrap(),
19761976+ Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(),
19771977+ Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
19781978+ ];
19791979+ let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order
19801980+ let result = Scope::remove_scope(&scopes, &to_remove);
19811981+ assert_eq!(result.len(), 2);
19821982+ assert!(!result.contains(&to_remove));
19831983+19841984+ // Test with OpenID Connect scopes
19851985+ let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto];
19861986+ let to_remove = Scope::Profile;
19871987+ let result = Scope::remove_scope(&scopes, &to_remove);
19881988+ assert_eq!(result.len(), 3);
19891989+ assert!(!result.contains(&to_remove));
19901990+ assert!(result.contains(&Scope::OpenId));
19911991+ assert!(result.contains(&Scope::Email));
19921992+ assert!(result.contains(&Scope::Atproto));
19931993+19941994+ // Test with transition scopes
19951995+ let scopes = vec![
19961996+ Scope::Transition(TransitionScope::Generic),
19971997+ Scope::Transition(TransitionScope::Email),
19981998+ Scope::Atproto,
19991999+ ];
20002000+ let to_remove = Scope::Transition(TransitionScope::Email);
20012001+ let result = Scope::remove_scope(&scopes, &to_remove);
20022002+ assert_eq!(result.len(), 2);
20032003+ assert!(!result.contains(&to_remove));
20042004+ assert!(result.contains(&Scope::Transition(TransitionScope::Generic)));
20052005+ assert!(result.contains(&Scope::Atproto));
20062006+20072007+ // Test that only exact matches are removed
20082008+ let scopes = vec![
20092009+ Scope::parse("account:email").unwrap(),
20102010+ Scope::parse("account:email?action=manage").unwrap(),
20112011+ Scope::parse("account:repo").unwrap(),
20122012+ ];
20132013+ let to_remove = Scope::parse("account:email").unwrap();
20142014+ let result = Scope::remove_scope(&scopes, &to_remove);
20152015+ assert_eq!(result.len(), 2);
20162016+ assert!(!result.contains(&Scope::parse("account:email").unwrap()));
20172017+ assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap()));
20182018+ assert!(result.contains(&Scope::parse("account:repo").unwrap()));
20192019+ }
20202020+}
+535
src-tauri/vendor/jacquard-oauth/src/session.rs
···11+use std::sync::Arc;
22+33+use chrono::TimeDelta;
44+55+use crate::{
66+ atproto::{AtprotoClientMetadata, atproto_client_metadata},
77+ authstore::ClientAuthStore,
88+ dpop::DpopExt,
99+ keyset::Keyset,
1010+ request::{OAuthMetadata, refresh},
1111+ resolver::OAuthResolver,
1212+ scopes::Scope,
1313+ types::TokenSet,
1414+};
1515+1616+use dashmap::DashMap;
1717+use jacquard_common::{
1818+ CowStr, IntoStatic,
1919+ deps::fluent_uri::Uri,
2020+ http_client::HttpClient,
2121+ session::SessionStoreError,
2222+ types::{did::Did, string::Datetime},
2323+};
2424+use jose_jwk::Key;
2525+use serde::{Deserialize, Serialize};
2626+use smol_str::{SmolStr, format_smolstr};
2727+use tokio::sync::Mutex;
2828+2929+/// 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.
3636+pub trait DpopDataSource {
3737+ /// Return the private JWK used to sign DPoP proofs.
3838+ fn key(&self) -> &Key;
3939+ /// Return the most recently observed nonce from the authorization server, if any.
4040+ fn authserver_nonce(&self) -> Option<CowStr<'_>>;
4141+ /// Persist a new nonce received from the authorization server.
4242+ fn set_authserver_nonce(&mut self, nonce: CowStr<'_>);
4343+ /// Return the most recently observed nonce from the resource server (PDS), if any.
4444+ fn host_nonce(&self) -> Option<CowStr<'_>>;
4545+ /// Persist a new nonce received from the resource server (PDS).
4646+ fn set_host_nonce(&mut self, nonce: CowStr<'_>);
4747+}
4848+4949+/// Persisted information about an OAuth session. Used to resume an active session.
5050+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
5151+pub struct ClientSessionData<'s> {
5252+ /// DID of the authenticated account; serves as the primary key for session storage
5353+ /// because only one active session per account is assumed.
5454+ #[serde(borrow)]
5555+ pub account_did: Did<'s>,
5656+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.
6060+ pub session_id: CowStr<'s>,
6161+6262+ /// Base URL of the resource server (PDS): scheme, host, and port only
6363+ pub host_url: Uri<String>,
6464+6565+ /// Base URL of the authorization server (PDS or entryway): scheme, host, and port only
6666+ pub authserver_url: CowStr<'s>,
6767+6868+ /// Full URL of the authorization server's token endpoint.
6969+ pub authserver_token_endpoint: CowStr<'s>,
7070+7171+ /// Full URL of the authorization server's revocation endpoint, if advertised.
7272+ #[serde(skip_serializing_if = "std::option::Option::is_none")]
7373+ pub authserver_revocation_endpoint: Option<CowStr<'s>>,
7474+7575+ /// The set of OAuth scopes approved for this session, as returned in the initial token response.
7676+ pub scopes: Vec<Scope<'s>>,
7777+7878+ /// DPoP key and nonce state for ongoing requests in this session.
7979+ #[serde(flatten)]
8080+ pub dpop_data: DpopClientData<'s>,
8181+8282+ /// Current token set (access token, refresh token, expiry, etc.).
8383+ #[serde(flatten)]
8484+ pub token_set: TokenSet<'s>,
8585+}
8686+8787+impl IntoStatic for ClientSessionData<'_> {
8888+ type Output = ClientSessionData<'static>;
8989+9090+ fn into_static(self) -> Self::Output {
9191+ ClientSessionData {
9292+ authserver_url: self.authserver_url.into_static(),
9393+ authserver_token_endpoint: self.authserver_token_endpoint.into_static(),
9494+ authserver_revocation_endpoint: self
9595+ .authserver_revocation_endpoint
9696+ .map(IntoStatic::into_static),
9797+ scopes: self.scopes.into_static(),
9898+ dpop_data: self.dpop_data.into_static(),
9999+ token_set: self.token_set.into_static(),
100100+ account_did: self.account_did.into_static(),
101101+ session_id: self.session_id.into_static(),
102102+ host_url: self.host_url.clone(),
103103+ }
104104+ }
105105+}
106106+107107+impl 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.
112112+ pub fn update_with_tokens(&mut self, token_set: TokenSet<'_>) {
113113+ if let Some(Ok(scopes)) = token_set
114114+ .scope
115115+ .as_ref()
116116+ .map(|scope| Scope::parse_multiple_reduced(&scope).map(IntoStatic::into_static))
117117+ {
118118+ self.scopes = scopes;
119119+ }
120120+ self.token_set = token_set.into_static();
121121+ }
122122+}
123123+124124+/// 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.
128128+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
129129+pub struct DpopClientData<'s> {
130130+ /// The private JWK bound to this session; used to sign all DPoP proofs.
131131+ pub dpop_key: Key,
132132+ /// Most recently observed DPoP nonce from the authorization server.
133133+ #[serde(borrow)]
134134+ pub dpop_authserver_nonce: CowStr<'s>,
135135+ /// Most recently observed DPoP nonce from the resource server (PDS).
136136+ pub dpop_host_nonce: CowStr<'s>,
137137+}
138138+139139+impl IntoStatic for DpopClientData<'_> {
140140+ type Output = DpopClientData<'static>;
141141+142142+ fn into_static(self) -> Self::Output {
143143+ DpopClientData {
144144+ dpop_key: self.dpop_key,
145145+ dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(),
146146+ dpop_host_nonce: self.dpop_host_nonce.into_static(),
147147+ }
148148+ }
149149+}
150150+151151+impl DpopDataSource for DpopClientData<'_> {
152152+ fn key(&self) -> &Key {
153153+ &self.dpop_key
154154+ }
155155+ fn authserver_nonce(&self) -> Option<CowStr<'_>> {
156156+ Some(self.dpop_authserver_nonce.clone())
157157+ }
158158+159159+ fn host_nonce(&self) -> Option<CowStr<'_>> {
160160+ Some(self.dpop_host_nonce.clone())
161161+ }
162162+163163+ fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) {
164164+ self.dpop_authserver_nonce = nonce.into_static();
165165+ }
166166+167167+ fn set_host_nonce(&mut self, nonce: CowStr<'_>) {
168168+ self.dpop_host_nonce = nonce.into_static();
169169+ }
170170+}
171171+172172+/// 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`].
177177+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178178+pub struct AuthRequestData<'s> {
179179+ /// Random identifier generated for this authorization request; used as the primary key
180180+ /// for storing and looking up this record during the callback.
181181+ #[serde(borrow)]
182182+ pub state: CowStr<'s>,
183183+184184+ /// Base URL of the authorization server that was selected for this flow.
185185+ pub authserver_url: CowStr<'s>,
186186+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.
189189+ #[serde(skip_serializing_if = "std::option::Option::is_none")]
190190+ pub account_did: Option<Did<'s>>,
191191+192192+ /// OAuth scopes requested for this authorization.
193193+ pub scopes: Vec<Scope<'s>>,
194194+195195+ /// The PAR `request_uri` returned by the authorization server; included in the redirect URL.
196196+ pub request_uri: CowStr<'s>,
197197+198198+ /// Full URL of the authorization server's token endpoint.
199199+ pub authserver_token_endpoint: CowStr<'s>,
200200+201201+ /// Full URL of the authorization server's revocation endpoint, if advertised.
202202+ #[serde(skip_serializing_if = "std::option::Option::is_none")]
203203+ pub authserver_revocation_endpoint: Option<CowStr<'s>>,
204204+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.
207207+ pub pkce_verifier: CowStr<'s>,
208208+209209+ /// DPoP key and any authserver nonce observed during the PAR request.
210210+ #[serde(flatten)]
211211+ pub dpop_data: DpopReqData<'s>,
212212+}
213213+214214+impl IntoStatic for AuthRequestData<'_> {
215215+ type Output = AuthRequestData<'static>;
216216+ fn into_static(self) -> AuthRequestData<'static> {
217217+ AuthRequestData {
218218+ request_uri: self.request_uri.into_static(),
219219+ authserver_token_endpoint: self.authserver_token_endpoint.into_static(),
220220+ authserver_revocation_endpoint: self
221221+ .authserver_revocation_endpoint
222222+ .map(|s| s.into_static()),
223223+ pkce_verifier: self.pkce_verifier.into_static(),
224224+ dpop_data: self.dpop_data.into_static(),
225225+ state: self.state.into_static(),
226226+ authserver_url: self.authserver_url.into_static(),
227227+ account_did: self.account_did.into_static(),
228228+ scopes: self.scopes.into_static(),
229229+ }
230230+ }
231231+}
232232+233233+/// 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.
237237+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
238238+pub struct DpopReqData<'s> {
239239+ /// The private JWK generated fresh for this authorization request and session.
240240+ pub dpop_key: Key,
241241+ /// DPoP nonce received from the authorization server during the PAR exchange, if any.
242242+ #[serde(borrow)]
243243+ pub dpop_authserver_nonce: Option<CowStr<'s>>,
244244+}
245245+246246+impl IntoStatic for DpopReqData<'_> {
247247+ type Output = DpopReqData<'static>;
248248+ fn into_static(self) -> DpopReqData<'static> {
249249+ DpopReqData {
250250+ dpop_key: self.dpop_key,
251251+ dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(),
252252+ }
253253+ }
254254+}
255255+256256+impl DpopDataSource for DpopReqData<'_> {
257257+ fn key(&self) -> &Key {
258258+ &self.dpop_key
259259+ }
260260+ fn authserver_nonce(&self) -> Option<CowStr<'_>> {
261261+ self.dpop_authserver_nonce.clone()
262262+ }
263263+264264+ fn host_nonce(&self) -> Option<CowStr<'_>> {
265265+ None
266266+ }
267267+268268+ fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) {
269269+ self.dpop_authserver_nonce = Some(nonce.into_static());
270270+ }
271271+272272+ fn set_host_nonce(&mut self, _nonce: CowStr<'_>) {}
273273+}
274274+275275+/// 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`].
279279+#[derive(Clone, Debug)]
280280+pub 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.
283283+ pub keyset: Option<Keyset>,
284284+ /// AT Protocol-specific client registration metadata (redirect URIs, scopes, etc.).
285285+ pub config: AtprotoClientMetadata<'s>,
286286+}
287287+288288+impl<'s> IntoStatic for ClientData<'s> {
289289+ type Output = ClientData<'static>;
290290+ fn into_static(self) -> ClientData<'static> {
291291+ ClientData {
292292+ keyset: self.keyset,
293293+ config: self.config.into_static(),
294294+ }
295295+ }
296296+}
297297+298298+impl<'s> ClientData<'s> {
299299+ /// Create `ClientData` with an optional signing keyset and the given client metadata.
300300+ pub fn new(keyset: Option<Keyset>, config: AtprotoClientMetadata<'s>) -> Self {
301301+ Self { keyset, config }
302302+ }
303303+304304+ /// 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.
308308+ pub fn new_public(config: AtprotoClientMetadata<'s>) -> Self {
309309+ Self {
310310+ keyset: None,
311311+ config,
312312+ }
313313+ }
314314+}
315315+316316+/// 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.
321321+pub struct ClientSession<'s> {
322322+ /// Optional signing keyset, forwarded from [`ClientData`].
323323+ pub keyset: Option<Keyset>,
324324+ /// Client registration metadata, forwarded from [`ClientData`].
325325+ pub config: AtprotoClientMetadata<'s>,
326326+ /// The session state for the authenticated account.
327327+ pub session_data: ClientSessionData<'s>,
328328+}
329329+330330+impl<'s> ClientSession<'s> {
331331+ /// Construct a `ClientSession` from a [`ClientData`] and an active session.
332332+ pub fn new(
333333+ ClientData { keyset, config }: ClientData<'s>,
334334+ session_data: ClientSessionData<'s>,
335335+ ) -> Self {
336336+ Self {
337337+ keyset,
338338+ config,
339339+ session_data,
340340+ }
341341+ }
342342+343343+ /// Fetch and assemble an [`OAuthMetadata`] for the authorization server of this session.
344344+ pub async fn metadata<T: HttpClient + OAuthResolver + Send + Sync>(
345345+ &self,
346346+ client: &T,
347347+ ) -> Result<OAuthMetadata, Error> {
348348+ Ok(OAuthMetadata {
349349+ server_metadata: client
350350+ .get_authorization_server_metadata(&self.session_data.authserver_url)
351351+ .await
352352+ .map_err(|e| Error::ServerAgent(crate::request::RequestError::resolver(e)))?,
353353+ client_metadata: atproto_client_metadata(self.config.clone(), &self.keyset)
354354+ .unwrap()
355355+ .into_static(),
356356+ keyset: self.keyset.clone(),
357357+ })
358358+ }
359359+}
360360+361361+/// Errors that can occur during OAuth session management.
362362+#[derive(thiserror::Error, Debug, miette::Diagnostic)]
363363+#[non_exhaustive]
364364+pub enum Error {
365365+ /// A token-endpoint or metadata operation failed.
366366+ #[error(transparent)]
367367+ #[diagnostic(code(jacquard_oauth::session::request))]
368368+ ServerAgent(#[from] crate::request::RequestError),
369369+ /// The backing session store returned an error.
370370+ #[error(transparent)]
371371+ #[diagnostic(code(jacquard_oauth::session::storage))]
372372+ Store(#[from] SessionStoreError),
373373+ /// The requested session does not exist in the store.
374374+ #[error("session does not exist")]
375375+ #[diagnostic(code(jacquard_oauth::session::not_found))]
376376+ 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.
379379+ #[error("session refresh failed permanently")]
380380+ #[diagnostic(
381381+ code(jacquard_oauth::session::refresh_failed),
382382+ help("the session has been cleared - user must re-authenticate")
383383+ )]
384384+ RefreshFailed(#[source] crate::request::RequestError),
385385+}
386386+387387+impl Error {
388388+ /// Returns true if this error indicates a permanent auth failure
389389+ /// where the user needs to re-authenticate.
390390+ pub fn is_permanent(&self) -> bool {
391391+ match self {
392392+ Error::RefreshFailed(_) => true,
393393+ Error::SessionNotFound => true,
394394+ Error::ServerAgent(e) => e.is_permanent(),
395395+ Error::Store(_) => false,
396396+ }
397397+ }
398398+}
399399+400400+/// 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.
406406+pub struct SessionRegistry<T, S>
407407+where
408408+ T: OAuthResolver,
409409+ S: ClientAuthStore,
410410+{
411411+ /// Backing store for persisting session data across process restarts.
412412+ pub store: Arc<S>,
413413+ /// Shared resolver used to fetch authorization server metadata during refresh.
414414+ pub client: Arc<T>,
415415+ /// Static client configuration (keyset and registration metadata).
416416+ pub client_data: ClientData<'static>,
417417+ /// Per-`(DID, session_id)` mutex that serializes concurrent refresh attempts.
418418+ pending: DashMap<SmolStr, Arc<Mutex<()>>>,
419419+}
420420+421421+impl<T, S> SessionRegistry<T, S>
422422+where
423423+ S: ClientAuthStore,
424424+ T: OAuthResolver,
425425+{
426426+ /// Create a new registry, taking ownership of the store.
427427+ pub fn new(store: S, client: Arc<T>, client_data: ClientData<'static>) -> Self {
428428+ let store = Arc::new(store);
429429+ Self {
430430+ store: Arc::clone(&store),
431431+ client,
432432+ client_data,
433433+ pending: DashMap::new(),
434434+ }
435435+ }
436436+437437+ /// 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.
441441+ pub fn new_shared(store: Arc<S>, client: Arc<T>, client_data: ClientData<'static>) -> Self {
442442+ Self {
443443+ store,
444444+ client,
445445+ client_data,
446446+ pending: DashMap::new(),
447447+ }
448448+ }
449449+}
450450+451451+impl<T, S> SessionRegistry<T, S>
452452+where
453453+ S: ClientAuthStore + Send + Sync + 'static,
454454+ T: OAuthResolver + DpopExt + Send + Sync + 'static,
455455+{
456456+ async fn get_refreshed(
457457+ &self,
458458+ did: &Did<'_>,
459459+ session_id: &str,
460460+ ) -> Result<ClientSessionData<'_>, Error> {
461461+ let key = format_smolstr!("{}_{}", did, session_id);
462462+ let lock = self
463463+ .pending
464464+ .entry(key)
465465+ .or_insert_with(|| Arc::new(Mutex::new(())))
466466+ .clone();
467467+ let _guard = lock.lock().await;
468468+469469+ let session = self
470470+ .store
471471+ .get_session(did, session_id)
472472+ .await?
473473+ .ok_or(Error::SessionNotFound)?;
474474+475475+ // Check if token is still valid with a 60-second buffer before expiry.
476476+ // This triggers proactive refresh before the token actually expires,
477477+ // avoiding the race condition where a token expires mid-request.
478478+ const EXPIRY_BUFFER_SECS: i64 = 60;
479479+ if let Some(expires_at) = &session.token_set.expires_at {
480480+ let now_with_buffer = Datetime::now()
481481+ .as_ref()
482482+ .checked_add_signed(TimeDelta::seconds(EXPIRY_BUFFER_SECS))
483483+ .map(Datetime::new)
484484+ .unwrap_or_else(Datetime::now);
485485+ if expires_at > &now_with_buffer {
486486+ return Ok(session);
487487+ }
488488+ }
489489+ let metadata =
490490+ OAuthMetadata::new(self.client.as_ref(), &self.client_data, &session).await?;
491491+ match refresh(self.client.as_ref(), session, &metadata).await {
492492+ Ok(refreshed) => {
493493+ self.store.upsert_session(refreshed.clone()).await?;
494494+ Ok(refreshed)
495495+ }
496496+ Err(e) if e.is_permanent() => {
497497+ // Session is permanently dead - clean it up
498498+ let _ = self.store.delete_session(did, session_id).await;
499499+ Err(Error::RefreshFailed(e))
500500+ }
501501+ Err(e) => Err(Error::ServerAgent(e)),
502502+ }
503503+ }
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.
509509+ pub async fn get(
510510+ &self,
511511+ did: &Did<'_>,
512512+ session_id: &str,
513513+ refresh: bool,
514514+ ) -> Result<ClientSessionData<'_>, Error> {
515515+ if refresh {
516516+ self.get_refreshed(did, session_id).await
517517+ } else {
518518+ // TODO: cached?
519519+ self.store
520520+ .get_session(did, session_id)
521521+ .await?
522522+ .ok_or(Error::SessionNotFound)
523523+ }
524524+ }
525525+ /// Persist an updated session to the backing store.
526526+ pub async fn set(&self, value: ClientSessionData<'_>) -> Result<(), Error> {
527527+ self.store.upsert_session(value).await?;
528528+ Ok(())
529529+ }
530530+ /// Delete a session from the backing store.
531531+ pub async fn del(&self, did: &Did<'_>, session_id: &str) -> Result<(), Error> {
532532+ self.store.delete_session(did, session_id).await?;
533533+ Ok(())
534534+ }
535535+}
+119
src-tauri/vendor/jacquard-oauth/src/types.rs
···11+mod client_metadata;
22+mod metadata;
33+mod request;
44+mod response;
55+mod token;
66+77+use crate::scopes::Scope;
88+99+pub use self::client_metadata::*;
1010+pub use self::metadata::*;
1111+pub use self::request::*;
1212+pub use self::response::*;
1313+pub use self::token::*;
1414+use jacquard_common::CowStr;
1515+use jacquard_common::IntoStatic;
1616+use jacquard_common::deps::fluent_uri::Uri;
1717+use serde::Deserialize;
1818+1919+/// 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.
2323+#[derive(Debug, Deserialize, Clone, Copy)]
2424+pub enum AuthorizeOptionPrompt {
2525+ /// Prompt the user to re-authenticate.
2626+ Login,
2727+ /// Do not display any authentication or consent UI; fail if interaction is required.
2828+ None,
2929+ /// Prompt the user for explicit consent before issuing tokens.
3030+ Consent,
3131+ /// Prompt the user to select an account when multiple sessions are active.
3232+ SelectAccount,
3333+}
3434+3535+impl From<AuthorizeOptionPrompt> for CowStr<'static> {
3636+ fn from(value: AuthorizeOptionPrompt) -> Self {
3737+ match value {
3838+ AuthorizeOptionPrompt::Login => CowStr::new_static("login"),
3939+ AuthorizeOptionPrompt::None => CowStr::new_static("none"),
4040+ AuthorizeOptionPrompt::Consent => CowStr::new_static("consent"),
4141+ AuthorizeOptionPrompt::SelectAccount => CowStr::new_static("select_account"),
4242+ }
4343+ }
4444+}
4545+4646+/// Options for initiating an OAuth authorization request.
4747+#[derive(Debug)]
4848+pub struct AuthorizeOptions<'s> {
4949+ /// Override the redirect URI registered in the client metadata.
5050+ pub redirect_uri: Option<Uri<String>>,
5151+ /// Scopes to request. Defaults to an empty list (server-defined defaults apply).
5252+ pub scopes: Vec<Scope<'s>>,
5353+ /// Optional prompt hint for the authorization server's UI.
5454+ pub prompt: Option<AuthorizeOptionPrompt>,
5555+ /// Opaque client-provided state value, echoed back in the callback for CSRF protection.
5656+ pub state: Option<CowStr<'s>>,
5757+}
5858+5959+impl Default for AuthorizeOptions<'_> {
6060+ fn default() -> Self {
6161+ Self {
6262+ redirect_uri: None,
6363+ scopes: vec![],
6464+ prompt: None,
6565+ state: None,
6666+ }
6767+ }
6868+}
6969+7070+impl<'s> AuthorizeOptions<'s> {
7171+ /// Set the `prompt` parameter sent to the authorization server.
7272+ pub fn with_prompt(mut self, prompt: AuthorizeOptionPrompt) -> Self {
7373+ self.prompt = Some(prompt);
7474+ self
7575+ }
7676+7777+ /// Set a CSRF-protection `state` value to be echoed in the callback.
7878+ pub fn with_state(mut self, state: CowStr<'s>) -> Self {
7979+ self.state = Some(state);
8080+ self
8181+ }
8282+8383+ /// Override the redirect URI for this specific authorization request.
8484+ pub fn with_redirect_uri(mut self, redirect_uri: Uri<String>) -> Self {
8585+ self.redirect_uri = Some(redirect_uri);
8686+ self
8787+ }
8888+8989+ /// Set the OAuth scopes to request.
9090+ pub fn with_scopes(mut self, scopes: Vec<Scope<'s>>) -> Self {
9191+ self.scopes = scopes;
9292+ self
9393+ }
9494+}
9595+9696+/// Query parameters delivered to the OAuth redirect URI after user authorization.
9797+#[derive(Debug, Deserialize)]
9898+pub struct CallbackParams<'s> {
9999+ /// The authorization code issued by the authorization server.
100100+ #[serde(borrow)]
101101+ pub code: CowStr<'s>,
102102+ /// The `state` value originally sent in the authorization request, used to
103103+ /// verify the response belongs to this session.
104104+ pub state: Option<CowStr<'s>>,
105105+ /// The `iss` (issuer) parameter, required by RFC 9207 to prevent mix-up attacks.
106106+ pub iss: Option<CowStr<'s>>,
107107+}
108108+109109+impl IntoStatic for CallbackParams<'_> {
110110+ type Output = CallbackParams<'static>;
111111+112112+ fn into_static(self) -> Self::Output {
113113+ CallbackParams {
114114+ code: self.code.into_static(),
115115+ state: self.state.map(|s| s.into_static()),
116116+ iss: self.iss.map(|s| s.into_static()),
117117+ }
118118+ }
119119+}
···11+use jacquard_common::{CowStr, IntoStatic};
22+use serde::{Deserialize, Serialize};
33+44+/// The `response_type` parameter for an OAuth 2.0 authorization request.
55+///
66+/// Determines what the authorization server returns in the redirect response.
77+#[derive(Serialize, Deserialize, Debug)]
88+#[serde(rename_all = "snake_case")]
99+pub enum AuthorizationResponseType {
1010+ /// Authorization code flow — server returns a short-lived code for token exchange.
1111+ Code,
1212+ /// Implicit flow — server returns an access token directly (not recommended for new clients).
1313+ Token,
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)).
1616+ IdToken,
1717+}
1818+1919+/// The `response_mode` parameter controlling how the authorization response is returned.
2020+///
2121+/// Defaults to `query` for `code` response type and `fragment` for `token`.
2222+#[derive(Serialize, Deserialize, Debug)]
2323+#[serde(rename_all = "snake_case")]
2424+pub enum AuthorizationResponseMode {
2525+ /// Parameters are appended as query string components to the redirect URI.
2626+ Query,
2727+ /// Parameters are appended as URI fragment components to the redirect URI.
2828+ Fragment,
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>
3232+ FormPost,
3333+}
3434+3535+/// 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.
3939+#[derive(Serialize, Deserialize, Debug)]
4040+pub enum AuthorizationCodeChallengeMethod {
4141+ /// SHA-256 hash of the code verifier, base64url-encoded (recommended).
4242+ S256,
4343+ /// Raw code verifier used as the challenge (not recommended).
4444+ #[serde(rename = "plain")]
4545+ Plain,
4646+}
4747+4848+/// 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.
5353+#[derive(Serialize, Deserialize, Debug)]
5454+pub struct ParParameters<'a> {
5555+ /// The response type to request (e.g. `code`).
5656+ ///
5757+ /// <https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1>
5858+ pub response_type: AuthorizationResponseType,
5959+ /// The redirect URI where the authorization response will be sent.
6060+ #[serde(borrow)]
6161+ pub redirect_uri: CowStr<'a>,
6262+ /// An opaque CSRF state value to be echoed back in the callback.
6363+ pub state: CowStr<'a>,
6464+ /// Space-separated list of requested scopes.
6565+ pub scope: Option<CowStr<'a>>,
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>
6969+ pub response_mode: Option<AuthorizationResponseMode>,
7070+ /// The PKCE code challenge derived from the code verifier.
7171+ ///
7272+ /// <https://datatracker.ietf.org/doc/html/rfc7636#section-4.3>
7373+ pub code_challenge: CowStr<'a>,
7474+ /// The method used to derive the code challenge.
7575+ pub code_challenge_method: AuthorizationCodeChallengeMethod,
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>
7979+ pub login_hint: Option<CowStr<'a>>,
8080+ /// Prompt hint controlling authorization server UI behavior.
8181+ pub prompt: Option<CowStr<'a>>,
8282+}
8383+8484+/// The `grant_type` parameter for a token endpoint request.
8585+#[derive(Serialize, Deserialize)]
8686+#[serde(rename_all = "snake_case")]
8787+pub enum TokenGrantType {
8888+ /// Exchange an authorization code for tokens.
8989+ AuthorizationCode,
9090+ /// Use a refresh token to obtain a new access token.
9191+ RefreshToken,
9292+}
9393+9494+/// Parameters for exchanging an authorization code for tokens (RFC 6749 §4.1.3).
9595+#[derive(Serialize, Deserialize)]
9696+pub struct TokenRequestParameters<'a> {
9797+ /// Must be `authorization_code` for the authorization code grant.
9898+ ///
9999+ /// <https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3>
100100+ pub grant_type: TokenGrantType,
101101+ /// The authorization code received from the authorization server.
102102+ #[serde(borrow)]
103103+ pub code: CowStr<'a>,
104104+ /// The redirect URI used in the original authorization request.
105105+ pub redirect_uri: CowStr<'a>,
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>
109109+ pub code_verifier: CowStr<'a>,
110110+}
111111+112112+/// Parameters for refreshing an access token using a refresh token (RFC 6749 §6).
113113+#[derive(Serialize, Deserialize)]
114114+pub struct RefreshRequestParameters<'a> {
115115+ /// Must be `refresh_token` for the refresh grant.
116116+ ///
117117+ /// <https://datatracker.ietf.org/doc/html/rfc6749#section-6>
118118+ pub grant_type: TokenGrantType,
119119+ /// The refresh token previously issued to the client.
120120+ #[serde(borrow)]
121121+ pub refresh_token: CowStr<'a>,
122122+ /// Optional scope to request; must not exceed the originally granted scope.
123123+ pub scope: Option<CowStr<'a>>,
124124+}
125125+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>
132132+#[derive(Serialize, Deserialize)]
133133+pub struct RevocationRequestParameters<'a> {
134134+ /// The token to be revoked.
135135+ #[serde(borrow)]
136136+ pub token: CowStr<'a>,
137137+ // ?
138138+ // pub token_type_hint: Option<String>,
139139+}
140140+141141+impl IntoStatic for RevocationRequestParameters<'_> {
142142+ type Output = RevocationRequestParameters<'static>;
143143+144144+ fn into_static(self) -> Self::Output {
145145+ Self::Output {
146146+ token: self.token.into_static(),
147147+ }
148148+ }
149149+}
150150+151151+impl IntoStatic for TokenRequestParameters<'_> {
152152+ type Output = TokenRequestParameters<'static>;
153153+154154+ fn into_static(self) -> Self::Output {
155155+ Self::Output {
156156+ grant_type: self.grant_type,
157157+ code: self.code.into_static(),
158158+ redirect_uri: self.redirect_uri.into_static(),
159159+ code_verifier: self.code_verifier.into_static(),
160160+ }
161161+ }
162162+}
163163+164164+impl IntoStatic for RefreshRequestParameters<'_> {
165165+ type Output = RefreshRequestParameters<'static>;
166166+167167+ fn into_static(self) -> Self::Output {
168168+ Self::Output {
169169+ grant_type: self.grant_type,
170170+ refresh_token: self.refresh_token.into_static(),
171171+ scope: self.scope.map(CowStr::into_static),
172172+ }
173173+ }
174174+}
175175+176176+impl IntoStatic for ParParameters<'_> {
177177+ type Output = ParParameters<'static>;
178178+179179+ fn into_static(self) -> Self::Output {
180180+ Self::Output {
181181+ redirect_uri: self.redirect_uri.into_static(),
182182+ response_type: self.response_type,
183183+ scope: self.scope.into_static(),
184184+ code_challenge: self.code_challenge.into_static(),
185185+ code_challenge_method: self.code_challenge_method,
186186+ state: self.state.into_static(),
187187+ response_mode: self.response_mode,
188188+ login_hint: self.login_hint.into_static(),
189189+ prompt: self.prompt.into_static(),
190190+ }
191191+ }
192192+}
···11+use serde::{Deserialize, Serialize};
22+use smol_str::SmolStr;
33+44+/// 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.
88+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
99+pub struct OAuthParResponse {
1010+ /// A short-lived URI representing the pushed authorization request.
1111+ pub request_uri: SmolStr,
1212+ /// Number of seconds until the `request_uri` expires.
1313+ pub expires_in: Option<u32>,
1414+}
1515+1616+/// The token type returned by the authorization server, indicating how to present the token.
1717+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1818+pub enum OAuthTokenType {
1919+ /// Demonstration of Proof of Possession (DPoP) token (RFC 9449). Requires a DPoP proof header.
2020+ DPoP,
2121+ /// Standard Bearer token (RFC 6750). Sent as `Authorization: Bearer <token>`.
2222+ Bearer,
2323+}
2424+2525+impl OAuthTokenType {
2626+ /// Returns the string representation used in HTTP `Authorization` headers.
2727+ pub fn as_str(&self) -> &'static str {
2828+ match self {
2929+ OAuthTokenType::DPoP => "DPoP",
3030+ OAuthTokenType::Bearer => "Bearer",
3131+ }
3232+ }
3333+}
3434+3535+/// A successful token response from the authorization server (RFC 6749 §5.1).
3636+/// <https://datatracker.ietf.org/doc/html/rfc6749#section-5.1>
3737+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
3838+pub struct OAuthTokenResponse {
3939+ /// The issued access token.
4040+ pub access_token: SmolStr,
4141+ /// The type of token, indicating the presentation scheme to use.
4242+ pub token_type: OAuthTokenType,
4343+ /// Lifetime of the access token in seconds from the time of issuance.
4444+ pub expires_in: Option<i64>,
4545+ /// A refresh token that can be used to obtain new access tokens.
4646+ pub refresh_token: Option<SmolStr>,
4747+ /// The scopes actually granted, if different from those requested.
4848+ pub scope: Option<SmolStr>,
4949+ // ATPROTO extension: add the sub claim to the token response to allow
5050+ // clients to resolve the PDS url (audience) using the did resolution
5151+ // mechanism.
5252+ /// The subject (DID) the token was issued for; ATProto extension for PDS discovery.
5353+ pub sub: Option<SmolStr>,
5454+}
···11+use super::response::OAuthTokenType;
22+use jacquard_common::types::string::{Datetime, Did};
33+use jacquard_common::{CowStr, IntoStatic};
44+use serde::{Deserialize, Serialize};
55+66+/// 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`.
1111+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1212+pub struct TokenSet<'s> {
1313+ /// The issuer URL of the authorization server that issued these tokens.
1414+ #[serde(borrow)]
1515+ pub iss: CowStr<'s>,
1616+ /// The subject DID identifying the authenticated user.
1717+ pub sub: Did<'s>,
1818+ /// The audience (resource server URL or DID) the tokens are intended for.
1919+ pub aud: CowStr<'s>,
2020+ /// The scopes granted by the authorization server.
2121+ pub scope: Option<CowStr<'s>>,
2222+2323+ /// A refresh token that can be exchanged for new access tokens.
2424+ pub refresh_token: Option<CowStr<'s>>,
2525+ /// The current access token to include in API requests.
2626+ pub access_token: CowStr<'s>,
2727+ /// Whether the access token must be presented as a DPoP or Bearer token.
2828+ pub token_type: OAuthTokenType,
2929+3030+ /// The point in time at which the access token expires.
3131+ pub expires_at: Option<Datetime>,
3232+}
3333+3434+impl IntoStatic for TokenSet<'_> {
3535+ type Output = TokenSet<'static>;
3636+3737+ fn into_static(self) -> Self::Output {
3838+ TokenSet {
3939+ iss: self.iss.into_static(),
4040+ sub: self.sub.into_static(),
4141+ aud: self.aud.into_static(),
4242+ scope: self.scope.map(|s| s.into_static()),
4343+ refresh_token: self.refresh_token.map(|s| s.into_static()),
4444+ access_token: self.access_token.into_static(),
4545+ token_type: self.token_type,
4646+ expires_at: self.expires_at.map(|s| s.into_static()),
4747+ }
4848+ }
4949+}
+115
src-tauri/vendor/jacquard-oauth/src/utils.rs
···11+use base64::Engine;
22+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33+use elliptic_curve::SecretKey;
44+use jacquard_common::CowStr;
55+use jose_jwk::{Key, crypto};
66+use rand::{CryptoRng, RngCore, rngs::ThreadRng};
77+use sha2::{Digest, Sha256};
88+use std::cmp::Ordering;
99+1010+use crate::{FALLBACK_ALG, types::OAuthAuthorizationServerMetadata};
1111+1212+/// 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.
1616+pub fn generate_key(allowed_algos: &[CowStr]) -> Option<Key> {
1717+ for alg in allowed_algos {
1818+ #[allow(clippy::single_match)]
1919+ match alg.as_ref() {
2020+ "ES256" => {
2121+ return Some(Key::from(&crypto::Key::from(
2222+ SecretKey::<p256::NistP256>::random(&mut ThreadRng::default()),
2323+ )));
2424+ }
2525+ _ => {
2626+ // TODO: Implement other algorithms?
2727+ }
2828+ }
2929+ }
3030+ None
3131+}
3232+3333+/// Generate a cryptographically random 16-byte nonce encoded as base64url (no padding).
3434+pub fn generate_nonce() -> CowStr<'static> {
3535+ URL_SAFE_NO_PAD
3636+ .encode(get_random_values::<_, 16>(&mut ThreadRng::default()))
3737+ .into()
3838+}
3939+4040+/// Generate a cryptographically random 43-byte PKCE code verifier encoded as base64url (no padding).
4141+pub fn generate_verifier() -> CowStr<'static> {
4242+ URL_SAFE_NO_PAD
4343+ .encode(get_random_values::<_, 43>(&mut ThreadRng::default()))
4444+ .into()
4545+}
4646+4747+/// Fill a `LEN`-byte array with cryptographically random bytes from `rng`.
4848+pub fn get_random_values<R, const LEN: usize>(rng: &mut R) -> [u8; LEN]
4949+where
5050+ R: RngCore + CryptoRng,
5151+{
5252+ let mut bytes = [0u8; LEN];
5353+ rng.fill_bytes(&mut bytes);
5454+ bytes
5555+}
5656+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.
6161+pub fn compare_algos(a: &CowStr, b: &CowStr) -> Ordering {
6262+ if a.as_ref() == "ES256K" {
6363+ return Ordering::Less;
6464+ }
6565+ if b.as_ref() == "ES256K" {
6666+ return Ordering::Greater;
6767+ }
6868+ for prefix in ["ES", "PS", "RS"] {
6969+ if let Some(stripped_a) = a.strip_prefix(prefix) {
7070+ if let Some(stripped_b) = b.strip_prefix(prefix) {
7171+ if let (Ok(len_a), Ok(len_b)) =
7272+ (stripped_a.parse::<u32>(), stripped_b.parse::<u32>())
7373+ {
7474+ return len_a.cmp(&len_b);
7575+ }
7676+ } else {
7777+ return Ordering::Less;
7878+ }
7979+ } else if b.starts_with(prefix) {
8080+ return Ordering::Greater;
8181+ }
8282+ }
8383+ Ordering::Equal
8484+}
8585+8686+/// 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.
9292+pub fn generate_pkce() -> (CowStr<'static>, CowStr<'static>) {
9393+ // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
9494+ let verifier = generate_verifier();
9595+ (
9696+ URL_SAFE_NO_PAD
9797+ .encode(Sha256::digest(&verifier.as_str()))
9898+ .into(),
9999+ verifier,
100100+ )
101101+}
102102+103103+/// 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.
108108+pub fn generate_dpop_key(metadata: &OAuthAuthorizationServerMetadata) -> Option<Key> {
109109+ let mut algs = metadata
110110+ .dpop_signing_alg_values_supported
111111+ .clone()
112112+ .unwrap_or(vec![FALLBACK_ALG.into()]);
113113+ algs.sort_by(compare_algos);
114114+ generate_key(&algs)
115115+}