A better Rust ATProto crate
1

Configure Feed

Select the types of activity you want to include in your feed.

dev experience improvements to oauth scope construction and related usage

author nonbinary.computer date (Jun 13, 2026, 7:43 PM -0400) commit 566b18d9 parent a555c35d change-id lolluvqw
+895 -84
+12 -4
CHANGELOG.md
··· 19 19 - `Cow<'a, str>` now supported by borrow-or-share pattern traits 20 20 21 21 **OAuth permission sets, refactored scopes** (`jacquard-oauth`, `jacquard`, `jacquard-lexicon`) 22 - - Added `Scopes<S>` validated container for space-separated OAuth scope strings, replacing `Vec<Scope<S>>`. Stores a single string buffer with pre-computed byte-range indices, yielding zero-copy `Scope<&str>` views. 22 + - Added ergonomic `Scope` constructors for common atproto, account, identity, transition, repo, RPC, and permission-set scopes. 23 + - Added `Scopes::from_scopes()` and `ScopesBuilder` so callers can construct typed scopes programmatically while reusing the same validation, normalization, reduction, and indexing path as string parsing. 24 + - Added endpoint-aware `Scope::rpc_request::<R: XrpcRequest>()` and collection-aware repo helpers such as `Scope::repo_create_record::<C: Collection>()`. 25 + - Added `AuthorizeOptions` helpers for setting scopes from a string or typed scope values; builders can be passed through existing `with_scopes()` after `ScopesBuilder::build()`. 23 26 - Added `IncludeScope<S>` scope variant referencing permission set NSIDs with optional `?aud=<did>` audience. 24 - - Added permission set lexicon types (`LexPermissionSet`, `LexPermission`, `LexPermissionResource`) in jacquard-lexicon. 25 - - Added `expand_permission_set()` and `resolve_permission_set()` for converting permission set lexicons into concrete scopes. 26 - - Added `scope-check` feature to jacquard-oauth and jacquard, enabling client-side scope validation and eager resolution of `include:` scopes at session creation. 27 + - Updated OAuth examples and inline docs to demonstrate typed scope builders and link to the atproto OAuth scope docs and interactive scope-string builder. 27 28 28 29 **Bootstrap XRPC types** (`jacquard-common`) 29 30 - Added `DidService<S>` validated type for DID audiences with optional service-id fragments (e.g., `did:web:example.com#bsky_appview`). ··· 146 147 - `DpopDataSource` trait methods return `Option<&str>` (was `Option<CowStr<'_>>`) 147 148 - DPoP proof building uses `&str` for zero-copy JWT construction 148 149 - `build_dpop_proof` takes `&str` parameters, returns `SmolStr` 150 + 151 + **OAuth permission sets, refactored scopes** (`jacquard-oauth`, `jacquard`, `jacquard-lexicon`) 152 + - Added `Scopes<S>` validated container for space-separated OAuth scope strings, replacing `Vec<Scope<S>>`. Stores a single string buffer with pre-computed byte-range indices, yielding zero-copy `Scope<&str>` views. 153 + - Added `IncludeScope<S>` scope variant referencing permission set NSIDs with optional `?aud=<did>` audience. 154 + - Added permission set lexicon types (`LexPermissionSet`, `LexPermission`, `LexPermissionResource`) in jacquard-lexicon. 155 + - Added `expand_permission_set()` and `resolve_permission_set()` for converting permission set lexicons into concrete scopes. 156 + - Added `scope-check` feature to jacquard-oauth and jacquard, enabling client-side scope validation and eager resolution of `include:` scopes at session creation. 149 157 150 158 **Identity resolution** (`jacquard-identity`) 151 159 - `IdentityResolver::resolve_handle<S: BosStr + Sync>(&self, handle: &Handle<S>)`: generic over handle backing type
+8 -6
crates/jacquard-lexicon/src/lexicon.rs
··· 6 6 CowStr, 7 7 deps::smol_str::SmolStr, 8 8 into_static::IntoStatic, 9 - types::blob::MimeType, 10 - types::did::Did, 11 - types::nsid::Nsid, 12 - types::scope_primitives::{AccountAction, RepoAction}, 9 + types::{ 10 + blob::MimeType, 11 + nsid::Nsid, 12 + scope_primitives::{AccountAction, RepoAction}, 13 + string::DidService, 14 + }, 13 15 }; 14 16 use serde::{Deserialize, Serialize}; 15 17 use serde_repr::{Deserialize_repr, Serialize_repr}; ··· 455 457 /// Lexicon method NSIDs this permission applies to. 456 458 #[serde(borrow)] 457 459 lxm: Vec<Nsid<CowStr<'s>>>, 458 - /// Audience DID for inter-service auth. 460 + /// Audience DID with optional service fragment for inter-service auth. 459 461 #[serde(borrow, default)] 460 - aud: Option<Did<CowStr<'s>>>, 462 + aud: Option<DidService<CowStr<'s>>>, 461 463 /// If true, inherits audience from the include scope's aud parameter. 462 464 #[serde(default, rename = "inheritAud")] 463 465 inherit_aud: Option<bool>,
+47
crates/jacquard-oauth/src/atproto.rs
··· 210 210 self 211 211 } 212 212 213 + /// Set the OAuth scopes for this client. 214 + pub fn with_scopes(mut self, scopes: Scopes<S>) -> Self { 215 + self.scopes = scopes; 216 + self 217 + } 218 + 219 + /// Set the uri where the client's keys are hosted. 220 + pub fn with_jwks_uri(mut self, jwks_uri: Uri<String>) -> Self { 221 + self.jwks_uri = Some(jwks_uri); 222 + self 223 + } 224 + 225 + /// Set the human-readable display name for this client. 226 + pub fn with_client_name(mut self, client_name: S) -> Self { 227 + self.client_name = Some(client_name); 228 + self 229 + } 230 + 213 231 /// Create a default loopback client metadata with the `atproto` and `transition:generic` scopes. 214 232 /// 215 233 /// This is a convenience constructor for local development and CLI tools. The resulting ··· 223 241 .expect("valid scopes") 224 242 .convert(); 225 243 Self::new_localhost(None, Some(scopes)) 244 + } 245 + 246 + /// Create hosted client metadata with optional custom scopes. 247 + /// 248 + /// When `scopes` is `None`, the `atproto` scope is used. 249 + /// Use the builder functions to set fields like the jwks_uri or client name, etc. 250 + pub fn new( 251 + redirect_uris: Vec<Uri<String>>, 252 + client_id: Uri<String>, 253 + scopes: Option<Scopes<S>>, 254 + ) -> AtprotoClientMetadata<S> 255 + where 256 + S: From<SmolStr> + AsRef<str>, 257 + { 258 + let default_scopes: Scopes<S> = Scopes::new(SmolStr::new_static("atproto")) 259 + .expect("valid scopes") 260 + .convert(); 261 + AtprotoClientMetadata { 262 + client_id: client_id.clone(), 263 + client_uri: Some(client_id), 264 + redirect_uris: redirect_uris, 265 + grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 266 + scopes: scopes.unwrap_or(default_scopes), 267 + jwks_uri: None, 268 + client_name: None, 269 + logo_uri: None, 270 + tos_uri: None, 271 + privacy_policy_uri: None, 272 + } 226 273 } 227 274 228 275 /// Create loopback client metadata with optional custom redirect URIs and scopes.
+2 -2
crates/jacquard-oauth/src/client.rs
··· 18 18 #[cfg(feature = "websocket")] 19 19 use jacquard_common::CowStr; 20 20 #[cfg(feature = "scope-check")] 21 - use jacquard_common::types::nsid::Nsid; 21 + use jacquard_common::types::{nsid::Nsid, string::DidService}; 22 22 use jacquard_common::{ 23 23 AuthorizationToken, IntoStatic, 24 24 bos::BosStr, ··· 535 535 Scope::Include(IncludeScope { nsid, audience }) => { 536 536 let audience_did = if let Some(aud_str) = audience { 537 537 let decoded = decode_audience(aud_str)?; 538 - match Did::new_owned(&decoded) { 538 + match DidService::new_owned(&decoded) { 539 539 Ok(did) => Some(did), 540 540 Err(_) => { 541 541 return Err(crate::error::CallbackError::ScopeResolution {
+1 -1
crates/jacquard-oauth/src/resolver.rs
··· 824 824 pub async fn resolve_permission_set<R, S>( 825 825 resolver: &R, 826 826 nsid: &jacquard_common::types::nsid::Nsid<S>, 827 - inherited_audience: Option<&jacquard_common::types::did::Did<smol_str::SmolStr>>, 827 + inherited_audience: Option<&jacquard_common::types::string::DidService<smol_str::SmolStr>>, 828 828 ) -> Result<Vec<crate::scopes::Scope<smol_str::SmolStr>>> 829 829 where 830 830 R: OAuthResolver + jacquard_identity::lexicon_resolver::LexiconSchemaResolver + Sync,
+671 -38
crates/jacquard-oauth/src/scopes.rs
··· 3 3 //! Originally derived from <https://tangled.org/nickgerakines.me/atproto-crates/raw/main/crates/atproto-oauth/src/scopes.rs>, since substantially modified. 4 4 //! 5 5 //! This module provides comprehensive support for AT Protocol OAuth scopes, 6 - //! including parsing, serialization, normalization, and permission checking. 6 + //! including parsing, serialization, normalization, programmatic construction, 7 + //! and permission checking. 8 + //! 9 + //! See the AT Protocol OAuth spec's 10 + //! [authorization scopes](https://atproto.com/specs/oauth#authorization-scopes) 11 + //! section for the profile-level rules, including the required `atproto` marker 12 + //! scope and current 13 + //! [transitional scopes](https://atproto.com/specs/oauth#transitional-scopes). 14 + //! The AT Protocol docs also provide an interactive 15 + //! [scope builder](https://atproto.com/guides/scope-builder) for choosing 16 + //! appropriate scopes before translating them into [`Scope`] constructors or a 17 + //! [`ScopesBuilder`] chain. 7 18 //! 8 19 //! Scopes in AT Protocol follow a prefix-based format with optional query parameters: 9 20 //! - `account`: Access to account information (email, repo, status) ··· 11 22 //! - `blob`: Access to blob operations with mime type constraints 12 23 //! - `repo`: Repository operations with collection and action constraints 13 24 //! - `rpc`: RPC method access with lexicon and audience constraints 25 + //! - `include`: Reference a permission-set NSID, optionally with an audience 14 26 //! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used 15 - //! - `transition`: Migration operations (generic or email) 27 + //! - `transition`: Migration operations (generic, email, or chat.bsky) 16 28 //! 17 29 //! Standard OpenID Connect scopes (no suffixes or query parameters): 18 30 //! - `openid`: Required for OpenID Connect authentication 19 31 //! - `profile`: Access to user profile information 20 32 //! - `email`: Access to user email address 33 + //! 34 + //! Programmatic construction is available through [`Scope`] constructors, 35 + //! [`Scopes::from_scopes`], and [`ScopesBuilder`]: 36 + //! 37 + //! ```rust 38 + //! use jacquard_oauth::scopes::{Scope, Scopes}; 39 + //! 40 + //! let scopes = Scopes::from_scopes([ 41 + //! Scope::atproto(), 42 + //! Scope::transition_generic(), 43 + //! Scope::rpc("app.bsky.feed.getTimeline")?, 44 + //! Scope::repo_create("app.bsky.feed.post")?, 45 + //! ])?; 46 + //! 47 + //! assert_eq!( 48 + //! scopes.to_normalized_string(), 49 + //! "atproto repo:app.bsky.feed.post?action=create rpc:app.bsky.feed.getTimeline transition:generic" 50 + //! ); 51 + //! # Ok::<(), jacquard_oauth::scopes::ParseError>(()) 52 + //! ``` 53 + //! 54 + //! For fluent construction: 55 + //! 56 + //! ```rust 57 + //! use jacquard_oauth::scopes::Scopes; 58 + //! 59 + //! let scopes = Scopes::builder() 60 + //! .atproto() 61 + //! .transition_generic() 62 + //! .rpc("app.bsky.feed.getTimeline")? 63 + //! .repo_create("app.bsky.feed.post")? 64 + //! .build()?; 65 + //! 66 + //! assert!(scopes.grants(&jacquard_oauth::scopes::Scope::atproto())); 67 + //! # Ok::<(), jacquard_oauth::scopes::ParseError>(()) 68 + //! ``` 21 69 22 70 use std::collections::{BTreeMap, BTreeSet}; 23 71 use std::fmt; ··· 29 77 EStr, EString, 30 78 encoder::{Query, Query as EncQuery}, 31 79 }; 32 - use jacquard_common::types::did::Did; 80 + use jacquard_common::types::collection::Collection; 81 + use jacquard_common::types::did_service::validate_did_service; 33 82 use jacquard_common::types::nsid::Nsid; 34 - use jacquard_common::types::string::AtStrError; 83 + use jacquard_common::types::string::{AtStrError, DidService}; 84 + use jacquard_common::xrpc::XrpcRequest; 35 85 use jacquard_common::{BorrowOrShare, Bos, FromStaticStr, IntoStatic}; 36 86 use serde::de::{Error as DeError, Visitor}; 37 87 use serde::{Deserialize, Serialize}; ··· 130 180 } 131 181 } 132 182 183 + impl Scope<SmolStr> { 184 + /// Construct the required AT Protocol marker scope. 185 + pub fn atproto() -> Self { 186 + Scope::Atproto 187 + } 188 + 189 + /// Construct the OpenID Connect `openid` scope. 190 + pub fn openid() -> Self { 191 + Scope::OpenId 192 + } 193 + 194 + /// Construct the OpenID Connect `profile` scope. 195 + pub fn profile() -> Self { 196 + Scope::Profile 197 + } 198 + 199 + /// Construct the OpenID Connect `email` scope. 200 + pub fn email() -> Self { 201 + Scope::Email 202 + } 203 + 204 + /// Construct `identity:handle`. 205 + pub fn identity_handle() -> Self { 206 + Scope::Identity(IdentityScope::Handle) 207 + } 208 + 209 + /// Construct `identity:*`. 210 + pub fn identity_all() -> Self { 211 + Scope::Identity(IdentityScope::All) 212 + } 213 + 214 + /// Construct `transition:generic`. 215 + pub fn transition_generic() -> Self { 216 + Scope::Transition(TransitionScope::Generic) 217 + } 218 + 219 + /// Construct `transition:email`. 220 + pub fn transition_email() -> Self { 221 + Scope::Transition(TransitionScope::Email) 222 + } 223 + 224 + /// Construct `transition:chat.bsky`. 225 + pub fn transition_chat_bsky() -> Self { 226 + Scope::Transition(TransitionScope::ChatBsky) 227 + } 228 + 229 + /// Construct `account:email`. 230 + pub fn account_email_read() -> Self { 231 + account_scope(AccountResource::Email, AccountAction::Read) 232 + } 233 + 234 + /// Construct `account:email?action=manage`. 235 + pub fn account_email_manage() -> Self { 236 + account_scope(AccountResource::Email, AccountAction::Manage) 237 + } 238 + 239 + /// Construct `account:repo`. 240 + pub fn account_repo_read() -> Self { 241 + account_scope(AccountResource::Repo, AccountAction::Read) 242 + } 243 + 244 + /// Construct `account:repo?action=manage`. 245 + pub fn account_repo_manage() -> Self { 246 + account_scope(AccountResource::Repo, AccountAction::Manage) 247 + } 248 + 249 + /// Construct `account:status`. 250 + pub fn account_status_read() -> Self { 251 + account_scope(AccountResource::Status, AccountAction::Read) 252 + } 253 + 254 + /// Construct `account:status?action=manage`. 255 + pub fn account_status_manage() -> Self { 256 + account_scope(AccountResource::Status, AccountAction::Manage) 257 + } 258 + 259 + /// Construct `repo:*` with all repository actions. 260 + pub fn repo_all() -> Self { 261 + Scope::repo_nsid_actions(None, all_repo_actions()) 262 + } 263 + 264 + /// Construct a repo scope for all actions on one collection. 265 + pub fn repo_collection(collection: impl AsRef<str>) -> Result<Self, ParseError> { 266 + Ok(Scope::repo_nsid_actions( 267 + Some(Nsid::new_owned(collection)?), 268 + all_repo_actions(), 269 + )) 270 + } 271 + 272 + /// Construct a repo scope granting create on one collection. 273 + pub fn repo_create(collection: impl AsRef<str>) -> Result<Self, ParseError> { 274 + Ok(Scope::repo_nsid_actions( 275 + Some(Nsid::new_owned(collection)?), 276 + [RepoAction::Create], 277 + )) 278 + } 279 + 280 + /// Construct a repo scope granting update on one collection. 281 + pub fn repo_update(collection: impl AsRef<str>) -> Result<Self, ParseError> { 282 + Ok(Scope::repo_nsid_actions( 283 + Some(Nsid::new_owned(collection)?), 284 + [RepoAction::Update], 285 + )) 286 + } 287 + 288 + /// Construct a repo scope granting delete on one collection. 289 + pub fn repo_delete(collection: impl AsRef<str>) -> Result<Self, ParseError> { 290 + Ok(Scope::repo_nsid_actions( 291 + Some(Nsid::new_owned(collection)?), 292 + [RepoAction::Delete], 293 + )) 294 + } 295 + 296 + /// Construct `rpc:*`. 297 + pub fn rpc_all() -> Self { 298 + let mut lxm = BTreeSet::new(); 299 + lxm.insert(RpcLexicon::All); 300 + let mut aud = BTreeSet::new(); 301 + aud.insert(RpcAudience::All); 302 + Scope::Rpc(RpcScope { lxm, aud }) 303 + } 304 + 305 + /// Construct an RPC scope for one XRPC method NSID and all audiences. 306 + pub fn rpc(lxm: impl AsRef<str>) -> Result<Self, ParseError> { 307 + Ok(Scope::rpc_nsid(Nsid::new_owned(lxm)?)) 308 + } 309 + 310 + /// Construct an RPC scope for one XRPC method NSID and one DID audience. 311 + pub fn rpc_aud(lxm: impl AsRef<str>, aud: impl AsRef<str>) -> Result<Self, ParseError> { 312 + Ok(Scope::rpc_nsid_aud( 313 + Nsid::new_owned(lxm)?, 314 + DidService::new_owned(aud)?, 315 + )) 316 + } 317 + 318 + /// Construct an RPC scope from an XRPC request type. 319 + pub fn rpc_request<R: XrpcRequest>() -> Self { 320 + Scope::rpc_nsid(unsafe { Nsid::unchecked(SmolStr::new_static(R::NSID)) }) 321 + } 322 + 323 + /// Construct an RPC scope from an XRPC request type and one DID audience. 324 + pub fn rpc_request_aud<R: XrpcRequest>(aud: impl AsRef<str>) -> Result<Self, ParseError> { 325 + Ok(Scope::rpc_nsid_aud( 326 + unsafe { Nsid::unchecked(SmolStr::new_static(R::NSID)) }, 327 + DidService::new_owned(aud)?, 328 + )) 329 + } 330 + 331 + /// Construct an `include:<nsid>` permission-set scope. 332 + pub fn include(nsid: impl AsRef<str>) -> Result<Self, ParseError> { 333 + Ok(Scope::include_nsid(Nsid::new_owned(nsid)?, None)) 334 + } 335 + 336 + /// Construct an `include:<nsid>?aud=<audience>` permission-set scope. 337 + pub fn include_aud(nsid: impl AsRef<str>, aud: impl AsRef<str>) -> Result<Self, ParseError> { 338 + let audience = normalize_include_audience(aud.as_ref())?; 339 + Ok(Scope::include_nsid(Nsid::new_owned(nsid)?, Some(audience))) 340 + } 341 + 342 + /// Construct a repo scope granting create for a record collection type. 343 + pub fn repo_create_record<C: Collection>() -> Self { 344 + Scope::repo_nsid_actions(Some(collection_nsid::<C>()), [RepoAction::Create]) 345 + } 346 + 347 + /// Construct a repo scope granting update for a record collection type. 348 + pub fn repo_update_record<C: Collection>() -> Self { 349 + Scope::repo_nsid_actions(Some(collection_nsid::<C>()), [RepoAction::Update]) 350 + } 351 + 352 + /// Construct a repo scope granting delete for a record collection type. 353 + pub fn repo_delete_record<C: Collection>() -> Self { 354 + Scope::repo_nsid_actions(Some(collection_nsid::<C>()), [RepoAction::Delete]) 355 + } 356 + 357 + /// Construct a repo scope granting all repo actions for a record collection type. 358 + pub fn repo_all_record<C: Collection>() -> Self { 359 + Scope::repo_nsid_actions(Some(collection_nsid::<C>()), all_repo_actions()) 360 + } 361 + } 362 + 363 + impl<S: BosStr + Ord> Scope<S> { 364 + /// Construct a repo scope for a typed collection NSID and explicit actions. 365 + pub fn repo_nsid(collection: Nsid<S>, actions: impl IntoIterator<Item = RepoAction>) -> Self { 366 + Self::repo_nsid_actions(Some(collection), actions) 367 + } 368 + 369 + fn repo_nsid_actions( 370 + collection: Option<Nsid<S>>, 371 + actions: impl IntoIterator<Item = RepoAction>, 372 + ) -> Self { 373 + let mut actions: BTreeSet<_> = actions.into_iter().collect(); 374 + if actions.is_empty() { 375 + actions.extend(all_repo_actions()); 376 + } 377 + 378 + Scope::Repo(RepoScope { 379 + collection: collection.map_or(RepoCollection::All, RepoCollection::Nsid), 380 + actions, 381 + }) 382 + } 383 + 384 + /// Construct an RPC scope for a typed XRPC method NSID and all audiences. 385 + pub fn rpc_nsid(lxm: Nsid<S>) -> Self { 386 + let mut lxm_set = BTreeSet::new(); 387 + lxm_set.insert(RpcLexicon::Nsid(lxm)); 388 + let mut aud = BTreeSet::new(); 389 + aud.insert(RpcAudience::All); 390 + Scope::Rpc(RpcScope { lxm: lxm_set, aud }) 391 + } 392 + 393 + /// Construct an RPC scope for a typed XRPC method NSID and typed DID audience. 394 + pub fn rpc_nsid_aud(lxm: Nsid<S>, aud: DidService<S>) -> Self { 395 + let mut lxm_set = BTreeSet::new(); 396 + lxm_set.insert(RpcLexicon::Nsid(lxm)); 397 + let mut aud_set = BTreeSet::new(); 398 + aud_set.insert(RpcAudience::Did(aud)); 399 + Scope::Rpc(RpcScope { 400 + lxm: lxm_set, 401 + aud: aud_set, 402 + }) 403 + } 404 + 405 + /// Construct an include permission-set scope from a typed NSID and optional audience string. 406 + pub fn include_nsid(nsid: Nsid<S>, audience: Option<S>) -> Self { 407 + Scope::Include(IncludeScope { nsid, audience }) 408 + } 409 + } 410 + 411 + fn account_scope(resource: AccountResource, action: AccountAction) -> Scope<SmolStr> { 412 + Scope::Account(AccountScope { resource, action }) 413 + } 414 + 415 + fn all_repo_actions() -> [RepoAction; 3] { 416 + [RepoAction::Create, RepoAction::Update, RepoAction::Delete] 417 + } 418 + 419 + fn collection_nsid<C: Collection>() -> Nsid<SmolStr> { 420 + unsafe { Nsid::unchecked(SmolStr::new_static(C::NSID)) } 421 + } 422 + 423 + fn normalize_include_audience(audience: &str) -> Result<SmolStr, ParseError> { 424 + if audience.contains('%') { 425 + let estr = EStr::<Query>::new(audience).ok_or_else(|| { 426 + ParseError::InvalidResource( 427 + "include audience has invalid percent-encoding".to_smolstr(), 428 + ) 429 + })?; 430 + let decoded = estr.decode().to_string().map_err(|_| { 431 + ParseError::InvalidResource( 432 + "include audience contains invalid UTF-8 sequence".to_smolstr(), 433 + ) 434 + })?; 435 + validate_include_audience(&decoded)?; 436 + Ok(SmolStr::new(decoded)) 437 + } else { 438 + validate_include_audience(audience)?; 439 + Ok(SmolStr::new(audience)) 440 + } 441 + } 442 + 443 + fn validate_include_audience(audience: &str) -> Result<(), ParseError> { 444 + Ok(validate_did_service(audience)?) 445 + } 446 + 133 447 /// Account scope attributes 134 448 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 135 449 pub struct AccountScope { ··· 458 772 pub enum RpcAudience<S: BosStr = DefaultStr> { 459 773 /// All audiences (wildcard) 460 774 All, 461 - /// Specific DID 462 - Did(Did<S>), 775 + /// Specific DID with optional service id fragment 776 + Did(DidService<S>), 463 777 } 464 778 465 779 impl<S: BosStr> RpcAudience<S> { ··· 805 1119 indices: Vec::new(), 806 1120 } 807 1121 } 1122 + 1123 + /// Construct scopes from typed scope values, reusing normal parsing, validation, 1124 + /// reduction, and indexing. 1125 + pub fn from_scopes<I>(scopes: I) -> Result<Self, ParseError> 1126 + where 1127 + I: IntoIterator<Item = Scope<SmolStr>>, 1128 + { 1129 + let mut builder = SmolStrBuilder::new(); 1130 + for (index, scope) in scopes.into_iter().enumerate() { 1131 + if index > 0 { 1132 + builder.push(' '); 1133 + } 1134 + builder.push_str(&scope.to_string_normalized()); 1135 + } 1136 + Scopes::new(builder.finish()) 1137 + } 1138 + 1139 + /// Construct the default atproto marker scope set. 1140 + pub fn atproto() -> Self { 1141 + Scopes::new(SmolStr::new_static("atproto")).expect("static atproto scope is valid") 1142 + } 1143 + 1144 + /// Construct a conservative local-development scope set. 1145 + pub fn local_development() -> Self { 1146 + Scopes::new(SmolStr::new_static("atproto transition:generic")) 1147 + .expect("static local-development scopes are valid") 1148 + } 1149 + 1150 + /// Start a fluent scope builder. 1151 + pub fn builder() -> ScopesBuilder { 1152 + ScopesBuilder::new() 1153 + } 1154 + } 1155 + 1156 + /// Fluent builder for programmatic OAuth scope construction. 1157 + #[derive(Debug, Clone, Default)] 1158 + pub struct ScopesBuilder { 1159 + scopes: Vec<Scope<SmolStr>>, 1160 + } 1161 + 1162 + impl ScopesBuilder { 1163 + /// Create an empty builder. 1164 + pub fn new() -> Self { 1165 + Self { scopes: Vec::new() } 1166 + } 1167 + 1168 + /// Add an already-constructed scope. 1169 + pub fn scope(mut self, scope: Scope<SmolStr>) -> Self { 1170 + self.scopes.push(scope); 1171 + self 1172 + } 1173 + 1174 + /// Add `atproto`. 1175 + pub fn atproto(self) -> Self { 1176 + self.scope(Scope::atproto()) 1177 + } 1178 + 1179 + /// Add `openid`. 1180 + pub fn openid(self) -> Self { 1181 + self.scope(Scope::openid()) 1182 + } 1183 + 1184 + /// Add `profile`. 1185 + pub fn profile(self) -> Self { 1186 + self.scope(Scope::profile()) 1187 + } 1188 + 1189 + /// Add `email`. 1190 + pub fn email(self) -> Self { 1191 + self.scope(Scope::email()) 1192 + } 1193 + 1194 + /// Add `transition:generic`. 1195 + pub fn transition_generic(self) -> Self { 1196 + self.scope(Scope::transition_generic()) 1197 + } 1198 + 1199 + /// Add `transition:email`. 1200 + pub fn transition_email(self) -> Self { 1201 + self.scope(Scope::transition_email()) 1202 + } 1203 + 1204 + /// Add `transition:chat.bsky`. 1205 + pub fn transition_chat_bsky(self) -> Self { 1206 + self.scope(Scope::transition_chat_bsky()) 1207 + } 1208 + 1209 + /// Add `identity:handle`. 1210 + pub fn identity_handle(self) -> Self { 1211 + self.scope(Scope::identity_handle()) 1212 + } 1213 + 1214 + /// Add `identity:*`. 1215 + pub fn identity_all(self) -> Self { 1216 + self.scope(Scope::identity_all()) 1217 + } 1218 + 1219 + /// Add `rpc:*`. 1220 + pub fn rpc_all(self) -> Self { 1221 + self.scope(Scope::rpc_all()) 1222 + } 1223 + 1224 + /// Add an RPC scope for one XRPC method NSID. 1225 + pub fn rpc(self, nsid: impl AsRef<str>) -> Result<Self, ParseError> { 1226 + Ok(self.scope(Scope::rpc(nsid)?)) 1227 + } 1228 + 1229 + /// Add an RPC scope for one XRPC request type. 1230 + pub fn rpc_request<R: XrpcRequest>(self) -> Self { 1231 + self.scope(Scope::rpc_request::<R>()) 1232 + } 1233 + 1234 + /// Add an RPC scope for one XRPC request type. 1235 + pub fn rpc_request_aud<R: XrpcRequest>(self, aud: impl AsRef<str>) -> Result<Self, ParseError> { 1236 + Ok(self.scope(Scope::rpc_request_aud::<R>(aud)?)) 1237 + } 1238 + 1239 + /// Add a repo create scope for one collection NSID. 1240 + pub fn repo_create(self, collection: impl AsRef<str>) -> Result<Self, ParseError> { 1241 + Ok(self.scope(Scope::repo_create(collection)?)) 1242 + } 1243 + 1244 + /// Add a repo update scope for one collection NSID. 1245 + pub fn repo_update(self, collection: impl AsRef<str>) -> Result<Self, ParseError> { 1246 + Ok(self.scope(Scope::repo_update(collection)?)) 1247 + } 1248 + 1249 + /// Add a repo delete scope for one collection NSID. 1250 + pub fn repo_delete(self, collection: impl AsRef<str>) -> Result<Self, ParseError> { 1251 + Ok(self.scope(Scope::repo_delete(collection)?)) 1252 + } 1253 + 1254 + /// Add a repo all-actions scope for one collection NSID. 1255 + pub fn repo_collection(self, collection: impl AsRef<str>) -> Result<Self, ParseError> { 1256 + Ok(self.scope(Scope::repo_collection(collection)?)) 1257 + } 1258 + 1259 + /// Add a repo create scope for a collection type. 1260 + pub fn repo_create_record<C: Collection>(self) -> Self { 1261 + self.scope(Scope::repo_create_record::<C>()) 1262 + } 1263 + 1264 + /// Add a repo update scope for a collection type. 1265 + pub fn repo_update_record<C: Collection>(self) -> Self { 1266 + self.scope(Scope::repo_update_record::<C>()) 1267 + } 1268 + 1269 + /// Add a repo delete scope for a collection type. 1270 + pub fn repo_delete_record<C: Collection>(self) -> Self { 1271 + self.scope(Scope::repo_delete_record::<C>()) 1272 + } 1273 + 1274 + /// Add a repo all-actions scope for a collection type. 1275 + pub fn repo_all_record<C: Collection>(self) -> Self { 1276 + self.scope(Scope::repo_all_record::<C>()) 1277 + } 1278 + 1279 + /// Add an include permission-set scope. 1280 + pub fn include(self, nsid: impl AsRef<str>) -> Result<Self, ParseError> { 1281 + Ok(self.scope(Scope::include(nsid)?)) 1282 + } 1283 + 1284 + /// Add an include permission-set scope with an audience. 1285 + pub fn include_aud( 1286 + self, 1287 + nsid: impl AsRef<str>, 1288 + aud: impl AsRef<str>, 1289 + ) -> Result<Self, ParseError> { 1290 + Ok(self.scope(Scope::include_aud(nsid, aud)?)) 1291 + } 1292 + 1293 + /// Validate, reduce, and build the final scope container. 1294 + pub fn build(self) -> Result<Scopes<SmolStr>, ParseError> { 1295 + Scopes::from_scopes(self.scopes) 1296 + } 808 1297 } 809 1298 810 1299 impl<S: Bos<str> + AsRef<str> + Default + FromStaticStr> Default for Scopes<S> { ··· 1142 1631 aud.push((start, start + 1)); 1143 1632 } 1144 1633 } else { 1145 - jacquard_common::types::did::validate_did(value)?; 1634 + validate_did_service(value)?; 1146 1635 if let Some(pos) = token.find(value) { 1147 1636 let start = base + pos as u16; 1148 1637 let end = start + value.len() as u16; ··· 1173 1662 aud.push((start, start + 1)); 1174 1663 } 1175 1664 } else { 1176 - jacquard_common::types::did::validate_did(value)?; 1665 + validate_did_service(value)?; 1177 1666 if let Some(pos) = token.find(value) { 1178 1667 let start = base + pos as u16; 1179 1668 let end = start + value.len() as u16; ··· 1253 1742 ) 1254 1743 })?; 1255 1744 1256 - // Validate the DID portion (before any #). 1257 - let did_part = decoded.split('#').next().unwrap_or(""); 1258 - jacquard_common::types::did::validate_did(did_part)?; 1259 - if decoded.contains('#') { 1260 - let frag = decoded.split('#').nth(1).unwrap_or(""); 1261 - if frag.is_empty() { 1262 - return Err(ParseError::InvalidResource( 1263 - "include audience fragment cannot be empty".to_smolstr(), 1264 - )); 1265 - } 1266 - } 1745 + validate_did_service(&decoded)?; 1267 1746 } else { 1268 - // Unencoded: validate the DID portion before `#`. 1269 - let did_part = aud_value.split('#').next().unwrap_or(""); 1270 - jacquard_common::types::did::validate_did(did_part)?; 1271 - if aud_value.contains('#') { 1272 - let frag = aud_value.split('#').nth(1).unwrap_or(""); 1273 - if frag.is_empty() { 1274 - return Err(ParseError::InvalidResource( 1275 - "include audience fragment cannot be empty".to_smolstr(), 1276 - )); 1277 - } 1278 - } 1747 + validate_did_service(aud_value)?; 1279 1748 } 1280 1749 1281 1750 // Find the audience's byte position in the token. ··· 1496 1965 if s == "*" { 1497 1966 aud_set.insert(RpcAudience::All); 1498 1967 } else { 1499 - aud_set.insert(RpcAudience::Did(unsafe { Did::unchecked(s) })); 1968 + aud_set.insert(RpcAudience::Did(unsafe { DidService::unchecked(s) })); 1500 1969 } 1501 1970 } 1502 1971 } ··· 1560 2029 "rpc", 1561 2030 "atproto", 1562 2031 "transition", 2032 + "include", 1563 2033 "openid", 1564 2034 "profile", 1565 2035 "email", ··· 1599 2069 "rpc" => Self::parse_rpc(suffix), 1600 2070 "atproto" => Self::parse_atproto(suffix), 1601 2071 "transition" => Self::parse_transition(suffix), 2072 + "include" => Self::parse_include(suffix), 1602 2073 "openid" => Self::parse_openid(suffix), 1603 2074 "profile" => Self::parse_profile(suffix), 1604 2075 "email" => Self::parse_email(suffix), ··· 1774 2245 if *value == "*" { 1775 2246 aud.insert(RpcAudience::All); 1776 2247 } else { 1777 - aud.insert(RpcAudience::Did(Did::from_str(*value)?)); 2248 + aud.insert(RpcAudience::Did(DidService::from_str(*value)?)); 1778 2249 } 1779 2250 } 1780 2251 } ··· 1792 2263 if *value == "*" { 1793 2264 aud.insert(RpcAudience::All); 1794 2265 } else { 1795 - aud.insert(RpcAudience::Did(Did::from_str(*value)?)); 2266 + aud.insert(RpcAudience::Did(DidService::from_str(*value)?)); 1796 2267 } 1797 2268 } 1798 2269 } ··· 1832 2303 }; 1833 2304 1834 2305 Ok(Scope::Transition(scope)) 2306 + } 2307 + 2308 + fn parse_include<'a>(suffix: Option<&'a str>) -> Result<Self, ParseError> 2309 + where 2310 + S: FromStr, 2311 + { 2312 + let (nsid_str, params) = match suffix { 2313 + Some(s) => { 2314 + if let Some(pos) = s.find('?') { 2315 + (&s[..pos], Some(&s[pos + 1..])) 2316 + } else { 2317 + (s, None) 2318 + } 2319 + } 2320 + None => return Err(ParseError::MissingResource), 2321 + }; 2322 + 2323 + let audience = if let Some(params) = params { 2324 + let parsed_params = parse_query_string(params); 2325 + parsed_params 2326 + .get("aud") 2327 + .and_then(|values| values.first()) 2328 + .map(|audience| { 2329 + let normalized = normalize_include_audience(audience)?; 2330 + S::from_str(normalized.as_str()).map_err(|_| { 2331 + ParseError::InvalidResource("invalid include audience".to_smolstr()) 2332 + }) 2333 + }) 2334 + .transpose()? 2335 + } else { 2336 + None 2337 + }; 2338 + 2339 + Ok(Scope::Include(IncludeScope { 2340 + nsid: Nsid::from_str(nsid_str)?, 2341 + audience, 2342 + })) 1835 2343 } 1836 2344 1837 2345 fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { ··· 2246 2754 #[cfg(feature = "scope-check")] 2247 2755 pub fn expand_permission_set( 2248 2756 perm_set: &jacquard_lexicon::lexicon::LexPermissionSet<'static>, 2249 - inherited_audience: Option<&Did<SmolStr>>, 2757 + inherited_audience: Option<&DidService<SmolStr>>, 2250 2758 ) -> Result<Vec<Scope<SmolStr>>, PermissionSetConversionError> { 2251 2759 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource}; 2252 2760 ··· 2382 2890 use super::*; 2383 2891 #[cfg(feature = "scope-check")] 2384 2892 use jacquard_common::CowStr; 2893 + use jacquard_common::xrpc::{XrpcMethod, XrpcResp}; 2894 + use serde::{Deserialize, Serialize}; 2895 + 2896 + #[derive(Debug, Serialize, Deserialize)] 2897 + struct TestRequest; 2898 + 2899 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] 2900 + #[error("test error")] 2901 + struct TestError; 2902 + 2903 + struct TestResponse; 2904 + 2905 + impl XrpcResp for TestResponse { 2906 + const NSID: &'static str = "app.bsky.feed.getTimeline"; 2907 + const ENCODING: &'static str = "application/json"; 2908 + type Output<S: BosStr> = (); 2909 + type Err = TestError; 2910 + } 2911 + 2912 + impl XrpcRequest for TestRequest { 2913 + const NSID: &'static str = "app.bsky.feed.getTimeline"; 2914 + const METHOD: XrpcMethod = XrpcMethod::Query; 2915 + type Response = TestResponse; 2916 + } 2917 + 2918 + struct TestCollection; 2919 + 2920 + impl Collection for TestCollection { 2921 + const NSID: &'static str = "app.bsky.feed.post"; 2922 + type Record = TestResponse; 2923 + } 2924 + 2925 + #[test] 2926 + fn test_scope_constructors() { 2927 + assert_eq!(Scope::atproto().to_string_normalized(), "atproto"); 2928 + assert_eq!( 2929 + Scope::identity_handle().to_string_normalized(), 2930 + "identity:handle" 2931 + ); 2932 + assert_eq!( 2933 + Scope::account_repo_manage().to_string_normalized(), 2934 + "account:repo?action=manage" 2935 + ); 2936 + assert_eq!(Scope::repo_all().to_string_normalized(), "repo:*"); 2937 + assert_eq!( 2938 + Scope::repo_create("app.bsky.feed.post") 2939 + .unwrap() 2940 + .to_string_normalized(), 2941 + "repo:app.bsky.feed.post?action=create" 2942 + ); 2943 + assert_eq!( 2944 + Scope::rpc("app.bsky.feed.getTimeline") 2945 + .unwrap() 2946 + .to_string_normalized(), 2947 + "rpc:app.bsky.feed.getTimeline" 2948 + ); 2949 + assert_eq!( 2950 + Scope::rpc_aud( 2951 + "app.bsky.feed.getTimeline", 2952 + "did:plc:yfvwmnlztr4dwkb7hwz55r2g" 2953 + ) 2954 + .unwrap() 2955 + .to_string_normalized(), 2956 + "rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=app.bsky.feed.getTimeline" 2957 + ); 2958 + assert_eq!( 2959 + Scope::include_aud("app.bsky.authFull", "did:web:example.com#svc") 2960 + .unwrap() 2961 + .to_string_normalized(), 2962 + "include:app.bsky.authFull?aud=did:web:example.com%23svc" 2963 + ); 2964 + } 2965 + 2966 + #[test] 2967 + fn test_endpoint_and_collection_scope_constructors() { 2968 + assert_eq!( 2969 + Scope::rpc_request::<TestRequest>().to_string_normalized(), 2970 + "rpc:app.bsky.feed.getTimeline" 2971 + ); 2972 + assert_eq!( 2973 + Scope::repo_create_record::<TestCollection>().to_string_normalized(), 2974 + "repo:app.bsky.feed.post?action=create" 2975 + ); 2976 + assert_eq!( 2977 + Scope::repo_all_record::<TestCollection>().to_string_normalized(), 2978 + "repo:app.bsky.feed.post" 2979 + ); 2980 + } 2981 + 2982 + #[test] 2983 + fn test_scopes_from_scopes_and_builder() { 2984 + let scopes = Scopes::from_scopes([ 2985 + Scope::repo_create("app.bsky.feed.post").unwrap(), 2986 + Scope::repo_all(), 2987 + Scope::atproto(), 2988 + ]) 2989 + .unwrap(); 2990 + 2991 + assert_eq!(scopes.to_normalized_string(), "atproto repo:*"); 2992 + 2993 + let built = Scopes::builder() 2994 + .atproto() 2995 + .transition_generic() 2996 + .rpc_request::<TestRequest>() 2997 + .repo_create_record::<TestCollection>() 2998 + .include("app.bsky.authFull") 2999 + .unwrap() 3000 + .build() 3001 + .unwrap(); 3002 + 3003 + assert_eq!( 3004 + built.to_normalized_string(), 3005 + "atproto include:app.bsky.authFull repo:app.bsky.feed.post?action=create rpc:app.bsky.feed.getTimeline transition:generic" 3006 + ); 3007 + } 3008 + 3009 + #[test] 3010 + fn test_single_scope_include_parsing() { 3011 + let scope: Scope = 3012 + Scope::parse("include:app.bsky.authFull?aud=did:web:example.com#svc").unwrap(); 3013 + assert_eq!( 3014 + scope.to_string_normalized(), 3015 + "include:app.bsky.authFull?aud=did:web:example.com%23svc" 3016 + ); 3017 + } 2385 3018 2386 3019 #[test] 2387 3020 fn test_account_scope_parsing() { ··· 2512 3145 Nsid::new_owned("com.example.service").unwrap(), 2513 3146 )); 2514 3147 aud.insert(RpcAudience::Did( 2515 - Did::new_owned("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 3148 + DidService::new_owned("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 2516 3149 )); 2517 3150 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 2518 3151 ··· 2528 3161 Nsid::new_owned("com.example.method2").unwrap(), 2529 3162 )); 2530 3163 aud.insert(RpcAudience::Did( 2531 - Did::new_owned("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 3164 + DidService::new_owned("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 2532 3165 )); 2533 3166 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 2534 3167 } ··· 4029 4662 permissions: perms, 4030 4663 }; 4031 4664 4032 - let inherited_did = Did::new_static("did:web:example.com").unwrap(); 4665 + let inherited_did = DidService::new_static("did:web:example.com").unwrap(); 4033 4666 let scopes = expand_permission_set(&perm_set, Some(&inherited_did)).unwrap(); 4034 4667 assert_eq!(scopes.len(), 1); 4035 4668 ··· 4053 4686 perms.push(LexPermission::Permission { 4054 4687 resource: LexPermissionResource::Rpc { 4055 4688 lxm: vec![Nsid::new_static("app.bsky.feed.getTimeline").unwrap()], 4056 - aud: Some(Did::new_static("did:web:custom.com").unwrap()), 4689 + aud: Some(DidService::new_static("did:web:custom.com").unwrap()), 4057 4690 inherit_aud: None, 4058 4691 }, 4059 4692 });
+70 -1
crates/jacquard-oauth/src/types.rs
··· 4 4 mod response; 5 5 mod token; 6 6 7 - use crate::scopes::Scopes; 7 + use crate::scopes::{ParseError, Scope, Scopes}; 8 8 9 9 pub use self::client_metadata::*; 10 10 pub use self::metadata::*; ··· 107 107 pub fn with_scopes(mut self, scopes: Scopes<S>) -> Self { 108 108 self.scopes = scopes; 109 109 self 110 + } 111 + } 112 + 113 + impl AuthorizeOptions<DefaultStr> { 114 + /// Parse and set OAuth scopes from a space-separated scope string. 115 + pub fn with_scope_str(mut self, scopes: impl AsRef<str>) -> Result<Self, ParseError> { 116 + self.scopes = Scopes::new(SmolStr::new(scopes.as_ref()))?; 117 + Ok(self) 118 + } 119 + 120 + /// Set OAuth scopes from one typed scope. 121 + pub fn with_scope(self, scope: Scope<SmolStr>) -> Result<Self, ParseError> { 122 + self.with_scope_iter([scope]) 123 + } 124 + 125 + /// Set OAuth scopes from typed scope values. 126 + pub fn with_scope_iter<I>(mut self, scopes: I) -> Result<Self, ParseError> 127 + where 128 + I: IntoIterator<Item = Scope<SmolStr>>, 129 + { 130 + self.scopes = Scopes::from_scopes(scopes)?; 131 + Ok(self) 132 + } 133 + } 134 + 135 + #[cfg(test)] 136 + mod tests { 137 + use super::*; 138 + 139 + #[test] 140 + fn authorize_options_accept_scope_string() { 141 + let opts = AuthorizeOptions::default() 142 + .with_scope_str("rpc:* atproto") 143 + .unwrap(); 144 + 145 + assert_eq!(opts.scopes.to_normalized_string(), "atproto rpc:*"); 146 + } 147 + 148 + #[test] 149 + fn authorize_options_accept_typed_scopes() { 150 + let opts = AuthorizeOptions::default() 151 + .with_scope_iter([ 152 + Scope::atproto(), 153 + Scope::rpc("app.bsky.feed.getTimeline").unwrap(), 154 + Scope::repo_create("app.bsky.feed.post").unwrap(), 155 + ]) 156 + .unwrap(); 157 + 158 + assert_eq!( 159 + opts.scopes.to_normalized_string(), 160 + "atproto repo:app.bsky.feed.post?action=create rpc:app.bsky.feed.getTimeline" 161 + ); 162 + } 163 + 164 + #[test] 165 + fn authorize_options_accept_built_scopes() { 166 + let scopes = Scopes::builder() 167 + .atproto() 168 + .transition_generic() 169 + .rpc("app.bsky.feed.getTimeline") 170 + .unwrap() 171 + .build() 172 + .unwrap(); 173 + let opts = AuthorizeOptions::default().with_scopes(scopes); 174 + 175 + assert_eq!( 176 + opts.scopes.to_normalized_string(), 177 + "atproto rpc:app.bsky.feed.getTimeline transition:generic" 178 + ); 110 179 } 111 180 } 112 181
+2 -2
crates/jacquard/tests/scope_check.rs
··· 8 8 use jacquard::client::Agent; 9 9 use jacquard::deps::fluent_uri::Uri; 10 10 use jacquard::types::did::Did; 11 - use jacquard::types::string::Nsid; 11 + use jacquard::types::string::{DidService, Nsid}; 12 12 use jacquard::xrpc::XrpcClient; 13 13 use jacquard::{BosStr, IntoStatic}; 14 14 use jacquard_common::http_client::HttpClient; ··· 592 592 )); 593 593 let mut aud = BTreeSet::new(); 594 594 aud.insert(RpcAudience::Did( 595 - Did::<SmolStr>::new_static("did:web:api.bsky.app").unwrap(), 595 + DidService::<SmolStr>::new_static("did:web:api.bsky.app").unwrap(), 596 596 )); 597 597 let resolved_scopes = Some(vec![Scope::Rpc(RpcScope { lxm, aud })]); 598 598
+12 -16
examples/axum_oauth_session.rs
··· 45 45 client::{Agent, FileAuthStore}, 46 46 common::deps::{fluent_uri::Uri, smol_str::SmolStr}, 47 47 oauth::{ 48 - atproto::{AtprotoClientMetadata, GrantType}, 49 - client::OAuthClient, 50 - keyset::Keyset, 51 - scopes::Scopes, 48 + atproto::AtprotoClientMetadata, client::OAuthClient, keyset::Keyset, scopes::Scopes, 52 49 session::ClientData, 53 50 }, 54 51 xrpc::XrpcClient, ··· 279 276 .map_err(|(err, _)| miette!("invalid client metadata URL: {err}"))?; 280 277 let redirect_uri = Uri::parse(format!("{base_url}/oauth/callback")) 281 278 .map_err(|(err, _)| miette!("invalid OAuth callback URL: {err}"))?; 282 - let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")) 279 + // The atproto scope-builder guide can help choose a suitable scope string: 280 + // https://atproto.com/guides/scope-builder. The typed builder below is the 281 + // Jacquard equivalent for code, and keeps the XRPC NSID tied to the endpoint type. 282 + let scopes = Scopes::builder() 283 + .atproto() 284 + .rpc_request_aud::<GetTimeline>("did:web:public.api.bsky.app#bsky_appview")? 285 + .build() 283 286 .map_err(|err| miette!("invalid OAuth scopes: {err}"))?; 284 287 285 - Ok(AtprotoClientMetadata { 288 + Ok(AtprotoClientMetadata::new( 289 + vec![redirect_uri], 286 290 client_id, 287 - client_uri: Some(client_uri), 288 - redirect_uris: vec![redirect_uri], 289 - grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 290 - scopes, 291 - jwks_uri: None, 292 - client_name: Some(SmolStr::new_static("Jacquard Axum OAuth example")), 293 - logo_uri: None, 294 - tos_uri: None, 295 - privacy_policy_uri: None, 296 - }) 291 + Some(scopes), 292 + )) 297 293 } 298 294 299 295 #[derive(Debug)]
+9 -1
examples/create_post.rs
··· 7 7 use jacquard::oauth::types::AuthorizeOptions; 8 8 use jacquard::richtext::RichText; 9 9 use jacquard::types::string::Datetime; 10 + use jacquard_oauth::scopes::Scopes; 10 11 11 12 #[derive(Parser, Debug)] 12 13 #[command( ··· 36 37 let Some(session) = oauth 37 38 .resume_or_login_with_local_server( 38 39 &hint, 39 - AuthorizeOptions::default(), 40 + AuthorizeOptions::default().with_scopes( 41 + Scopes::builder() 42 + .include_aud( 43 + "app.bsky.authCreatePosts", 44 + "did:web:public.api.bsky.app#bsky_appview", 45 + )? 46 + .build()?, 47 + ), 40 48 LoopbackConfig::default(), 41 49 ) 42 50 .await?
+4 -1
examples/create_whitewind_post.rs
··· 7 7 use jacquard::oauth::types::AuthorizeOptions; 8 8 use jacquard::types::string::Datetime; 9 9 use jacquard_common::deps::fluent_uri::Uri; 10 + use jacquard_oauth::scopes::Scopes; 10 11 use miette::IntoDiagnostic; 11 12 12 13 #[derive(Parser, Debug)] ··· 41 42 let Some(session) = oauth 42 43 .resume_or_login_with_local_server( 43 44 &hint, 44 - AuthorizeOptions::default(), 45 + AuthorizeOptions::default() 46 + // repo record creation scoped to the 'com.whtwnd.blog.entry' NSID 47 + .with_scopes(Scopes::builder().repo_create_record::<Entry>().build()?), 45 48 LoopbackConfig::default(), 46 49 ) 47 50 .await?
+12 -5
examples/moderated_timeline.rs
··· 12 12 use jacquard::oauth::types::AuthorizeOptions; 13 13 use jacquard::xrpc::{CallOptions, XrpcClient}; 14 14 use jacquard_api::app_bsky::feed::{ReplyRefParent, ReplyRefRoot}; 15 - use jacquard_api::app_bsky::labeler::get_services::GetServicesOutputViewsItem; 15 + use jacquard_api::app_bsky::labeler::get_services::{GetServices, GetServicesOutputViewsItem}; 16 + use jacquard_oauth::scopes::Scopes; 16 17 use smol_str::ToSmolStr; 17 18 18 19 // To save having to fetch prefs, etc., we're borrowing some from our test cases. ··· 71 72 let Some(session) = oauth 72 73 .resume_or_login_with_local_server( 73 74 &hint, 74 - AuthorizeOptions::default(), 75 + AuthorizeOptions::default().with_scopes( 76 + Scopes::builder() 77 + .atproto() 78 + .rpc_request_aud::<GetTimeline>("did:web:api.bsky.app#bsky_appview")? 79 + .rpc_request_aud::<GetServices>("did:web:api.bsky.app#bsky_appview")? 80 + .build()?, 81 + ), 75 82 LoopbackConfig::default(), 76 83 ) 77 84 .await? ··· 144 151 .ok() 145 152 .map(|p| p.text.to_string()) 146 153 .unwrap_or_else(|| "<no text>".to_string()); 147 - println!("@{}:\n{}", root.author.handle, root_text); 154 + println!("@{}:\n{}\n", root.author.handle, root_text); 148 155 } 149 156 } 150 157 let parent_text = from_data::<Post, _>(&parent.record) 151 158 .ok() 152 159 .map(|p| p.text.to_string()) 153 160 .unwrap_or_else(|| "<no text>".to_string()); 154 - println!("@{}:\n{}", parent.author.handle, parent_text); 161 + println!("@{}:\n{}\n", parent.author.handle, parent_text); 155 162 } 156 163 } 157 - println!("@{}:\n{}", post.author.handle, text); 164 + println!("@{}:\n{}\n", post.author.handle, text); 158 165 159 166 // Show details for any part with moderation causes 160 167 for (tag, decision) in decisions.iter() {
+18 -4
examples/oauth_timeline.rs
··· 10 10 use jacquard::oauth::client::OAuthResumeOrLogin; 11 11 #[cfg(feature = "loopback")] 12 12 use jacquard::oauth::loopback::LoopbackConfig; 13 + use jacquard::oauth::scopes::Scopes; 13 14 use jacquard::oauth::types::AuthorizeOptions; 14 15 #[cfg(not(feature = "loopback"))] 15 16 use jacquard::oauth::types::CallbackParams; ··· 44 45 // Build an OAuth client (this is reusable, and can create multiple sessions). 45 46 let oauth = OAuthClient::new(store, client_data, reqwest::Client::new()); 46 47 let hint = SessionHint::from_optional_input(args.input.as_deref()); 48 + // The atproto docs include a scope string builder for choosing permissions: 49 + // https://atproto.com/guides/scope-builder. In Jacquard code, use typed 50 + // helpers when possible so scope NSIDs stay tied to endpoint types. 51 + let timeline_scopes = Scopes::builder() 52 + .atproto() 53 + .rpc_request_aud::<GetTimeline>("did:web:api.bsky.app#bsky_appview")? 54 + .build()?; 47 55 48 56 #[cfg(feature = "loopback")] 49 57 let session = match oauth 50 58 .resume_or_login_with_local_server( 51 59 &hint, 52 - AuthorizeOptions::default(), 60 + AuthorizeOptions::default().with_scopes(timeline_scopes.clone()), 53 61 LoopbackConfig::default(), 54 62 ) 55 63 .await? ··· 60 68 oauth 61 69 .login_with_local_server( 62 70 input, 63 - AuthorizeOptions::default(), 71 + AuthorizeOptions::default().with_scopes(timeline_scopes.clone()), 64 72 LoopbackConfig::default(), 65 73 ) 66 74 .await? ··· 69 77 70 78 #[cfg(not(feature = "loopback"))] 71 79 let session = match oauth 72 - .resume_or_start_auth(&hint, AuthorizeOptions::default()) 80 + .resume_or_start_auth( 81 + &hint, 82 + AuthorizeOptions::default().with_scopes(timeline_scopes.clone()), 83 + ) 73 84 .await 74 85 { 75 86 Ok(OAuthResumeOrLogin::Resumed(session)) => session, ··· 77 88 Ok(OAuthResumeOrLogin::NeedsInput) => { 78 89 let input = prompt_login_input(&args.store)?; 79 90 match oauth 80 - .resume_or_start_auth_for(input, AuthorizeOptions::default()) 91 + .resume_or_start_auth_for( 92 + input, 93 + AuthorizeOptions::default().with_scopes(timeline_scopes.clone()), 94 + ) 81 95 .await? 82 96 { 83 97 OAuthResumeOrLogin::Resumed(session) => session,
+9 -1
examples/post_with_image.rs
··· 8 8 use jacquard::oauth::types::AuthorizeOptions; 9 9 use jacquard::types::blob::MimeType; 10 10 use jacquard::types::string::Datetime; 11 + use jacquard_oauth::scopes::Scopes; 11 12 use miette::IntoDiagnostic; 12 13 use smol_str::SmolStr; 13 14 use std::path::PathBuf; ··· 44 45 let Some(session) = oauth 45 46 .resume_or_login_with_local_server( 46 47 &hint, 47 - AuthorizeOptions::default(), 48 + AuthorizeOptions::default().with_scopes( 49 + Scopes::builder() 50 + .include_aud( 51 + "app.bsky.authCreatePosts", 52 + "did:web:public.api.bsky.app#bsky_appview", 53 + )? 54 + .build()?, 55 + ), 48 56 LoopbackConfig::default(), 49 57 ) 50 58 .await?
+9 -1
examples/update_preferences.rs
··· 7 7 use jacquard::oauth::client::OAuthClient; 8 8 use jacquard::oauth::loopback::LoopbackConfig; 9 9 use jacquard::oauth::types::AuthorizeOptions; 10 + use jacquard_oauth::scopes::Scopes; 10 11 11 12 #[derive(Parser, Debug)] 12 13 #[command(author, version, about = "Update Bluesky preferences")] ··· 32 33 let Some(session) = oauth 33 34 .resume_or_login_with_local_server( 34 35 &hint, 35 - AuthorizeOptions::default(), 36 + AuthorizeOptions::default().with_scopes( 37 + Scopes::builder() 38 + .include_aud( 39 + "app.bsky.authFullApp", 40 + "did:web:public.api.bsky.app#bsky_appview", 41 + )? 42 + .build()?, 43 + ), 36 44 LoopbackConfig::default(), 37 45 ) 38 46 .await?
+9 -1
examples/update_profile.rs
··· 6 6 use jacquard::oauth::loopback::LoopbackConfig; 7 7 use jacquard::oauth::types::AuthorizeOptions; 8 8 use jacquard::types::string::AtUri; 9 + use jacquard_oauth::scopes::Scopes; 9 10 use smol_str::SmolStr; 10 11 11 12 #[derive(Parser, Debug)] ··· 36 37 let Some(session) = oauth 37 38 .resume_or_login_with_local_server( 38 39 &hint, 39 - AuthorizeOptions::default(), 40 + AuthorizeOptions::default().with_scopes( 41 + Scopes::builder() 42 + .include_aud( 43 + "app.bsky.authManageProfile", 44 + "did:web:public.api.bsky.app#bsky_appview", 45 + )? 46 + .build()?, 47 + ), 40 48 LoopbackConfig::default(), 41 49 ) 42 50 .await?