···6666miette = { workspace = true, optional = true } # also need to gate this to std only
6767multibase = { version = "0.9.1", default-features = false }
6868multihash = { version = "0.19.3", default-features = false, features = ["alloc"] }
6969-ouroboros = "0.18.5"
7069rand = { version = "0.9.2", default-features = false, features = ["alloc"] }
7170serde.workspace = true
7271serde_html_form.workspace = true # need to check these at workspace level
+150-112
crates/jacquard-common/src/service_auth.rs
···18181919use crate::CowStr;
2020use crate::IntoStatic;
2121-use crate::types::string::{Did, Nsid};
2121+use crate::bos::{BosStr, DefaultStr};
2222+use crate::types::string::{Did, DidService, Nsid};
2223use alloc::string::String;
2323-use alloc::string::ToString;
2424use alloc::vec::Vec;
2525use base64::Engine;
2626use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2727-use ouroboros::self_referencing;
2827use serde::{Deserialize, Serialize};
2928use signature::Verifier;
3029use smol_str::SmolStr;
···7372 now: i64,
7473 },
75747676- /// Audience mismatch
7575+ /// Audience mismatch.
7776 #[error("audience mismatch: expected {expected}, got {actual}")]
7877 AudienceMismatch {
7979- /// Expected audience DID
7878+ /// Expected audience DID.
8079 expected: Did,
8181- /// Actual audience DID in token
8282- actual: Did,
8080+ /// Actual audience DID service in token.
8181+ actual: DidService,
8282+ },
8383+8484+ /// Service id mismatch.
8585+ #[error("service id mismatch: allowed {allowed:?}, got {actual:?}")]
8686+ ServiceIdMismatch {
8787+ /// Allowed service ids.
8888+ allowed: Vec<SmolStr>,
8989+ /// Actual service id in token.
9090+ actual: Option<SmolStr>,
8391 },
84928593 /// Method mismatch (lxm field)
···102110103111/// JWT header for service auth tokens.
104112#[derive(Debug, Clone, Serialize, Deserialize)]
105105-pub struct JwtHeader<'a> {
106106- /// Algorithm used for signing
107107- #[serde(borrow)]
108108- pub alg: CowStr<'a>,
109109- /// Type (always "JWT")
110110- #[serde(borrow)]
111111- pub typ: CowStr<'a>,
113113+pub struct JwtHeader<S: BosStr = DefaultStr> {
114114+ /// Algorithm used for signing.
115115+ pub alg: S,
116116+ /// Type (always "JWT").
117117+ pub typ: S,
112118}
113119114114-impl IntoStatic for JwtHeader<'_> {
115115- type Output = JwtHeader<'static>;
120120+impl<S> IntoStatic for JwtHeader<S>
121121+where
122122+ S: BosStr + IntoStatic,
123123+ S::Output: BosStr,
124124+{
125125+ type Output = JwtHeader<S::Output>;
116126117127 fn into_static(self) -> Self::Output {
118128 JwtHeader {
···126136///
127137/// These are the payload fields in a service auth JWT.
128138#[derive(Debug, Clone, Serialize, Deserialize)]
129129-pub struct ServiceAuthClaims<'a> {
130130- /// Issuer (user's DID)
131131- pub iss: Did,
139139+pub struct ServiceAuthClaims<S: BosStr = DefaultStr> {
140140+ /// Issuer (user's DID).
141141+ pub iss: Did<S>,
132142133133- /// Audience (target service DID)
134134- pub aud: Did,
143143+ /// Audience (target service DID with optional service-id fragment).
144144+ pub aud: DidService<S>,
135145136136- /// Expiration time (unix timestamp)
146146+ /// Expiration time (unix timestamp).
137147 pub exp: i64,
138148139139- /// Issued at (unix timestamp)
149149+ /// Issued at (unix timestamp).
140150 pub iat: i64,
141151142142- /// JWT ID (nonce for replay protection)
143143- #[serde(borrow, skip_serializing_if = "Option::is_none")]
144144- pub jti: Option<CowStr<'a>>,
152152+ /// JWT ID (nonce for replay protection).
153153+ #[serde(skip_serializing_if = "Option::is_none")]
154154+ pub jti: Option<S>,
145155146146- /// Lexicon method NSID (method binding)
156156+ /// Lexicon method NSID (method binding).
147157 #[serde(skip_serializing_if = "Option::is_none")]
148148- pub lxm: Option<Nsid>,
158158+ pub lxm: Option<Nsid<S>>,
149159}
150160151151-impl<'a> IntoStatic for ServiceAuthClaims<'a> {
152152- type Output = ServiceAuthClaims<'static>;
161161+impl<S> IntoStatic for ServiceAuthClaims<S>
162162+where
163163+ S: BosStr + IntoStatic,
164164+ S::Output: BosStr,
165165+{
166166+ type Output = ServiceAuthClaims<S::Output>;
153167154168 fn into_static(self) -> Self::Output {
155169 ServiceAuthClaims {
···163177 }
164178}
165179166166-impl<'a> ServiceAuthClaims<'a> {
180180+impl<S: BosStr> ServiceAuthClaims<S> {
167181 /// Validate the claims against expected values.
168182 ///
169183 /// Checks:
170170- /// - Audience matches expected DID
171171- /// - Token is not expired
172172- pub fn validate(&self, expected_aud: &Did<&str>) -> Result<(), ServiceAuthError> {
173173- // Check audience
174174- if self.aud.as_str() != expected_aud.as_str() {
184184+ /// - The fragmentless audience matches the expected DID.
185185+ /// - A present service-id fragment is in the allowed service list when configured.
186186+ /// - The token is not expired.
187187+ pub fn validate<B, Svc>(
188188+ &self,
189189+ expected_aud: &Did<B>,
190190+ allowed_services: &[Svc],
191191+ ) -> Result<(), ServiceAuthError>
192192+ where
193193+ B: BosStr,
194194+ Svc: AsRef<str>,
195195+ {
196196+ if self.aud.audience().as_str() != expected_aud.as_str() {
175197 return Err(ServiceAuthError::AudienceMismatch {
176176- expected: expected_aud.clone().into_static(),
177177- actual: self.aud.clone().into_static(),
198198+ expected: expected_aud.borrow().into_static(),
199199+ actual: DidService::new_owned(self.aud.as_str()).unwrap(),
178200 });
179201 }
180202181181- // Check expiration
203203+ if !allowed_services.is_empty() {
204204+ if let Some(service) = self.aud.service() {
205205+ if !allowed_services
206206+ .iter()
207207+ .any(|allowed| allowed.as_ref() == service)
208208+ {
209209+ return Err(ServiceAuthError::ServiceIdMismatch {
210210+ allowed: allowed_services
211211+ .iter()
212212+ .map(|allowed| SmolStr::new(allowed.as_ref()))
213213+ .collect(),
214214+ actual: Some(SmolStr::new(service)),
215215+ });
216216+ }
217217+ }
218218+ }
219219+182220 if self.is_expired() {
183221 let now = chrono::Utc::now().timestamp();
184222 return Err(ServiceAuthError::Expired { exp: self.exp, now });
···219257220258/// Parsed JWT components.
221259///
222222-/// This struct owns the decoded buffers and parsed components using ouroboros
223223-/// self-referencing. The header and claims borrow from their respective buffers.
224224-#[self_referencing]
225225-pub struct ParsedJwt {
226226- /// Decoded header buffer (owned)
227227- header_buf: Vec<u8>,
228228- /// Decoded payload buffer (owned)
229229- payload_buf: Vec<u8>,
230230- /// Original token string for signing_input
231231- token: String,
232232- /// Signature bytes
260260+/// This struct owns decoded and parsed JWT data. `signing_input` stores the
261261+/// original `header.payload` bytes used for signature verification.
262262+pub struct ParsedJwt<S: BosStr = DefaultStr> {
263263+ /// Parsed JWT header.
264264+ header: JwtHeader,
265265+ /// Parsed service-auth claims.
266266+ claims: ServiceAuthClaims<S>,
267267+ /// Original `header.payload` signing input.
268268+ signing_input: String,
269269+ /// Decoded signature bytes.
233270 signature: Vec<u8>,
234234- /// Parsed header borrowing from header_buf
235235- #[borrows(header_buf)]
236236- #[covariant]
237237- header: JwtHeader<'this>,
238238- /// Parsed claims borrowing from payload_buf
239239- #[borrows(payload_buf)]
240240- #[covariant]
241241- claims: ServiceAuthClaims<'this>,
242271}
243272244244-impl ParsedJwt {
273273+impl<S: BosStr> ParsedJwt<S> {
245274 /// Get the signing input (header.payload) for signature verification.
246275 pub fn signing_input(&self) -> &[u8] {
247247- self.with_token(|token| {
248248- let dot_pos = token.find('.').unwrap();
249249- let second_dot_pos = token[dot_pos + 1..].find('.').unwrap() + dot_pos + 1;
250250- token[..second_dot_pos].as_bytes()
251251- })
276276+ self.signing_input.as_bytes()
252277 }
253278254279 /// Get a reference to the header.
255255- pub fn header(&self) -> &JwtHeader<'_> {
256256- self.borrow_header()
280280+ pub fn header(&self) -> &JwtHeader {
281281+ &self.header
257282 }
258283259284 /// Get a reference to the claims.
260260- pub fn claims(&self) -> &ServiceAuthClaims<'_> {
261261- self.borrow_claims()
285285+ pub fn claims(&self) -> &ServiceAuthClaims<S> {
286286+ &self.claims
262287 }
263288264289 /// Get a reference to the signature.
265290 pub fn signature(&self) -> &[u8] {
266266- self.borrow_signature()
291291+ &self.signature
267292 }
268293269294 /// Get owned header with 'static lifetime.
270270- pub fn into_header(self) -> JwtHeader<'static> {
271271- self.with_header(|header| header.clone().into_static())
295295+ pub fn into_header(self) -> JwtHeader {
296296+ self.header
272297 }
273298274274- /// Get owned claims with 'static lifetime.
275275- pub fn into_claims(self) -> ServiceAuthClaims<'static> {
276276- self.with_claims(|claims| claims.clone().into_static())
299299+ /// Get owned claims.
300300+ pub fn into_claims(self) -> ServiceAuthClaims<S> {
301301+ self.claims
277302 }
278303}
279304280305/// Parse a JWT token into its components without verifying the signature.
281306///
282307/// This extracts and decodes all JWT components. The header and claims are parsed
283283-/// and borrow from their respective owned buffers using ouroboros self-referencing.
308308+/// into their default owned backing types.
284309pub fn parse_jwt(token: &str) -> Result<ParsedJwt, ServiceAuthError> {
285310 let parts: Vec<&str> = token.split('.').collect();
286311 if parts.len() != 3 {
···293318 let payload_b64 = parts[1];
294319 let signature_b64 = parts[2];
295320296296- // Decode all components
297321 let header_buf = URL_SAFE_NO_PAD.decode(header_b64)?;
298322 let payload_buf = URL_SAFE_NO_PAD.decode(payload_b64)?;
299323 let signature = URL_SAFE_NO_PAD.decode(signature_b64)?;
324324+ let header: JwtHeader = serde_json::from_slice(&header_buf)?;
325325+ let claims: ServiceAuthClaims = serde_json::from_slice(&payload_buf)?;
326326+ let signing_input = format!("{}.{}", header_b64, payload_b64);
300327301301- // Validate that buffers contain valid JSON for their types
302302- // We parse once here to validate, then again in the builder (unavoidable with ouroboros)
303303- let _header: JwtHeader = serde_json::from_slice(&header_buf)?;
304304- let _claims: ServiceAuthClaims = serde_json::from_slice(&payload_buf)?;
305305-306306- Ok(ParsedJwtBuilder {
307307- header_buf,
308308- payload_buf,
309309- token: token.to_string(),
328328+ Ok(ParsedJwt {
329329+ header,
330330+ claims,
331331+ signing_input,
310332 signature,
311311- header_builder: |buf| {
312312- // Safe: we validated this succeeds above
313313- serde_json::from_slice(buf).expect("header was validated")
314314- },
315315- claims_builder: |buf| {
316316- // Safe: we validated this succeeds above
317317- serde_json::from_slice(buf).expect("claims were validated")
318318- },
319319- }
320320- .build())
333333+ })
321334}
322335323336/// Public key types for signature verification.
···402415pub fn verify_service_jwt(
403416 token: &str,
404417 public_key: &PublicKey,
405405-) -> Result<ServiceAuthClaims<'static>, ServiceAuthError> {
418418+) -> Result<ServiceAuthClaims, ServiceAuthError> {
406419 let parsed = parse_jwt(token)?;
407420 verify_signature(&parsed, public_key)?;
408421 Ok(parsed.into_claims())
···421434 #[test]
422435 fn test_claims_expiration() {
423436 let now = chrono::Utc::now().timestamp();
424424- let expired_claims = ServiceAuthClaims {
437437+ let expired_claims: ServiceAuthClaims = ServiceAuthClaims {
425438 iss: Did::new_static("did:plc:test").unwrap(),
426426- aud: Did::new_static("did:web:example.com").unwrap(),
439439+ aud: DidService::new_static("did:web:example.com").unwrap(),
427440 exp: now - 100,
428441 iat: now - 200,
429442 jti: None,
···432445433446 assert!(expired_claims.is_expired());
434447435435- let valid_claims = ServiceAuthClaims {
448448+ let valid_claims: ServiceAuthClaims = ServiceAuthClaims {
436449 iss: Did::new_static("did:plc:test").unwrap(),
437437- aud: Did::new_static("did:web:example.com").unwrap(),
450450+ aud: DidService::new_static("did:web:example.com").unwrap(),
438451 exp: now + 100,
439452 iat: now,
440453 jti: None,
···444457 assert!(!valid_claims.is_expired());
445458 }
446459447447- #[test]
448448- fn test_audience_validation() {
449449- let now = chrono::Utc::now().timestamp();
450450- let claims = ServiceAuthClaims {
460460+ fn claims_with_aud(aud: &str) -> ServiceAuthClaims {
461461+ ServiceAuthClaims {
451462 iss: Did::new_static("did:plc:test").unwrap(),
452452- aud: Did::new_static("did:web:example.com").unwrap(),
453453- exp: now + 100,
454454- iat: now,
463463+ aud: DidService::new_owned(aud).unwrap(),
464464+ exp: chrono::Utc::now().timestamp() + 100,
465465+ iat: chrono::Utc::now().timestamp(),
455466 jti: None,
456467 lxm: None,
457457- };
468468+ }
469469+ }
458470471471+ #[test]
472472+ fn test_audience_validation() {
459473 let expected_aud = Did::new("did:web:example.com").unwrap();
460460- assert!(claims.validate(&expected_aud).is_ok());
474474+ assert!(
475475+ claims_with_aud("did:web:example.com")
476476+ .validate(&expected_aud, &[] as &[&str])
477477+ .is_ok()
478478+ );
479479+ assert!(
480480+ claims_with_aud("did:web:example.com#bsky_appview")
481481+ .validate(&expected_aud, &[] as &[&str])
482482+ .is_ok()
483483+ );
484484+ assert!(
485485+ claims_with_aud("did:web:example.com")
486486+ .validate(&expected_aud, &["bsky_appview"])
487487+ .is_ok()
488488+ );
489489+ assert!(
490490+ claims_with_aud("did:web:example.com#bsky_appview")
491491+ .validate(&expected_aud, &["bsky_appview"])
492492+ .is_ok()
493493+ );
494494+ assert!(matches!(
495495+ claims_with_aud("did:web:example.com#other").validate(&expected_aud, &["bsky_appview"]),
496496+ Err(ServiceAuthError::ServiceIdMismatch { .. })
497497+ ));
461498462499 let wrong_aud = Did::new("did:web:wrong.com").unwrap();
463500 assert!(matches!(
464464- claims.validate(&wrong_aud),
501501+ claims_with_aud("did:web:example.com#bsky_appview")
502502+ .validate(&wrong_aud, &["bsky_appview"]),
465503 Err(ServiceAuthError::AudienceMismatch { .. })
466504 ));
467505 }
468506469507 #[test]
470508 fn test_method_check() {
471471- let claims = ServiceAuthClaims {
509509+ let claims: ServiceAuthClaims = ServiceAuthClaims {
472510 iss: Did::new_static("did:plc:test").unwrap(),
473473- aud: Did::new_static("did:web:example.com").unwrap(),
511511+ aud: DidService::new_static("did:web:example.com").unwrap(),
474512 exp: chrono::Utc::now().timestamp() + 100,
475513 iat: chrono::Utc::now().timestamp(),
476514 jti: None,
+2
crates/jacquard-common/src/types.rs
···1818pub mod did;
1919/// DID Document types and helpers
2020pub mod did_doc;
2121+/// DID service audience types and validation.
2222+pub mod did_service;
2123/// AT Protocol handle types and validation
2224pub mod handle;
2325/// AT Protocol identifier types (handle or DID)
+332
crates/jacquard-common/src/types/did_service.rs
···11+use crate::bos::{Bos, BosStr, DefaultStr};
22+use crate::types::did::{Did, validate_did};
33+use crate::types::string::{AtStrError, StrParseKind};
44+use crate::{CowStr, IntoStatic};
55+use alloc::string::{String, ToString};
66+use core::fmt;
77+use core::ops::Deref;
88+use core::str::FromStr;
99+use serde::{Deserialize, Deserializer, Serialize};
1010+use smol_str::SmolStr;
1111+1212+/// A DID audience with an optional service-id fragment.
1313+///
1414+/// Service auth JWTs may target either a bare service DID such as
1515+/// `did:web:example.com` or a DID plus service fragment such as
1616+/// `did:web:example.com#bsky_appview`.
1717+#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
1818+#[serde(transparent)]
1919+#[repr(transparent)]
2020+pub struct DidService<S: Bos<str> = DefaultStr>(pub(crate) S);
2121+2222+/// Validate a DID service audience string without constructing a [`DidService`].
2323+pub fn validate_did_service(value: &str) -> Result<(), AtStrError> {
2424+ if value.len() > 2048 {
2525+ return Err(AtStrError::too_long(
2626+ "did service audience",
2727+ value,
2828+ 2048,
2929+ value.len(),
3030+ ));
3131+ }
3232+3333+ let mut parts = value.split('#');
3434+ let did = parts.next().unwrap_or_default();
3535+ let service = parts.next();
3636+ if parts.next().is_some() {
3737+ return Err(AtStrError::regex(
3838+ "did service audience",
3939+ value,
4040+ SmolStr::new_static("multiple fragments"),
4141+ ));
4242+ }
4343+4444+ validate_did(did)?;
4545+4646+ if let Some(service) = service {
4747+ validate_service_id(service, value)?;
4848+ }
4949+5050+ Ok(())
5151+}
5252+5353+fn validate_service_id(service: &str, whole: &str) -> Result<(), AtStrError> {
5454+ let mut bytes = service.bytes();
5555+ match bytes.next() {
5656+ Some(first) if first.is_ascii_alphabetic() => {}
5757+ _ => {
5858+ return Err(AtStrError::regex(
5959+ "did service audience",
6060+ whole,
6161+ SmolStr::new_static("invalid service id"),
6262+ ));
6363+ }
6464+ }
6565+6666+ if bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') {
6767+ Ok(())
6868+ } else {
6969+ Err(AtStrError::regex(
7070+ "did service audience",
7171+ whole,
7272+ SmolStr::new_static("invalid service id"),
7373+ ))
7474+ }
7575+}
7676+7777+impl<S: BosStr> DidService<S> {
7878+ /// Get the full DID service audience as a string slice.
7979+ pub fn as_str(&self) -> &str {
8080+ self.0.as_ref()
8181+ }
8282+8383+ /// Get the fragmentless DID audience.
8484+ pub fn audience(&self) -> Did<&str> {
8585+ let did = self
8686+ .as_str()
8787+ .split_once('#')
8888+ .map_or(self.as_str(), |(did, _)| did);
8989+ // SAFETY: self is already validated, and validation validates the DID portion.
9090+ unsafe { Did::unchecked(did) }
9191+ }
9292+9393+ /// Get the optional service-id fragment without the leading `#`.
9494+ pub fn service(&self) -> Option<&str> {
9595+ self.as_str().split_once('#').map(|(_, service)| service)
9696+ }
9797+}
9898+9999+impl<S: Bos<str>> DidService<S> {
100100+ /// Infallible unchecked constructor.
101101+ ///
102102+ /// # Safety
103103+ ///
104104+ /// The caller must ensure the DID service audience is valid.
105105+ pub unsafe fn unchecked(value: S) -> Self {
106106+ Self(value)
107107+ }
108108+109109+ /// Convert to a `DidService` with a different backing type.
110110+ pub fn convert<B: Bos<str> + From<S>>(self) -> DidService<B> {
111111+ DidService(B::from(self.0))
112112+ }
113113+114114+ /// Borrow as a `DidService<&str>`.
115115+ pub fn borrow(&self) -> DidService<&str>
116116+ where
117117+ S: AsRef<str>,
118118+ {
119119+ // SAFETY: self is already validated.
120120+ unsafe { DidService::unchecked(self.0.as_ref()) }
121121+ }
122122+}
123123+124124+impl<S: BosStr> DidService<S> {
125125+ /// Fallible constructor that validates and wraps the input directly.
126126+ pub fn new(s: S) -> Result<Self, AtStrError> {
127127+ validate_did_service(s.as_ref())?;
128128+ Ok(Self(s))
129129+ }
130130+131131+ /// Infallible constructor. Panics on invalid DID service audiences.
132132+ pub fn raw(s: S) -> Self {
133133+ Self::new(s).expect("invalid DID service audience")
134134+ }
135135+}
136136+137137+impl<S: BosStr + FromStr> DidService<S> {
138138+ /// Fallible constructor that validates and takes ownership.
139139+ pub fn new_owned(value: impl AsRef<str>) -> Result<Self, AtStrError> {
140140+ let value = value.as_ref();
141141+ validate_did_service(value)?;
142142+ let s = S::from_str(value).map_err(|_| {
143143+ AtStrError::new(
144144+ "did service audience",
145145+ value.to_string(),
146146+ StrParseKind::Conversion,
147147+ )
148148+ })?;
149149+ Ok(Self(s))
150150+ }
151151+152152+ /// Fallible constructor for static strings.
153153+ pub fn new_static(value: &'static str) -> Result<Self, AtStrError> {
154154+ validate_did_service(value)?;
155155+ let s = S::from_str(value).map_err(|_| {
156156+ AtStrError::new(
157157+ "did service audience",
158158+ value.to_string(),
159159+ StrParseKind::Conversion,
160160+ )
161161+ })?;
162162+ Ok(Self(s))
163163+ }
164164+}
165165+166166+impl<'de, S> Deserialize<'de> for DidService<S>
167167+where
168168+ S: BosStr + Deserialize<'de>,
169169+{
170170+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171171+ where
172172+ D: Deserializer<'de>,
173173+ {
174174+ let s = S::deserialize(deserializer)?;
175175+ validate_did_service(s.as_ref()).map_err(serde::de::Error::custom)?;
176176+ Ok(Self(s))
177177+ }
178178+}
179179+180180+impl<S> IntoStatic for DidService<S>
181181+where
182182+ S: Bos<str> + IntoStatic,
183183+ S::Output: Bos<str>,
184184+{
185185+ type Output = DidService<S::Output>;
186186+187187+ fn into_static(self) -> Self::Output {
188188+ DidService(self.0.into_static())
189189+ }
190190+}
191191+192192+impl<S: BosStr + FromStr> FromStr for DidService<S> {
193193+ type Err = AtStrError;
194194+195195+ fn from_str(s: &str) -> Result<Self, Self::Err> {
196196+ Self::new_owned(s)
197197+ }
198198+}
199199+200200+impl<S: BosStr> fmt::Display for DidService<S> {
201201+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202202+ f.write_str(self.as_str())
203203+ }
204204+}
205205+206206+impl<S: BosStr> fmt::Debug for DidService<S> {
207207+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208208+ f.write_str(self.as_str())
209209+ }
210210+}
211211+212212+impl<S: BosStr> From<DidService<S>> for String {
213213+ fn from(value: DidService<S>) -> Self {
214214+ value.as_str().to_string()
215215+ }
216216+}
217217+218218+impl<S: BosStr> From<DidService<S>> for CowStr<'static> {
219219+ fn from(value: DidService<S>) -> Self {
220220+ CowStr::copy_from_str(value.as_str())
221221+ }
222222+}
223223+224224+impl From<String> for DidService {
225225+ fn from(value: String) -> Self {
226226+ Self::new_owned(value).unwrap()
227227+ }
228228+}
229229+230230+impl<'d> From<CowStr<'d>> for DidService<CowStr<'d>> {
231231+ fn from(value: CowStr<'d>) -> Self {
232232+ Self::new(value).unwrap()
233233+ }
234234+}
235235+236236+impl<S: BosStr> AsRef<str> for DidService<S> {
237237+ fn as_ref(&self) -> &str {
238238+ self.as_str()
239239+ }
240240+}
241241+242242+impl<S: BosStr> Deref for DidService<S> {
243243+ type Target = str;
244244+245245+ fn deref(&self) -> &Self::Target {
246246+ self.as_str()
247247+ }
248248+}
249249+250250+#[cfg(test)]
251251+mod tests {
252252+ use super::*;
253253+ use serde_json::json;
254254+255255+ #[test]
256256+ fn valid_with_service_id() {
257257+ assert!(DidService::<&str>::new("did:web:example.com#bsky_appview").is_ok());
258258+ assert!(DidService::<&str>::new("did:plc:abc123#atproto_labeler").is_ok());
259259+ }
260260+261261+ #[test]
262262+ fn valid_bare_did() {
263263+ assert!(DidService::<&str>::new("did:web:example.com").is_ok());
264264+ }
265265+266266+ #[test]
267267+ fn splits_audience_and_service() {
268268+ let value = DidService::<&str>::new("did:web:example.com#bsky_appview").unwrap();
269269+ assert_eq!(value.audience().as_str(), "did:web:example.com");
270270+ assert_eq!(value.service(), Some("bsky_appview"));
271271+272272+ let bare = DidService::<&str>::new("did:web:example.com").unwrap();
273273+ assert_eq!(bare.audience().as_str(), "did:web:example.com");
274274+ assert_eq!(bare.service(), None);
275275+ }
276276+277277+ #[test]
278278+ fn rejects_empty_fragment() {
279279+ assert!(DidService::<&str>::new("did:web:example.com#").is_err());
280280+ }
281281+282282+ #[test]
283283+ fn rejects_invalid_service_chars() {
284284+ for value in [
285285+ "did:web:example.com#1bad",
286286+ "did:web:example.com#-bad",
287287+ "did:web:example.com#bad.service",
288288+ "did:web:example.com#bad:service",
289289+ "did:web:example.com#bad service",
290290+ "did:web:example.com#bad#service",
291291+ ] {
292292+ assert!(DidService::<&str>::new(value).is_err(), "{value}");
293293+ }
294294+ }
295295+296296+ #[test]
297297+ fn rejects_invalid_did_body() {
298298+ assert!(DidService::<&str>::new("not-a-did#service").is_err());
299299+ }
300300+301301+ #[test]
302302+ fn enforces_max_length() {
303303+ let service = "a".repeat(2049 - "did:web:example.com#".len());
304304+ let value = format!("did:web:example.com#{service}");
305305+ assert!(DidService::<&str>::new(&value).is_err());
306306+ }
307307+308308+ #[test]
309309+ fn serde_roundtrip() {
310310+ let value = DidService::new_static("did:web:example.com#bsky_appview").unwrap();
311311+ let json = serde_json::to_value(&value).unwrap();
312312+ assert_eq!(json, json!("did:web:example.com#bsky_appview"));
313313+ let decoded: DidService = serde_json::from_value(json).unwrap();
314314+ assert_eq!(decoded, value);
315315+ }
316316+317317+ #[test]
318318+ fn into_static_preserves_value() {
319319+ let value = DidService::<CowStr<'_>>::new(CowStr::copy_from_str(
320320+ "did:web:example.com#bsky_appview",
321321+ ))
322322+ .unwrap();
323323+ let static_value: DidService<CowStr<'static>> = value.into_static();
324324+ assert_eq!(static_value.as_str(), "did:web:example.com#bsky_appview");
325325+ }
326326+327327+ #[test]
328328+ fn from_str_owns_value() {
329329+ let value: DidService = "did:web:example.com#bsky_appview".parse().unwrap();
330330+ assert_eq!(value.as_str(), "did:web:example.com#bsky_appview");
331331+ }
332332+}