···2323//! );
2424//! let config = ServiceAuthConfig::new(
2525//! Did::new_static("did:web:feedgen.example.com").unwrap(),
2626+//! &[],
2627//! resolver,
2728//! );
2829//!
···4950 service_auth::{self, PublicKey},
5051 types::{
5152 did_doc::VerificationMethod,
5252- string::{Did, Nsid},
5353+ string::{Did, DidService, Nsid},
5354 },
5455};
5556use jacquard_identity::resolver::IdentityResolver;
5657use serde_json::json;
5757-use std::sync::Arc;
5858+use smol_str::SmolStr;
5959+use std::{borrow::Cow, sync::Arc};
5860use thiserror::Error;
59616062/// Trait for providing service authentication configuration.
···73757476 /// Whether to require the `lxm` (method binding) field
7577 fn require_lxm(&self) -> bool;
7878+7979+ /// Allow-list of service ids accepted in the audience `#service_id`
8080+ /// fragment. An empty slice disables the check and accepts any fragment
8181+ /// (including none).
8282+ fn allowed_services(&self) -> &[SmolStr];
7683}
77847885/// Configuration for service auth verification.
···8289pub struct ServiceAuthConfig<R> {
8390 /// The DID of your service (the expected audience)
8491 service_did: Did<'static>,
9292+ /// Allow-list of service ids accepted in the audience's `#service_id`
9393+ /// fragment. Empty means "no service-id check".
9494+ services: Vec<SmolStr>,
8595 /// Identity resolver for fetching DID documents
8696 resolver: Arc<R>,
8797 /// Whether to require the `lxm` (method binding) field
···92102 fn clone(&self) -> Self {
93103 Self {
94104 service_did: self.service_did.clone(),
105105+ services: self.services.clone(),
95106 resolver: Arc::clone(&self.resolver),
96107 require_lxm: self.require_lxm,
97108 }
···101112impl<R: IdentityResolver> ServiceAuthConfig<R> {
102113 /// Create a new service auth config.
103114 ///
104104- /// This enables `lxm` (method binding). If you need backward compatibility,
105105- /// use `ServiceAuthConfig::new_legacy()`
106106- pub fn new(service_did: Did<'static>, resolver: R) -> Self {
115115+ /// `services` is an allow-list of acceptable values for the audience's
116116+ /// `#service_id` fragment (e.g. `["bsky_appview"]`). Pass an empty slice
117117+ /// to accept any fragment (including none). This enables `lxm` (method
118118+ /// binding). If you need backward compatibility, use
119119+ /// [`ServiceAuthConfig::new_legacy`].
120120+ pub fn new(service_did: Did<'static>, services: &[&str], resolver: R) -> Self {
107121 Self {
108122 service_did,
123123+ services: services.iter().map(|s| SmolStr::new(*s)).collect(),
109124 resolver: Arc::new(resolver),
110125 require_lxm: true,
111126 }
···113128114129 /// Create a new service auth config.
115130 ///
116116- /// `lxm` (method binding) is disabled for backwards compatibility
117117- pub fn new_legacy(service_did: Did<'static>, resolver: R) -> Self {
131131+ /// `lxm` (method binding) is disabled for backwards compatibility. See
132132+ /// [`Self::new`] for the meaning of `services`.
133133+ pub fn new_legacy(service_did: Did<'static>, services: &[&str], resolver: R) -> Self {
118134 Self {
119135 service_did,
136136+ services: services.iter().map(|s| SmolStr::new(*s)).collect(),
120137 resolver: Arc::new(resolver),
121138 require_lxm: false,
122139 }
···136153 &self.service_did
137154 }
138155156156+ /// Get the allowed service-id fragments (empty if disabled).
157157+ pub fn services(&self) -> &[SmolStr] {
158158+ &self.services
159159+ }
160160+139161 /// Get a reference to the identity resolver.
140162 pub fn resolver(&self) -> &R {
141163 &self.resolver
···156178 fn require_lxm(&self) -> bool {
157179 self.require_lxm
158180 }
181181+182182+ fn allowed_services(&self) -> &[SmolStr] {
183183+ &self.services
184184+ }
159185}
160186161187/// Verified service authentication information.
···166192pub struct VerifiedServiceAuth<'a> {
167193 /// The authenticated user's DID (from `iss` claim)
168194 did: Did<'a>,
169169- /// The audience (should match your service DID)
170170- aud: Did<'a>,
195195+ /// The audience (your service DID, optionally with a `#service_id` fragment)
196196+ aud: DidService<'a>,
171197 /// The lexicon method NSID, if present
172198 lxm: Option<Nsid<'a>>,
173199 /// JWT ID (nonce), if present
···180206 &self.did
181207 }
182208183183- /// Get the audience (your service DID).
184184- pub fn aud(&self) -> &Did<'a> {
209209+ /// Get the audience (your service DID, optionally with a `#service_id` fragment).
210210+ pub fn aud(&self) -> &DidService<'a> {
185211 &self.aud
186212 }
187213···227253/// );
228254/// let config = ServiceAuthConfig::new(
229255/// Did::new_static("did:web:feedgen.example.com").unwrap(),
256256+/// &[],
230257/// resolver,
231258/// );
232259///
···276303/// );
277304/// let config = ServiceAuthConfig::new(
278305/// Did::new_static("did:web:example.com").unwrap(),
306306+/// &[],
279307/// resolver,
280308/// );
281309///
···445473 service_auth::verify_signature(&parsed, &signing_key)?;
446474447475 // Now validate claims (audience, expiration, etc.)
448448- claims.validate(state.service_did())?;
476476+ claims.validate(state.service_did(), state.allowed_services())?;
449477450478 // Check method binding if required
451479 if state.require_lxm() && claims.lxm.is_none() {
···527555 service_auth::verify_signature(&parsed, &signing_key)?;
528556529557 // Now validate claims (audience, expiration, etc.)
530530- claims.validate(state.service_did())?;
558558+ claims.validate(state.service_did(), state.allowed_services())?;
531559532560 // Check method binding if required
533561 if state.require_lxm() && claims.lxm.is_none() {
···622650/// );
623651/// let config = ServiceAuthConfig::new(
624652/// Did::new_static("did:web:feedgen.example.com").unwrap(),
653653+/// &[],
625654/// resolver,
626655/// );
627656///
+9-8
crates/jacquard-axum/tests/service_auth_tests.rs
···165165 let resolver = MockResolver::new(did_doc);
166166167167 // Create config (default: require_lxm = true)
168168- let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
168168+ let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver);
169169170170 // Create handler
171171 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
···210210 let did_doc = create_test_did_doc(user_did, verifying_key);
211211 let resolver = MockResolver::new(did_doc);
212212213213- let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
213213+ let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver);
214214215215 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
216216 format!("Authenticated as {}", auth.did())
···246246 let did_doc = create_test_did_doc(user_did, verifying_key);
247247 let resolver = MockResolver::new(did_doc);
248248249249- let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
249249+ let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver);
250250251251 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
252252 format!("Authenticated as {}", auth.did())
···278278 let did_doc = create_test_did_doc(user_did, verifying_key);
279279 let resolver = MockResolver::new(did_doc);
280280281281- let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
281281+ let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver);
282282283283 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
284284 format!("Authenticated as {}", auth.did())
···317317 let resolver = MockResolver::new(did_doc);
318318319319 // Create config (default: require_lxm = true)
320320- let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
320320+ let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver);
321321322322 async fn handler(Extension(auth): Extension<VerifiedServiceAuth<'static>>) -> String {
323323 format!("Authenticated as {}", auth.did())
···365365 let resolver = MockResolver::new(did_doc);
366366367367 let config =
368368- ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(true);
368368+ ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver).require_lxm(true);
369369370370 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
371371 format!("Authenticated as {}", auth.did())
···409409 let resolver = MockResolver::new(did_doc);
410410411411 let config =
412412- ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(true);
412412+ ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver).require_lxm(true);
413413414414 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
415415 format!(
···464464465465 // Legacy config: lxm not required
466466 let config =
467467- ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(false);
467467+ ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver).require_lxm(false);
468468469469 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
470470 format!("Authenticated as {}", auth.did())
···520520521521 let config = ServiceAuthConfig::new(
522522 Did::new_static("did:web:dev.pdsmoover.com").unwrap(),
523523+ &[],
523524 resolver,
524525 );
525526
+101-17
crates/jacquard-common/src/service_auth.rs
···18181919use crate::CowStr;
2020use crate::IntoStatic;
2121-use crate::types::string::{Did, Nsid};
2121+use crate::types::string::{Did, DidService, Nsid};
2222use alloc::string::String;
2323use alloc::string::ToString;
2424use alloc::vec::Vec;
···7878 AudienceMismatch {
7979 /// Expected audience DID
8080 expected: Did<'static>,
8181- /// Actual audience DID in token
8282- actual: Did<'static>,
8181+ /// Actual audience in token (DID with optional service id)
8282+ actual: DidService<'static>,
8383+ },
8484+8585+ /// Service id in audience did not match any of the allowed service ids
8686+ #[error("service id mismatch: expected one of {allowed:?}, got {actual:?}")]
8787+ ServiceIdMismatch {
8888+ /// Allowed service ids (caller-supplied)
8989+ allowed: alloc::vec::Vec<SmolStr>,
9090+ /// Service id present in the token's audience, if any
9191+ actual: Option<SmolStr>,
8392 },
84938594 /// Method mismatch (lxm field)
···131140 #[serde(borrow)]
132141 pub iss: Did<'a>,
133142134134- /// Audience (target service DID)
143143+ /// Audience (target service DID, optionally with `#service_id` fragment)
135144 #[serde(borrow)]
136136- pub aud: Did<'a>,
145145+ pub aud: DidService<'a>,
137146138147 /// Expiration time (unix timestamp)
139148 pub exp: i64,
···168177impl<'a> ServiceAuthClaims<'a> {
169178 /// Validate the claims against expected values.
170179 ///
171171- /// Checks:
172172- /// - Audience matches expected DID
173173- /// - Token is not expired
174174- pub fn validate(&self, expected_aud: &Did) -> Result<(), ServiceAuthError> {
175175- // Check audience
176176- if self.aud.as_str() != expected_aud.as_str() {
180180+ /// Checks, in order:
181181+ /// - Audience DID matches `expected_aud` (the service-id fragment is
182182+ /// compared separately below, not as part of the DID match).
183183+ /// - If `allowed_services` is non-empty, the audience's service id
184184+ /// (`#service_id` fragment) must be present and must equal one of the
185185+ /// provided values. An empty slice disables the service-id check and
186186+ /// accepts any fragment — including none.
187187+ /// - Token is not expired.
188188+ pub fn validate<S>(
189189+ &self,
190190+ expected_aud: &Did,
191191+ allowed_services: &[S],
192192+ ) -> Result<(), ServiceAuthError>
193193+ where
194194+ S: AsRef<str>,
195195+ {
196196+ // Check audience (DID portion only; service id is checked next).
197197+ if self.aud.audience().as_str() != expected_aud.as_str() {
177198 return Err(ServiceAuthError::AudienceMismatch {
178199 expected: expected_aud.clone().into_static(),
179200 actual: self.aud.clone().into_static(),
180201 });
181202 }
182203204204+ // If the caller provided an allow-list of service ids, enforce it.
205205+ if !allowed_services.is_empty() && self.aud.service().is_some() {
206206+ let actual = self.aud.service();
207207+ let matched = actual
208208+ .map(|svc| allowed_services.iter().any(|s| s.as_ref() == svc))
209209+ .unwrap_or(false);
210210+ if !matched {
211211+ return Err(ServiceAuthError::ServiceIdMismatch {
212212+ allowed: allowed_services
213213+ .iter()
214214+ .map(|s| SmolStr::new(s.as_ref()))
215215+ .collect(),
216216+ actual: actual.map(SmolStr::new),
217217+ });
218218+ }
219219+ }
220220+183221 // Check expiration
184222 if self.is_expired() {
185223 let now = chrono::Utc::now().timestamp();
···421459 let now = chrono::Utc::now().timestamp();
422460 let expired_claims = ServiceAuthClaims {
423461 iss: Did::new("did:plc:test").unwrap(),
424424- aud: Did::new("did:web:example.com").unwrap(),
462462+ aud: DidService::new("did:web:example.com").unwrap(),
425463 exp: now - 100,
426464 iat: now - 200,
427465 jti: None,
···432470433471 let valid_claims = ServiceAuthClaims {
434472 iss: Did::new("did:plc:test").unwrap(),
435435- aud: Did::new("did:web:example.com").unwrap(),
473473+ aud: DidService::new("did:web:example.com").unwrap(),
436474 exp: now + 100,
437475 iat: now,
438476 jti: None,
···447485 let now = chrono::Utc::now().timestamp();
448486 let claims = ServiceAuthClaims {
449487 iss: Did::new("did:plc:test").unwrap(),
450450- aud: Did::new("did:web:example.com").unwrap(),
488488+ aud: DidService::new("did:web:example.com").unwrap(),
451489 exp: now + 100,
452490 iat: now,
453491 jti: None,
···455493 };
456494457495 let expected_aud = Did::new("did:web:example.com").unwrap();
458458- assert!(claims.validate(&expected_aud).is_ok());
496496+ let no_services: &[&str] = &[];
497497+ assert!(claims.validate(&expected_aud, no_services).is_ok());
459498460499 let wrong_aud = Did::new("did:web:wrong.com").unwrap();
461500 assert!(matches!(
462462- claims.validate(&wrong_aud),
501501+ claims.validate(&wrong_aud, no_services),
463502 Err(ServiceAuthError::AudienceMismatch { .. })
464503 ));
465504 }
466505467506 #[test]
507507+ fn test_service_id_validation() {
508508+ let now = chrono::Utc::now().timestamp();
509509+ let with_service = ServiceAuthClaims {
510510+ iss: Did::new("did:plc:test").unwrap(),
511511+ aud: DidService::new("did:web:example.com#bsky_appview").unwrap(),
512512+ exp: now + 100,
513513+ iat: now,
514514+ jti: None,
515515+ lxm: None,
516516+ };
517517+ let expected_aud = Did::new("did:web:example.com").unwrap();
518518+519519+ // Empty allow-list: service id is not enforced.
520520+ assert!(with_service.validate(&expected_aud, &[] as &[&str]).is_ok());
521521+522522+ // Matching allow-list accepts the token.
523523+ assert!(
524524+ with_service
525525+ .validate(&expected_aud, &["bsky_appview", "atproto_labeler"])
526526+ .is_ok()
527527+ );
528528+529529+ // Non-matching allow-list rejects the token.
530530+ assert!(matches!(
531531+ with_service.validate(&expected_aud, &["atproto_labeler"]),
532532+ Err(ServiceAuthError::ServiceIdMismatch { .. })
533533+ ));
534534+535535+ // Bare-DID audience against a non-empty allow-list is rejected:
536536+ // the token carries no service id, so nothing can match.
537537+ let no_service = ServiceAuthClaims {
538538+ iss: Did::new("did:plc:test").unwrap(),
539539+ aud: DidService::new("did:web:example.com").unwrap(),
540540+ exp: now + 100,
541541+ iat: now,
542542+ jti: None,
543543+ lxm: None,
544544+ };
545545+ assert!(matches!(
546546+ no_service.validate(&expected_aud, &["bsky_appview"]),
547547+ Err(ServiceAuthError::ServiceIdMismatch { actual: None, .. })
548548+ ));
549549+ }
550550+551551+ #[test]
468552 fn test_method_check() {
469553 let claims = ServiceAuthClaims {
470554 iss: Did::new("did:plc:test").unwrap(),
471471- aud: Did::new("did:web:example.com").unwrap(),
555555+ aud: DidService::new("did:web:example.com").unwrap(),
472556 exp: chrono::Utc::now().timestamp() + 100,
473557 iat: chrono::Utc::now().timestamp(),
474558 jti: None,
+2
crates/jacquard-common/src/types.rs
···1818pub mod did;
1919/// DID Document types and helpers
2020pub mod did_doc;
2121+/// DID with optional service identifier fragment
2222+pub mod did_service;
2123/// AT Protocol handle types and validation
2224pub mod handle;
2325/// AT Protocol identifier types (handle or DID)
+325
crates/jacquard-common/src/types/did_service.rs
···11+use crate::types::did::Did;
22+use crate::types::string::AtStrError;
33+use crate::{CowStr, IntoStatic};
44+use alloc::string::{String, ToString};
55+use core::fmt;
66+use core::ops::Deref;
77+use core::str::FromStr;
88+#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
99+use regex::Regex;
1010+#[cfg(all(not(target_arch = "wasm32"), not(feature = "std")))]
1111+use regex_automata::meta::Regex;
1212+#[cfg(target_arch = "wasm32")]
1313+use regex_lite::Regex;
1414+use serde::{Deserialize, Deserializer, Serialize, de::Error};
1515+use smol_str::{SmolStr, ToSmolStr};
1616+1717+use super::Lazy;
1818+1919+/// DID with an optional service identifier fragment.
2020+///
2121+/// Represents values like `did:web:app.modelo.social#bsky_appview`, used in
2222+/// atproto service auth audience (`aud`) claims to scope a token to a specific
2323+/// application hosted on a given DID. The fragment is optional; a bare DID
2424+/// (e.g. `did:web:example.com`) is also valid and represents "any service on
2525+/// this DID".
2626+///
2727+/// The DID portion uses the same grammar as [`Did`]. The service identifier,
2828+/// when present, must begin with an ASCII letter and contain only ASCII
2929+/// letters, digits, `_`, or `-`, which matches the convention used in the wild
3030+/// (`bsky_appview`, `atproto_labeler`, `atproto_pds`).
3131+///
3232+/// Maximum total length is 2048, matching [`Did`].
3333+#[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)]
3434+#[serde(transparent)]
3535+#[repr(transparent)]
3636+pub struct DidService<'d>(pub(crate) CowStr<'d>);
3737+3838+/// Regex for `DidService` validation.
3939+///
4040+/// The DID body mirrors [`crate::types::did::DID_REGEX`]; the optional
4141+/// `#service` fragment requires a leading ASCII letter and disallows dots,
4242+/// colons, and percent signs to avoid ambiguity with DID-method and
4343+/// percent-encoded bytes.
4444+pub static DID_SERVICE_REGEX: Lazy<Regex> = Lazy::new(|| {
4545+ Regex::new(r"^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-](#[a-zA-Z][a-zA-Z0-9_-]*)?$").unwrap()
4646+});
4747+4848+impl<'d> DidService<'d> {
4949+ /// Fallible constructor, validates, borrows from input.
5050+ pub fn new(value: &'d str) -> Result<Self, AtStrError> {
5151+ Self::validate(value)?;
5252+ Ok(Self(CowStr::Borrowed(value)))
5353+ }
5454+5555+ /// Fallible constructor, validates, borrows from input if possible.
5656+ pub fn new_cow(value: CowStr<'d>) -> Result<Self, AtStrError> {
5757+ Self::validate(&value)?;
5858+ Ok(Self(value))
5959+ }
6060+6161+ /// Fallible constructor, validates, takes ownership.
6262+ pub fn new_owned(value: impl AsRef<str>) -> Result<Self, AtStrError> {
6363+ let value = value.as_ref();
6464+ Self::validate(value)?;
6565+ Ok(Self(CowStr::Owned(value.to_smolstr())))
6666+ }
6767+6868+ /// Fallible constructor, validates, doesn't allocate.
6969+ pub fn new_static(value: &'static str) -> Result<Self, AtStrError> {
7070+ Self::validate(value)?;
7171+ Ok(Self(CowStr::new_static(value)))
7272+ }
7373+7474+ /// Infallible constructor for when you *know* the string is valid.
7575+ /// Panics on invalid input. Prefer for trusted data decoded outside of serde.
7676+ pub fn raw(value: &'d str) -> Self {
7777+ match Self::validate(value) {
7878+ Ok(()) => Self(CowStr::Borrowed(value)),
7979+ Err(_) => panic!("Invalid DidService"),
8080+ }
8181+ }
8282+8383+ /// Infallible constructor. Marked unsafe because responsibility for
8484+ /// upholding the invariant is on the caller.
8585+ pub unsafe fn unchecked(value: &'d str) -> Self {
8686+ Self(CowStr::Borrowed(value))
8787+ }
8888+8989+ fn validate(value: &str) -> Result<(), AtStrError> {
9090+ if value.len() > 2048 {
9191+ Err(AtStrError::too_long(
9292+ "did-service",
9393+ value,
9494+ 2048,
9595+ value.len(),
9696+ ))
9797+ } else if !DID_SERVICE_REGEX.is_match(value) {
9898+ Err(AtStrError::regex(
9999+ "did-service",
100100+ value,
101101+ SmolStr::new_static("invalid"),
102102+ ))
103103+ } else {
104104+ Ok(())
105105+ }
106106+ }
107107+108108+ /// Get the full compound string (DID plus optional `#service`).
109109+ pub fn as_str(&self) -> &str {
110110+ &self.0
111111+ }
112112+113113+ /// The audience DID, i.e. the portion before any `#` fragment.
114114+ ///
115115+ /// The returned `Did` borrows from this value's storage. Validation at
116116+ /// construction time guarantees the DID portion is well-formed, so this
117117+ /// does not re-run the regex.
118118+ pub fn audience(&self) -> Did<'_> {
119119+ let full = self.as_str();
120120+ let did_part = match full.find('#') {
121121+ Some(i) => &full[..i],
122122+ None => full,
123123+ };
124124+ // SAFETY: `DID_SERVICE_REGEX` guarantees the portion before any `#` is
125125+ // a valid DID matching `DID_REGEX`.
126126+ unsafe { Did::unchecked(did_part) }
127127+ }
128128+129129+ /// The service identifier portion (after `#`), or `None` if absent.
130130+ ///
131131+ /// Returns `None` for a bare DID like `did:web:example.com`; returns
132132+ /// `Some("bsky_appview")` for `did:web:example.com#bsky_appview`. The
133133+ /// returned slice never contains the leading `#`.
134134+ pub fn service(&self) -> Option<&str> {
135135+ let full = self.as_str();
136136+ full.find('#').map(|i| &full[i + 1..])
137137+ }
138138+}
139139+140140+impl FromStr for DidService<'_> {
141141+ type Err = AtStrError;
142142+143143+ /// Has to take ownership due to `FromStr` lifetime constraints. Prefer
144144+ /// [`DidService::new`] or [`DidService::raw`] if you want to borrow.
145145+ fn from_str(s: &str) -> Result<Self, Self::Err> {
146146+ Self::new_owned(s)
147147+ }
148148+}
149149+150150+impl IntoStatic for DidService<'_> {
151151+ type Output = DidService<'static>;
152152+153153+ fn into_static(self) -> Self::Output {
154154+ DidService(self.0.into_static())
155155+ }
156156+}
157157+158158+impl<'de, 'a> Deserialize<'de> for DidService<'a>
159159+where
160160+ 'de: 'a,
161161+{
162162+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
163163+ where
164164+ D: Deserializer<'de>,
165165+ {
166166+ let value = Deserialize::deserialize(deserializer)?;
167167+ Self::new_cow(value).map_err(D::Error::custom)
168168+ }
169169+}
170170+171171+impl fmt::Display for DidService<'_> {
172172+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173173+ f.write_str(&self.0)
174174+ }
175175+}
176176+177177+impl fmt::Debug for DidService<'_> {
178178+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179179+ f.write_str(&self.0)
180180+ }
181181+}
182182+183183+impl<'d> From<DidService<'d>> for String {
184184+ fn from(value: DidService<'d>) -> Self {
185185+ value.0.to_string()
186186+ }
187187+}
188188+189189+impl<'d> From<DidService<'d>> for CowStr<'d> {
190190+ fn from(value: DidService<'d>) -> Self {
191191+ value.0
192192+ }
193193+}
194194+195195+impl From<String> for DidService<'static> {
196196+ fn from(value: String) -> Self {
197197+ match Self::validate(&value) {
198198+ Ok(()) => Self(CowStr::Owned(value.into())),
199199+ Err(_) => panic!("Invalid DidService"),
200200+ }
201201+ }
202202+}
203203+204204+impl<'d> From<CowStr<'d>> for DidService<'d> {
205205+ fn from(value: CowStr<'d>) -> Self {
206206+ match Self::validate(&value) {
207207+ Ok(()) => Self(value),
208208+ Err(_) => panic!("Invalid DidService"),
209209+ }
210210+ }
211211+}
212212+213213+impl AsRef<str> for DidService<'_> {
214214+ fn as_ref(&self) -> &str {
215215+ self.as_str()
216216+ }
217217+}
218218+219219+impl Deref for DidService<'_> {
220220+ type Target = str;
221221+222222+ fn deref(&self) -> &Self::Target {
223223+ self.as_str()
224224+ }
225225+}
226226+227227+#[cfg(test)]
228228+mod tests {
229229+ use super::*;
230230+231231+ #[test]
232232+ fn valid_with_service() {
233233+ assert!(DidService::new("did:web:app.modelo.social#bsky_appview").is_ok());
234234+ assert!(DidService::new("did:plc:abc123#atproto_labeler").is_ok());
235235+ assert!(DidService::new("did:web:example.com#a").is_ok());
236236+ }
237237+238238+ #[test]
239239+ fn valid_without_service() {
240240+ let v = DidService::new("did:web:example.com").unwrap();
241241+ assert_eq!(v.as_str(), "did:web:example.com");
242242+ assert_eq!(v.service(), None);
243243+ assert_eq!(v.audience().as_str(), "did:web:example.com");
244244+ }
245245+246246+ #[test]
247247+ fn audience_and_service_split() {
248248+ let v = DidService::new("did:web:app.modelo.social#bsky_appview").unwrap();
249249+ assert_eq!(v.audience().as_str(), "did:web:app.modelo.social");
250250+ assert_eq!(v.service(), Some("bsky_appview"));
251251+ assert_eq!(v.as_str(), "did:web:app.modelo.social#bsky_appview");
252252+ }
253253+254254+ #[test]
255255+ fn empty_fragment_rejected() {
256256+ assert!(DidService::new("did:web:foo#").is_err());
257257+ }
258258+259259+ #[test]
260260+ fn invalid_service_chars() {
261261+ assert!(DidService::new("did:web:foo#with space").is_err());
262262+ assert!(DidService::new("did:web:foo#with.dot").is_err());
263263+ assert!(DidService::new("did:web:foo#-leading-dash").is_err());
264264+ assert!(DidService::new("did:web:foo#123leading").is_err());
265265+ assert!(DidService::new("did:web:foo#svc#again").is_err());
266266+ assert!(DidService::new("did:web:foo#svc:nope").is_err());
267267+ }
268268+269269+ #[test]
270270+ fn valid_service_chars() {
271271+ assert!(DidService::new("did:web:foo#bsky_appview").is_ok());
272272+ assert!(DidService::new("did:web:foo#a").is_ok());
273273+ assert!(DidService::new("did:web:foo#atproto-labeler").is_ok());
274274+ assert!(DidService::new("did:web:foo#abc_123").is_ok());
275275+ }
276276+277277+ #[test]
278278+ fn invalid_did_body() {
279279+ assert!(DidService::new("foo#bsky_appview").is_err());
280280+ assert!(DidService::new("DID:web:foo#svc").is_err());
281281+ assert!(DidService::new("did:WEB:foo#svc").is_err());
282282+ assert!(DidService::new("did:web:foo:#svc").is_err());
283283+ }
284284+285285+ #[test]
286286+ fn max_length_enforced() {
287287+ // 2048 valid; pad the DID body.
288288+ let valid_2048 = format!("did:web:{}", "a".repeat(2048 - 8));
289289+ assert_eq!(valid_2048.len(), 2048);
290290+ assert!(DidService::new(&valid_2048).is_ok());
291291+292292+ let too_long = format!("did:web:{}", "a".repeat(2049 - 8));
293293+ assert_eq!(too_long.len(), 2049);
294294+ assert!(DidService::new(&too_long).is_err());
295295+ }
296296+297297+ #[test]
298298+ fn deserialize_roundtrip() {
299299+ let json = "\"did:web:a.b#svc\"";
300300+ let parsed: DidService<'_> = serde_json::from_str(json).unwrap();
301301+ assert_eq!(parsed.as_str(), "did:web:a.b#svc");
302302+ assert_eq!(parsed.service(), Some("svc"));
303303+ let reserialized = serde_json::to_string(&parsed).unwrap();
304304+ assert_eq!(reserialized, json);
305305+ }
306306+307307+ #[test]
308308+ fn into_static_preserves_value() {
309309+ let owned: DidService<'static> = {
310310+ let s = String::from("did:web:a.b#svc");
311311+ let borrowed = DidService::new(s.as_str()).unwrap();
312312+ borrowed.into_static()
313313+ };
314314+ assert_eq!(owned.as_str(), "did:web:a.b#svc");
315315+ assert_eq!(owned.audience().as_str(), "did:web:a.b");
316316+ assert_eq!(owned.service(), Some("svc"));
317317+ }
318318+319319+ #[test]
320320+ fn from_str_ok() {
321321+ let v: DidService<'static> = "did:plc:abc#svc".parse().unwrap();
322322+ assert_eq!(v.audience().as_str(), "did:plc:abc");
323323+ assert_eq!(v.service(), Some("svc"));
324324+ }
325325+}