+4
-4
README.md
+4
-4
README.md
···
131
### XRPC Service
132
133
```rust
134
-
use atproto_xrpcs::authorization::ResolvingAuthorization;
135
use axum::{Json, Router, extract::Query, routing::get};
136
use serde::Deserialize;
137
use serde_json::json;
···
143
144
async fn handle_hello(
145
params: Query<HelloParams>,
146
-
authorization: Option<ResolvingAuthorization>,
147
) -> Json<serde_json::Value> {
148
let subject = params.subject.as_deref().unwrap_or("World");
149
-
150
let message = if let Some(auth) = authorization {
151
format!("Hello, authenticated {}! (caller: {})", subject, auth.subject())
152
} else {
153
format!("Hello, {}!", subject)
154
};
155
-
156
Json(json!({ "message": message }))
157
}
158
···
131
### XRPC Service
132
133
```rust
134
+
use atproto_xrpcs::authorization::Authorization;
135
use axum::{Json, Router, extract::Query, routing::get};
136
use serde::Deserialize;
137
use serde_json::json;
···
143
144
async fn handle_hello(
145
params: Query<HelloParams>,
146
+
authorization: Option<Authorization>,
147
) -> Json<serde_json::Value> {
148
let subject = params.subject.as_deref().unwrap_or("World");
149
+
150
let message = if let Some(auth) = authorization {
151
format!("Hello, authenticated {}! (caller: {})", subject, auth.subject())
152
} else {
153
format!("Hello, {}!", subject)
154
};
155
+
156
Json(json!({ "message": message }))
157
}
158
+19
-1
crates/atproto-identity/src/model.rs
+19
-1
crates/atproto-identity/src/model.rs
···
70
/// The DID identifier (e.g., "did:plc:abc123").
71
pub id: String,
72
/// Alternative identifiers like handles and domains.
73
pub also_known_as: Vec<String>,
74
/// Available services for this identity.
75
pub service: Vec<Service>,
76
77
/// Cryptographic verification methods.
78
-
#[serde(alias = "verificationMethod")]
79
pub verification_method: Vec<VerificationMethod>,
80
81
/// Additional document properties not explicitly defined.
···
402
let document = document.unwrap();
403
assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
404
}
405
}
406
}
···
70
/// The DID identifier (e.g., "did:plc:abc123").
71
pub id: String,
72
/// Alternative identifiers like handles and domains.
73
+
#[serde(default)]
74
pub also_known_as: Vec<String>,
75
/// Available services for this identity.
76
+
#[serde(default)]
77
pub service: Vec<Service>,
78
79
/// Cryptographic verification methods.
80
+
#[serde(alias = "verificationMethod", default)]
81
pub verification_method: Vec<VerificationMethod>,
82
83
/// Additional document properties not explicitly defined.
···
404
let document = document.unwrap();
405
assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
406
}
407
+
}
408
+
409
+
#[test]
410
+
fn test_deserialize_service_did_document() {
411
+
// DID document from api.bsky.app - a service DID without alsoKnownAs
412
+
let document = serde_json::from_str::<Document>(
413
+
r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1"],"id":"did:web:api.bsky.app","verificationMethod":[{"id":"did:web:api.bsky.app#atproto","type":"Multikey","controller":"did:web:api.bsky.app","publicKeyMultibase":"zQ3shpRzb2NDriwCSSsce6EqGxG23kVktHZc57C3NEcuNy1jg"}],"service":[{"id":"#bsky_notif","type":"BskyNotificationService","serviceEndpoint":"https://api.bsky.app"},{"id":"#bsky_appview","type":"BskyAppView","serviceEndpoint":"https://api.bsky.app"}]}"##,
414
+
);
415
+
assert!(document.is_ok(), "Failed to parse: {:?}", document.err());
416
+
417
+
let document = document.unwrap();
418
+
assert_eq!(document.id, "did:web:api.bsky.app");
419
+
assert!(document.also_known_as.is_empty());
420
+
assert_eq!(document.service.len(), 2);
421
+
assert_eq!(document.service[0].id, "#bsky_notif");
422
+
assert_eq!(document.service[1].id, "#bsky_appview");
423
}
424
}
+283
crates/atproto-oauth/src/scopes.rs
+283
crates/atproto-oauth/src/scopes.rs
···
38
Atproto,
39
/// Transition scope for migration operations
40
Transition(TransitionScope),
41
/// OpenID Connect scope - required for OpenID Connect authentication
42
OpenId,
43
/// Profile scope - access to user profile information
···
91
Generic,
92
/// Email transition operations
93
Email,
94
}
95
96
/// Blob scope with mime type constraints
···
310
"rpc",
311
"atproto",
312
"transition",
313
"openid",
314
"profile",
315
"email",
···
349
"rpc" => Self::parse_rpc(suffix),
350
"atproto" => Self::parse_atproto(suffix),
351
"transition" => Self::parse_transition(suffix),
352
"openid" => Self::parse_openid(suffix),
353
"profile" => Self::parse_profile(suffix),
354
"email" => Self::parse_email(suffix),
···
573
Ok(Scope::Transition(scope))
574
}
575
576
fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
577
if suffix.is_some() {
578
return Err(ParseError::InvalidResource(
···
730
TransitionScope::Generic => "transition:generic".to_string(),
731
TransitionScope::Email => "transition:email".to_string(),
732
},
733
Scope::OpenId => "openid".to_string(),
734
Scope::Profile => "profile".to_string(),
735
Scope::Email => "email".to_string(),
···
749
// Other scopes don't grant transition scopes
750
(_, Scope::Transition(_)) => false,
751
(Scope::Transition(_), _) => false,
752
// OpenID Connect scopes only grant themselves
753
(Scope::OpenId, Scope::OpenId) => true,
754
(Scope::OpenId, _) => false,
···
888
}
889
890
params
891
}
892
893
/// Error type for scope parsing
···
1921
let reduced = Scope::parse_multiple_reduced("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*").unwrap();
1922
assert_eq!(reduced.len(), 1);
1923
assert_eq!(reduced[0], repo_all);
1924
}
1925
}
···
38
Atproto,
39
/// Transition scope for migration operations
40
Transition(TransitionScope),
41
+
/// Include scope for referencing permission sets by NSID
42
+
Include(IncludeScope),
43
/// OpenID Connect scope - required for OpenID Connect authentication
44
OpenId,
45
/// Profile scope - access to user profile information
···
93
Generic,
94
/// Email transition operations
95
Email,
96
+
}
97
+
98
+
/// Include scope for referencing permission sets by NSID
99
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
100
+
pub struct IncludeScope {
101
+
/// The permission set NSID (e.g., "app.example.authFull")
102
+
pub nsid: String,
103
+
/// Optional audience DID for inherited RPC permissions
104
+
pub aud: Option<String>,
105
}
106
107
/// Blob scope with mime type constraints
···
321
"rpc",
322
"atproto",
323
"transition",
324
+
"include",
325
"openid",
326
"profile",
327
"email",
···
361
"rpc" => Self::parse_rpc(suffix),
362
"atproto" => Self::parse_atproto(suffix),
363
"transition" => Self::parse_transition(suffix),
364
+
"include" => Self::parse_include(suffix),
365
"openid" => Self::parse_openid(suffix),
366
"profile" => Self::parse_profile(suffix),
367
"email" => Self::parse_email(suffix),
···
586
Ok(Scope::Transition(scope))
587
}
588
589
+
fn parse_include(suffix: Option<&str>) -> Result<Self, ParseError> {
590
+
let (nsid, params) = match suffix {
591
+
Some(s) => {
592
+
if let Some(pos) = s.find('?') {
593
+
(&s[..pos], Some(&s[pos + 1..]))
594
+
} else {
595
+
(s, None)
596
+
}
597
+
}
598
+
None => return Err(ParseError::MissingResource),
599
+
};
600
+
601
+
if nsid.is_empty() {
602
+
return Err(ParseError::MissingResource);
603
+
}
604
+
605
+
let aud = if let Some(params) = params {
606
+
let parsed_params = parse_query_string(params);
607
+
parsed_params
608
+
.get("aud")
609
+
.and_then(|v| v.first())
610
+
.map(|s| url_decode(s))
611
+
} else {
612
+
None
613
+
};
614
+
615
+
Ok(Scope::Include(IncludeScope {
616
+
nsid: nsid.to_string(),
617
+
aud,
618
+
}))
619
+
}
620
+
621
fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
622
if suffix.is_some() {
623
return Err(ParseError::InvalidResource(
···
775
TransitionScope::Generic => "transition:generic".to_string(),
776
TransitionScope::Email => "transition:email".to_string(),
777
},
778
+
Scope::Include(scope) => {
779
+
if let Some(ref aud) = scope.aud {
780
+
format!("include:{}?aud={}", scope.nsid, url_encode(aud))
781
+
} else {
782
+
format!("include:{}", scope.nsid)
783
+
}
784
+
}
785
Scope::OpenId => "openid".to_string(),
786
Scope::Profile => "profile".to_string(),
787
Scope::Email => "email".to_string(),
···
801
// Other scopes don't grant transition scopes
802
(_, Scope::Transition(_)) => false,
803
(Scope::Transition(_), _) => false,
804
+
// Include scopes only grant themselves (exact match including aud)
805
+
(Scope::Include(a), Scope::Include(b)) => a == b,
806
+
// Other scopes don't grant include scopes
807
+
(_, Scope::Include(_)) => false,
808
+
(Scope::Include(_), _) => false,
809
// OpenID Connect scopes only grant themselves
810
(Scope::OpenId, Scope::OpenId) => true,
811
(Scope::OpenId, _) => false,
···
945
}
946
947
params
948
+
}
949
+
950
+
/// Decode a percent-encoded string
951
+
fn url_decode(s: &str) -> String {
952
+
let mut result = String::with_capacity(s.len());
953
+
let mut chars = s.chars().peekable();
954
+
955
+
while let Some(c) = chars.next() {
956
+
if c == '%' {
957
+
let hex: String = chars.by_ref().take(2).collect();
958
+
if hex.len() == 2 {
959
+
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
960
+
result.push(byte as char);
961
+
continue;
962
+
}
963
+
}
964
+
result.push('%');
965
+
result.push_str(&hex);
966
+
} else {
967
+
result.push(c);
968
+
}
969
+
}
970
+
971
+
result
972
+
}
973
+
974
+
/// Encode a string for use in a URL query parameter
975
+
fn url_encode(s: &str) -> String {
976
+
let mut result = String::with_capacity(s.len() * 3);
977
+
978
+
for c in s.chars() {
979
+
match c {
980
+
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | ':' => {
981
+
result.push(c);
982
+
}
983
+
_ => {
984
+
for byte in c.to_string().as_bytes() {
985
+
result.push_str(&format!("%{:02X}", byte));
986
+
}
987
+
}
988
+
}
989
+
}
990
+
991
+
result
992
}
993
994
/// Error type for scope parsing
···
2022
let reduced = Scope::parse_multiple_reduced("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*").unwrap();
2023
assert_eq!(reduced.len(), 1);
2024
assert_eq!(reduced[0], repo_all);
2025
+
}
2026
+
2027
+
#[test]
2028
+
fn test_include_scope_parsing() {
2029
+
// Test basic include scope
2030
+
let scope = Scope::parse("include:app.example.authFull").unwrap();
2031
+
assert_eq!(
2032
+
scope,
2033
+
Scope::Include(IncludeScope {
2034
+
nsid: "app.example.authFull".to_string(),
2035
+
aud: None,
2036
+
})
2037
+
);
2038
+
2039
+
// Test include scope with audience
2040
+
let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com").unwrap();
2041
+
assert_eq!(
2042
+
scope,
2043
+
Scope::Include(IncludeScope {
2044
+
nsid: "app.example.authFull".to_string(),
2045
+
aud: Some("did:web:api.example.com".to_string()),
2046
+
})
2047
+
);
2048
+
2049
+
// Test include scope with URL-encoded audience (with fragment)
2050
+
let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat").unwrap();
2051
+
assert_eq!(
2052
+
scope,
2053
+
Scope::Include(IncludeScope {
2054
+
nsid: "app.example.authFull".to_string(),
2055
+
aud: Some("did:web:api.example.com#svc_chat".to_string()),
2056
+
})
2057
+
);
2058
+
2059
+
// Test missing NSID
2060
+
assert!(matches!(
2061
+
Scope::parse("include"),
2062
+
Err(ParseError::MissingResource)
2063
+
));
2064
+
2065
+
// Test empty NSID with query params
2066
+
assert!(matches!(
2067
+
Scope::parse("include:?aud=did:example:123"),
2068
+
Err(ParseError::MissingResource)
2069
+
));
2070
+
}
2071
+
2072
+
#[test]
2073
+
fn test_include_scope_normalization() {
2074
+
// Test normalization without audience
2075
+
let scope = Scope::parse("include:com.example.authBasic").unwrap();
2076
+
assert_eq!(scope.to_string_normalized(), "include:com.example.authBasic");
2077
+
2078
+
// Test normalization with audience (no special chars)
2079
+
let scope = Scope::parse("include:com.example.authBasic?aud=did:plc:xyz123").unwrap();
2080
+
assert_eq!(
2081
+
scope.to_string_normalized(),
2082
+
"include:com.example.authBasic?aud=did:plc:xyz123"
2083
+
);
2084
+
2085
+
// Test normalization with URL encoding (fragment needs encoding)
2086
+
let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat").unwrap();
2087
+
let normalized = scope.to_string_normalized();
2088
+
assert_eq!(
2089
+
normalized,
2090
+
"include:app.example.authFull?aud=did:web:api.example.com%23svc_chat"
2091
+
);
2092
+
}
2093
+
2094
+
#[test]
2095
+
fn test_include_scope_grants() {
2096
+
let include1 = Scope::parse("include:app.example.authFull").unwrap();
2097
+
let include2 = Scope::parse("include:app.example.authBasic").unwrap();
2098
+
let include1_with_aud = Scope::parse("include:app.example.authFull?aud=did:plc:xyz").unwrap();
2099
+
let account = Scope::parse("account:email").unwrap();
2100
+
2101
+
// Include scopes only grant themselves (exact match)
2102
+
assert!(include1.grants(&include1));
2103
+
assert!(!include1.grants(&include2));
2104
+
assert!(!include1.grants(&include1_with_aud)); // Different because aud differs
2105
+
assert!(include1_with_aud.grants(&include1_with_aud));
2106
+
2107
+
// Include scopes don't grant other scope types
2108
+
assert!(!include1.grants(&account));
2109
+
assert!(!account.grants(&include1));
2110
+
2111
+
// Include scopes don't grant atproto or transition
2112
+
let atproto = Scope::parse("atproto").unwrap();
2113
+
let transition = Scope::parse("transition:generic").unwrap();
2114
+
assert!(!include1.grants(&atproto));
2115
+
assert!(!include1.grants(&transition));
2116
+
assert!(!atproto.grants(&include1));
2117
+
assert!(!transition.grants(&include1));
2118
+
}
2119
+
2120
+
#[test]
2121
+
fn test_parse_multiple_with_include() {
2122
+
let scopes = Scope::parse_multiple("atproto include:app.example.auth repo:*").unwrap();
2123
+
assert_eq!(scopes.len(), 3);
2124
+
assert_eq!(scopes[0], Scope::Atproto);
2125
+
assert!(matches!(scopes[1], Scope::Include(_)));
2126
+
assert!(matches!(scopes[2], Scope::Repo(_)));
2127
+
2128
+
// Test with URL-encoded audience
2129
+
let scopes = Scope::parse_multiple(
2130
+
"include:app.example.auth?aud=did:web:api.example.com%23svc account:email"
2131
+
).unwrap();
2132
+
assert_eq!(scopes.len(), 2);
2133
+
if let Scope::Include(inc) = &scopes[0] {
2134
+
assert_eq!(inc.nsid, "app.example.auth");
2135
+
assert_eq!(inc.aud, Some("did:web:api.example.com#svc".to_string()));
2136
+
} else {
2137
+
panic!("Expected Include scope");
2138
+
}
2139
+
}
2140
+
2141
+
#[test]
2142
+
fn test_parse_multiple_reduced_with_include() {
2143
+
// Include scopes don't reduce each other (each is distinct)
2144
+
let scopes = Scope::parse_multiple_reduced(
2145
+
"include:app.example.auth include:app.example.other include:app.example.auth"
2146
+
).unwrap();
2147
+
assert_eq!(scopes.len(), 2); // Duplicates are removed
2148
+
assert!(scopes.contains(&Scope::Include(IncludeScope {
2149
+
nsid: "app.example.auth".to_string(),
2150
+
aud: None,
2151
+
})));
2152
+
assert!(scopes.contains(&Scope::Include(IncludeScope {
2153
+
nsid: "app.example.other".to_string(),
2154
+
aud: None,
2155
+
})));
2156
+
2157
+
// Include scopes with different audiences are not duplicates
2158
+
let scopes = Scope::parse_multiple_reduced(
2159
+
"include:app.example.auth include:app.example.auth?aud=did:plc:xyz"
2160
+
).unwrap();
2161
+
assert_eq!(scopes.len(), 2);
2162
+
}
2163
+
2164
+
#[test]
2165
+
fn test_serialize_multiple_with_include() {
2166
+
let scopes = vec![
2167
+
Scope::parse("repo:*").unwrap(),
2168
+
Scope::parse("include:app.example.authFull").unwrap(),
2169
+
Scope::Atproto,
2170
+
];
2171
+
let result = Scope::serialize_multiple(&scopes);
2172
+
assert_eq!(result, "atproto include:app.example.authFull repo:*");
2173
+
2174
+
// Test with URL-encoded audience
2175
+
let scopes = vec![
2176
+
Scope::Include(IncludeScope {
2177
+
nsid: "app.example.auth".to_string(),
2178
+
aud: Some("did:web:api.example.com#svc".to_string()),
2179
+
}),
2180
+
];
2181
+
let result = Scope::serialize_multiple(&scopes);
2182
+
assert_eq!(result, "include:app.example.auth?aud=did:web:api.example.com%23svc");
2183
+
}
2184
+
2185
+
#[test]
2186
+
fn test_remove_scope_with_include() {
2187
+
let scopes = vec![
2188
+
Scope::Atproto,
2189
+
Scope::parse("include:app.example.auth").unwrap(),
2190
+
Scope::parse("account:email").unwrap(),
2191
+
];
2192
+
let to_remove = Scope::parse("include:app.example.auth").unwrap();
2193
+
let result = Scope::remove_scope(&scopes, &to_remove);
2194
+
assert_eq!(result.len(), 2);
2195
+
assert!(!result.contains(&to_remove));
2196
+
assert!(result.contains(&Scope::Atproto));
2197
+
}
2198
+
2199
+
#[test]
2200
+
fn test_include_scope_roundtrip() {
2201
+
// Test that parse and serialize are inverses
2202
+
let original = "include:com.example.authBasicFeatures?aud=did:web:api.example.com%23svc_appview";
2203
+
let scope = Scope::parse(original).unwrap();
2204
+
let serialized = scope.to_string_normalized();
2205
+
let reparsed = Scope::parse(&serialized).unwrap();
2206
+
assert_eq!(scope, reparsed);
2207
}
2208
}
+3
-13
crates/atproto-xrpcs-helloworld/src/main.rs
+3
-13
crates/atproto-xrpcs-helloworld/src/main.rs
···
7
config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version},
8
key::{KeyData, KeyResolver, identify_key, to_public},
9
resolve::{HickoryDnsResolver, IdentityResolver, InnerIdentityResolver},
10
-
storage_lru::LruDidDocumentStorage,
11
-
traits::DidDocumentStorage,
12
};
13
-
use atproto_xrpcs::authorization::ResolvingAuthorization;
14
use axum::{
15
Json, Router,
16
extract::{FromRef, Query, State},
···
21
use http::{HeaderMap, StatusCode};
22
use serde::Deserialize;
23
use serde_json::json;
24
-
use std::{collections::HashMap, num::NonZeroUsize, ops::Deref, sync::Arc};
25
26
#[derive(Clone)]
27
pub struct SimpleKeyResolver {
···
61
62
pub struct InnerWebContext {
63
pub http_client: reqwest::Client,
64
-
pub document_storage: Arc<dyn DidDocumentStorage>,
65
pub key_resolver: Arc<dyn KeyResolver>,
66
pub service_document: ServiceDocument,
67
pub service_did: ServiceDID,
···
97
}
98
}
99
100
-
impl FromRef<WebContext> for Arc<dyn DidDocumentStorage> {
101
-
fn from_ref(context: &WebContext) -> Self {
102
-
context.0.document_storage.clone()
103
-
}
104
-
}
105
-
106
impl FromRef<WebContext> for Arc<dyn KeyResolver> {
107
fn from_ref(context: &WebContext) -> Self {
108
context.0.key_resolver.clone()
···
216
217
let web_context = WebContext(Arc::new(InnerWebContext {
218
http_client: http_client.clone(),
219
-
document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())),
220
key_resolver: Arc::new(SimpleKeyResolver {
221
keys: signing_key_storage,
222
}),
···
284
async fn handle_xrpc_hello_world(
285
parameters: Query<HelloParameters>,
286
headers: HeaderMap,
287
-
authorization: Option<ResolvingAuthorization>,
288
) -> Json<serde_json::Value> {
289
println!("headers {headers:?}");
290
let subject = parameters.subject.as_deref().unwrap_or("World");
···
7
config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version},
8
key::{KeyData, KeyResolver, identify_key, to_public},
9
resolve::{HickoryDnsResolver, IdentityResolver, InnerIdentityResolver},
10
};
11
+
use atproto_xrpcs::authorization::Authorization;
12
use axum::{
13
Json, Router,
14
extract::{FromRef, Query, State},
···
19
use http::{HeaderMap, StatusCode};
20
use serde::Deserialize;
21
use serde_json::json;
22
+
use std::{collections::HashMap, ops::Deref, sync::Arc};
23
24
#[derive(Clone)]
25
pub struct SimpleKeyResolver {
···
59
60
pub struct InnerWebContext {
61
pub http_client: reqwest::Client,
62
pub key_resolver: Arc<dyn KeyResolver>,
63
pub service_document: ServiceDocument,
64
pub service_did: ServiceDID,
···
94
}
95
}
96
97
impl FromRef<WebContext> for Arc<dyn KeyResolver> {
98
fn from_ref(context: &WebContext) -> Self {
99
context.0.key_resolver.clone()
···
207
208
let web_context = WebContext(Arc::new(InnerWebContext {
209
http_client: http_client.clone(),
210
key_resolver: Arc::new(SimpleKeyResolver {
211
keys: signing_key_storage,
212
}),
···
274
async fn handle_xrpc_hello_world(
275
parameters: Query<HelloParameters>,
276
headers: HeaderMap,
277
+
authorization: Option<Authorization>,
278
) -> Json<serde_json::Value> {
279
println!("headers {headers:?}");
280
let subject = parameters.subject.as_deref().unwrap_or("World");
+13
-13
crates/atproto-xrpcs/README.md
+13
-13
crates/atproto-xrpcs/README.md
···
23
### Basic XRPC Service
24
25
```rust
26
-
use atproto_xrpcs::authorization::ResolvingAuthorization;
27
use axum::{Json, Router, extract::Query, routing::get};
28
use serde::Deserialize;
29
use serde_json::json;
···
35
36
async fn handle_hello(
37
params: Query<HelloParams>,
38
-
authorization: Option<ResolvingAuthorization>,
39
) -> Json<serde_json::Value> {
40
let name = params.name.as_deref().unwrap_or("World");
41
-
42
let message = if authorization.is_some() {
43
format!("Hello, authenticated {}!", name)
44
} else {
45
format!("Hello, {}!", name)
46
};
47
-
48
Json(json!({ "message": message }))
49
}
50
···
56
### JWT Authorization
57
58
```rust
59
-
use atproto_xrpcs::authorization::ResolvingAuthorization;
60
61
async fn handle_secure_endpoint(
62
-
authorization: ResolvingAuthorization, // Required authorization
63
) -> Json<serde_json::Value> {
64
-
// The ResolvingAuthorization extractor automatically:
65
// 1. Validates the JWT token
66
-
// 2. Resolves the caller's DID document
67
// 3. Verifies the signature against the DID document
68
// 4. Provides access to caller identity information
69
-
70
let caller_did = authorization.subject();
71
Json(json!({"caller": caller_did, "status": "authenticated"}))
72
}
···
79
use axum::{response::IntoResponse, http::StatusCode};
80
81
async fn protected_handler(
82
-
authorization: Result<ResolvingAuthorization, AuthorizationError>,
83
) -> impl IntoResponse {
84
match authorization {
85
Ok(auth) => (StatusCode::OK, "Access granted").into_response(),
86
-
Err(AuthorizationError::InvalidJWTToken { .. }) => {
87
(StatusCode::UNAUTHORIZED, "Invalid token").into_response()
88
}
89
-
Err(AuthorizationError::DIDDocumentResolutionFailed { .. }) => {
90
(StatusCode::FORBIDDEN, "Identity verification failed").into_response()
91
}
92
Err(_) => {
···
98
99
## Authorization Flow
100
101
-
The `ResolvingAuthorization` extractor implements:
102
103
1. JWT extraction from HTTP Authorization headers
104
2. Token validation (signature and claims structure)
···
23
### Basic XRPC Service
24
25
```rust
26
+
use atproto_xrpcs::authorization::Authorization;
27
use axum::{Json, Router, extract::Query, routing::get};
28
use serde::Deserialize;
29
use serde_json::json;
···
35
36
async fn handle_hello(
37
params: Query<HelloParams>,
38
+
authorization: Option<Authorization>,
39
) -> Json<serde_json::Value> {
40
let name = params.name.as_deref().unwrap_or("World");
41
+
42
let message = if authorization.is_some() {
43
format!("Hello, authenticated {}!", name)
44
} else {
45
format!("Hello, {}!", name)
46
};
47
+
48
Json(json!({ "message": message }))
49
}
50
···
56
### JWT Authorization
57
58
```rust
59
+
use atproto_xrpcs::authorization::Authorization;
60
61
async fn handle_secure_endpoint(
62
+
authorization: Authorization, // Required authorization
63
) -> Json<serde_json::Value> {
64
+
// The Authorization extractor automatically:
65
// 1. Validates the JWT token
66
+
// 2. Resolves the caller's DID document
67
// 3. Verifies the signature against the DID document
68
// 4. Provides access to caller identity information
69
+
70
let caller_did = authorization.subject();
71
Json(json!({"caller": caller_did, "status": "authenticated"}))
72
}
···
79
use axum::{response::IntoResponse, http::StatusCode};
80
81
async fn protected_handler(
82
+
authorization: Result<Authorization, AuthorizationError>,
83
) -> impl IntoResponse {
84
match authorization {
85
Ok(auth) => (StatusCode::OK, "Access granted").into_response(),
86
+
Err(AuthorizationError::InvalidJWTFormat) => {
87
(StatusCode::UNAUTHORIZED, "Invalid token").into_response()
88
}
89
+
Err(AuthorizationError::SubjectResolutionFailed { .. }) => {
90
(StatusCode::FORBIDDEN, "Identity verification failed").into_response()
91
}
92
Err(_) => {
···
98
99
## Authorization Flow
100
101
+
The `Authorization` extractor implements:
102
103
1. JWT extraction from HTTP Authorization headers
104
2. Token validation (signature and claims structure)
+5
-49
crates/atproto-xrpcs/src/errors.rs
+5
-49
crates/atproto-xrpcs/src/errors.rs
···
42
#[error("error-atproto-xrpcs-authorization-4 No issuer found in JWT claims")]
43
NoIssuerInClaims,
44
45
-
/// Occurs when DID document is not found for the issuer
46
-
#[error("error-atproto-xrpcs-authorization-5 DID document not found for issuer: {issuer}")]
47
-
DIDDocumentNotFound {
48
-
/// The issuer DID that was not found
49
-
issuer: String,
50
-
},
51
-
52
/// Occurs when no verification keys are found in DID document
53
-
#[error("error-atproto-xrpcs-authorization-6 No verification keys found in DID document")]
54
NoVerificationKeys,
55
56
/// Occurs when JWT header cannot be base64 decoded
57
-
#[error("error-atproto-xrpcs-authorization-7 Failed to decode JWT header: {error}")]
58
HeaderDecodeError {
59
/// The underlying base64 decode error
60
error: base64::DecodeError,
61
},
62
63
/// Occurs when JWT header cannot be parsed as JSON
64
-
#[error("error-atproto-xrpcs-authorization-8 Failed to parse JWT header: {error}")]
65
HeaderParseError {
66
/// The underlying JSON parse error
67
error: serde_json::Error,
68
},
69
70
/// Occurs when JWT validation fails with all available keys
71
-
#[error("error-atproto-xrpcs-authorization-9 JWT validation failed with all available keys")]
72
ValidationFailedAllKeys,
73
74
/// Occurs when subject resolution fails during DID document lookup
75
-
#[error("error-atproto-xrpcs-authorization-10 Subject resolution failed: {issuer} {error}")]
76
SubjectResolutionFailed {
77
/// The issuer that failed to resolve
78
issuer: String,
79
/// The underlying resolution error
80
-
error: anyhow::Error,
81
-
},
82
-
83
-
/// Occurs when DID document lookup fails after successful resolution
84
-
#[error(
85
-
"error-atproto-xrpcs-authorization-11 DID document not found for resolved issuer: {resolved_did}"
86
-
)]
87
-
ResolvedDIDDocumentNotFound {
88
-
/// The resolved DID that was not found in storage
89
-
resolved_did: String,
90
-
},
91
-
92
-
/// Occurs when PLC directory query fails
93
-
#[error("error-atproto-xrpcs-authorization-12 PLC directory query failed: {error}")]
94
-
PLCQueryFailed {
95
-
/// The underlying PLC query error
96
-
error: anyhow::Error,
97
-
},
98
-
99
-
/// Occurs when web DID query fails
100
-
#[error("error-atproto-xrpcs-authorization-13 Web DID query failed: {error}")]
101
-
WebDIDQueryFailed {
102
-
/// The underlying web DID query error
103
-
error: anyhow::Error,
104
-
},
105
-
106
-
/// Occurs when DID document storage operation fails
107
-
#[error("error-atproto-xrpcs-authorization-14 DID document storage failed: {error}")]
108
-
DocumentStorageFailed {
109
-
/// The underlying storage error
110
-
error: anyhow::Error,
111
-
},
112
-
113
-
/// Occurs when input parsing fails for resolved DID
114
-
#[error("error-atproto-xrpcs-authorization-15 Input parsing failed for resolved DID: {error}")]
115
-
InputParsingFailed {
116
-
/// The underlying parsing error
117
error: anyhow::Error,
118
},
119
}
···
42
#[error("error-atproto-xrpcs-authorization-4 No issuer found in JWT claims")]
43
NoIssuerInClaims,
44
45
/// Occurs when no verification keys are found in DID document
46
+
#[error("error-atproto-xrpcs-authorization-5 No verification keys found in DID document")]
47
NoVerificationKeys,
48
49
/// Occurs when JWT header cannot be base64 decoded
50
+
#[error("error-atproto-xrpcs-authorization-6 Failed to decode JWT header: {error}")]
51
HeaderDecodeError {
52
/// The underlying base64 decode error
53
error: base64::DecodeError,
54
},
55
56
/// Occurs when JWT header cannot be parsed as JSON
57
+
#[error("error-atproto-xrpcs-authorization-7 Failed to parse JWT header: {error}")]
58
HeaderParseError {
59
/// The underlying JSON parse error
60
error: serde_json::Error,
61
},
62
63
/// Occurs when JWT validation fails with all available keys
64
+
#[error("error-atproto-xrpcs-authorization-8 JWT validation failed with all available keys")]
65
ValidationFailedAllKeys,
66
67
/// Occurs when subject resolution fails during DID document lookup
68
+
#[error("error-atproto-xrpcs-authorization-9 Subject resolution failed: {issuer} {error}")]
69
SubjectResolutionFailed {
70
/// The issuer that failed to resolve
71
issuer: String,
72
/// The underlying resolution error
73
error: anyhow::Error,
74
},
75
}