1use crate::ids::IdentityId;
13use bytes::Bytes;
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17use std::fmt;
18
19#[derive(Clone, Serialize, Deserialize)]
21pub struct Jwk(pub serde_json::Value);
22
23impl fmt::Debug for Jwk {
24 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25 let (kind, member_count) = match &self.0 {
26 serde_json::Value::Null => ("null", 0),
27 serde_json::Value::Bool(_) => ("bool", 1),
28 serde_json::Value::Number(_) => ("number", 1),
29 serde_json::Value::String(_) => ("string", 1),
30 serde_json::Value::Array(values) => ("array", values.len()),
31 serde_json::Value::Object(values) => ("object", values.len()),
32 };
33 formatter
34 .debug_struct("Jwk")
35 .field("kind", &kind)
36 .field("member_count", &member_count)
37 .finish()
38 }
39}
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
42pub enum IdentityKind {
43 Human,
44 Ai,
45 Service,
46 System,
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
50pub enum DeviceKind {
51 Mobile,
52 Web,
53 Desktop,
54 Embedded,
55 Server,
56}
57
58#[derive(Clone)]
66pub enum IdentityAssurance {
67 Anonymous,
68 Pseudonymous {
69 ephemeral_key: Jwk,
70 },
71 Identified {
72 credential_kind: CredentialKind,
73 },
74 TaskScoped {
75 identity: IdentityId,
76 task_id: String,
77 scopes: Vec<String>,
78 expires_at: DateTime<Utc>,
79 },
80 UserAuthorized {
81 identity: IdentityId,
82 user_id: IdentityId,
83 scopes: Vec<String>,
84 },
85 DtlsFingerprint {
90 algorithm: String,
91 value: String,
92 },
93}
94
95impl fmt::Debug for IdentityAssurance {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match self {
98 Self::Anonymous => formatter.write_str("Anonymous"),
99 Self::Pseudonymous { ephemeral_key } => formatter
100 .debug_struct("Pseudonymous")
101 .field("ephemeral_key_present", &!ephemeral_key.0.is_null())
102 .finish(),
103 Self::Identified { credential_kind } => formatter
104 .debug_struct("Identified")
105 .field("credential_kind", credential_kind)
106 .finish(),
107 Self::TaskScoped { scopes, .. } => formatter
108 .debug_struct("TaskScoped")
109 .field("scope_count", &scopes.len())
110 .field("expires_at_present", &true)
111 .finish(),
112 Self::UserAuthorized { scopes, .. } => formatter
113 .debug_struct("UserAuthorized")
114 .field("scope_count", &scopes.len())
115 .finish(),
116 Self::DtlsFingerprint {
117 algorithm, value, ..
118 } => formatter
119 .debug_struct("DtlsFingerprint")
120 .field("algorithm_present", &!algorithm.is_empty())
121 .field("fingerprint_bytes", &value.len())
122 .finish(),
123 }
124 }
125}
126
127impl IdentityAssurance {
128 pub const fn kind(&self) -> &'static str {
132 match self {
133 Self::Anonymous => "anonymous",
134 Self::Pseudonymous { .. } => "pseudonymous",
135 Self::Identified { .. } => "identified",
136 Self::TaskScoped { .. } => "task-scoped",
137 Self::UserAuthorized { .. } => "user-authorized",
138 Self::DtlsFingerprint { .. } => "dtls-fingerprint",
139 }
140 }
141}
142
143#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
149#[serde(rename_all = "kebab-case")]
150pub enum AuthenticationMethod {
151 Anonymous,
152 Bearer,
153 Jwt,
154 Oidc,
155 OAuth2Introspection,
156 Dpop,
157 SipDigest,
158 MutualTls,
159 AAuth,
160 ApiKey,
161}
162
163impl AuthenticationMethod {
164 pub const fn as_str(self) -> &'static str {
165 match self {
166 Self::Anonymous => "anonymous",
167 Self::Bearer => "bearer",
168 Self::Jwt => "jwt",
169 Self::Oidc => "oidc",
170 Self::OAuth2Introspection => "oauth2-introspection",
171 Self::Dpop => "dpop",
172 Self::SipDigest => "sip-digest",
173 Self::MutualTls => "mutual-tls",
174 Self::AAuth => "aauth",
175 Self::ApiKey => "api-key",
176 }
177 }
178}
179
180impl fmt::Display for AuthenticationMethod {
181 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
182 formatter.write_str(self.as_str())
183 }
184}
185
186#[derive(Clone, Eq, Hash, PartialEq, Serialize, Deserialize)]
193pub struct PrincipalOwnershipKey {
194 pub issuer: Option<String>,
195 pub tenant: Option<String>,
196 pub subject: String,
197}
198
199impl fmt::Debug for PrincipalOwnershipKey {
200 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201 formatter
202 .debug_struct("PrincipalOwnershipKey")
203 .field("issuer_present", &self.issuer.is_some())
204 .field("tenant_present", &self.tenant.is_some())
205 .field("subject_present", &!self.subject.is_empty())
206 .finish()
207 }
208}
209
210#[derive(Clone)]
217pub struct AuthenticatedPrincipal {
218 pub subject: String,
219 pub tenant: Option<String>,
220 pub scopes: Vec<String>,
221 pub issuer: Option<String>,
222 pub expires_at: Option<DateTime<Utc>>,
223 pub method: AuthenticationMethod,
224 pub assurance: IdentityAssurance,
225}
226
227impl fmt::Debug for AuthenticatedPrincipal {
228 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229 formatter
230 .debug_struct("AuthenticatedPrincipal")
231 .field("subject_present", &!self.subject.is_empty())
232 .field("tenant_present", &self.tenant.is_some())
233 .field("scope_count", &self.scopes.len())
234 .field("issuer_present", &self.issuer.is_some())
235 .field("expires_at_present", &self.expires_at.is_some())
236 .field("method", &self.method)
237 .field("assurance_kind", &self.assurance.kind())
238 .finish()
239 }
240}
241
242pub enum BearerAuthError {
246 Empty,
247
248 Invalid(String),
249
250 Unavailable(String),
251}
252
253impl BearerAuthError {
254 pub const fn diagnostic_class(&self) -> &'static str {
255 match self {
256 Self::Empty => "empty",
257 Self::Invalid(_) => "invalid",
258 Self::Unavailable(_) => "unavailable",
259 }
260 }
261}
262
263impl fmt::Display for BearerAuthError {
264 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
265 write!(
266 formatter,
267 "bearer authentication failed (class={})",
268 self.diagnostic_class()
269 )
270 }
271}
272
273impl fmt::Debug for BearerAuthError {
274 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
275 formatter
276 .debug_struct("BearerAuthError")
277 .field("class", &self.diagnostic_class())
278 .finish()
279 }
280}
281
282impl std::error::Error for BearerAuthError {}
283
284impl AuthenticatedPrincipal {
285 pub fn anonymous() -> Self {
286 Self {
287 subject: "anonymous".into(),
288 tenant: None,
289 scopes: Vec::new(),
290 issuer: None,
291 expires_at: None,
292 method: AuthenticationMethod::Anonymous,
293 assurance: IdentityAssurance::Anonymous,
294 }
295 }
296
297 pub fn has_scope(&self, scope: &str) -> bool {
298 self.scopes
299 .iter()
300 .any(|candidate| candidate == scope || candidate == "*")
301 }
302
303 pub fn require_scope(&self, scope: &str) -> Result<(), BearerAuthError> {
304 if self.has_scope(scope) {
305 Ok(())
306 } else {
307 Err(BearerAuthError::Invalid(format!(
308 "principal is missing required scope {scope}"
309 )))
310 }
311 }
312
313 pub fn ownership_key(&self) -> PrincipalOwnershipKey {
314 PrincipalOwnershipKey {
315 issuer: self.issuer.clone(),
316 tenant: self.tenant.clone(),
317 subject: self.subject.clone(),
318 }
319 }
320
321 pub fn has_same_owner(&self, other: &Self) -> bool {
322 self.ownership_key() == other.ownership_key()
323 }
324
325 pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
331 self.expires_at.is_some_and(|expires_at| expires_at <= now)
332 }
333
334 pub fn is_expired(&self) -> bool {
335 self.is_expired_at(Utc::now())
336 }
337
338 pub fn from_assurance(assurance: IdentityAssurance) -> Self {
342 Self::from_assurance_with_method(assurance, AuthenticationMethod::Bearer)
343 }
344
345 pub fn from_assurance_with_method(
346 assurance: IdentityAssurance,
347 method: AuthenticationMethod,
348 ) -> Self {
349 let (subject, scopes, expires_at) = match &assurance {
350 IdentityAssurance::Anonymous => ("assurance:anonymous".into(), Vec::new(), None),
351 IdentityAssurance::Pseudonymous { ephemeral_key } => {
352 let canonical_key = canonical_json(&ephemeral_key.0);
353 let subject = hashed_subject("pseudonymous", canonical_key.as_bytes());
354 (subject, Vec::new(), None)
355 }
356 IdentityAssurance::Identified { credential_kind } => (
357 format!("assurance:identified:{}", credential_kind.as_str()),
358 Vec::new(),
359 None,
360 ),
361 IdentityAssurance::TaskScoped {
362 identity,
363 task_id,
364 scopes,
365 expires_at,
366 } => {
367 let mut binding = Vec::with_capacity(16 + identity.as_str().len() + task_id.len());
368 append_length_prefixed(&mut binding, identity.as_str().as_bytes());
369 append_length_prefixed(&mut binding, task_id.as_bytes());
370 (
371 hashed_subject("task-scoped", &binding),
372 scopes.clone(),
373 Some(*expires_at),
374 )
375 }
376 IdentityAssurance::UserAuthorized {
377 identity,
378 user_id,
379 scopes,
380 } => {
381 let mut binding =
382 Vec::with_capacity(16 + identity.as_str().len() + user_id.as_str().len());
383 append_length_prefixed(&mut binding, identity.as_str().as_bytes());
384 append_length_prefixed(&mut binding, user_id.as_str().as_bytes());
385 (
386 hashed_subject("user-authorized", &binding),
387 scopes.clone(),
388 None,
389 )
390 }
391 IdentityAssurance::DtlsFingerprint { algorithm, value } => {
392 let algorithm = algorithm.to_ascii_lowercase();
393 let mut binding = Vec::with_capacity(16 + algorithm.len() + value.len());
394 append_length_prefixed(&mut binding, algorithm.as_bytes());
395 append_length_prefixed(&mut binding, value.as_bytes());
396 (
397 hashed_subject("dtls-fingerprint", &binding),
398 Vec::new(),
399 None,
400 )
401 }
402 };
403 Self {
404 subject,
405 tenant: None,
406 scopes,
407 issuer: None,
408 expires_at,
409 method,
410 assurance,
411 }
412 }
413}
414
415fn hashed_subject(kind: &str, binding: &[u8]) -> String {
416 let digest = Sha256::digest(binding);
417 let mut encoded = String::with_capacity(digest.len() * 2);
418 for byte in digest {
419 use std::fmt::Write as _;
420 write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
421 }
422 format!("{kind}:sha256:{encoded}")
423}
424
425fn append_length_prefixed(target: &mut Vec<u8>, value: &[u8]) {
426 target.extend_from_slice(&(value.len() as u64).to_be_bytes());
427 target.extend_from_slice(value);
428}
429
430fn canonical_json(value: &serde_json::Value) -> String {
431 match value {
432 serde_json::Value::Null => "null".to_owned(),
433 serde_json::Value::Bool(value) => value.to_string(),
434 serde_json::Value::Number(value) => value.to_string(),
435 serde_json::Value::String(value) => {
436 serde_json::to_string(value).expect("serializing a JSON string cannot fail")
437 }
438 serde_json::Value::Array(values) => format!(
439 "[{}]",
440 values
441 .iter()
442 .map(canonical_json)
443 .collect::<Vec<_>>()
444 .join(",")
445 ),
446 serde_json::Value::Object(values) => {
447 let mut members = values.iter().collect::<Vec<_>>();
448 members.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
449 format!(
450 "{{{}}}",
451 members
452 .into_iter()
453 .map(|(key, value)| format!(
454 "{}:{}",
455 serde_json::to_string(key)
456 .expect("serializing a JSON object key cannot fail"),
457 canonical_json(value)
458 ))
459 .collect::<Vec<_>>()
460 .join(",")
461 )
462 }
463 }
464}
465
466#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
467pub enum CredentialKind {
468 OAuth2Dpop,
469 Oidc,
470 SipDigest,
471 Passkey,
472 AAuth,
473}
474
475impl CredentialKind {
476 pub const fn as_str(self) -> &'static str {
477 match self {
478 Self::OAuth2Dpop => "oauth2-dpop",
479 Self::Oidc => "oidc",
480 Self::SipDigest => "sip-digest",
481 Self::Passkey => "passkey",
482 Self::AAuth => "aauth",
483 }
484 }
485}
486
487#[derive(Clone)]
488pub enum Credential {
489 Bearer(String),
490 OAuth2Dpop {
491 access_token: String,
492 dpop_proof: String,
493 },
494 Oidc {
495 id_token: String,
496 key_binding: Option<Jwk>,
497 },
498 Passkey {
499 challenge_response: Bytes,
500 attestation: Option<Bytes>,
501 },
502 SipDigest {
503 username: String,
504 response: String,
505 nonce: String,
506 },
507 AAuth {
508 signed_request: Bytes,
509 signature_key: Jwk,
510 signature_agent: Option<Jwk>,
511 },
512}
513
514impl fmt::Debug for Credential {
515 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
516 match self {
517 Self::Bearer(token) => formatter
518 .debug_struct("Bearer")
519 .field("token_bytes", &token.len())
520 .finish(),
521 Self::OAuth2Dpop {
522 access_token,
523 dpop_proof,
524 } => formatter
525 .debug_struct("OAuth2Dpop")
526 .field("access_token_bytes", &access_token.len())
527 .field("dpop_proof_bytes", &dpop_proof.len())
528 .finish(),
529 Self::Oidc {
530 id_token,
531 key_binding,
532 } => formatter
533 .debug_struct("Oidc")
534 .field("id_token_bytes", &id_token.len())
535 .field("key_binding_present", &key_binding.is_some())
536 .finish(),
537 Self::Passkey {
538 challenge_response,
539 attestation,
540 } => formatter
541 .debug_struct("Passkey")
542 .field("challenge_response_bytes", &challenge_response.len())
543 .field("attestation_present", &attestation.is_some())
544 .field(
545 "attestation_bytes",
546 &attestation.as_ref().map_or(0, Bytes::len),
547 )
548 .finish(),
549 Self::SipDigest {
550 username,
551 response,
552 nonce,
553 } => formatter
554 .debug_struct("SipDigest")
555 .field("username_bytes", &username.len())
556 .field("response_bytes", &response.len())
557 .field("nonce_bytes", &nonce.len())
558 .finish(),
559 Self::AAuth {
560 signed_request,
561 signature_key,
562 signature_agent,
563 } => formatter
564 .debug_struct("AAuth")
565 .field("signed_request_bytes", &signed_request.len())
566 .field("signature_key_present", &!signature_key.0.is_null())
567 .field("signature_agent_present", &signature_agent.is_some())
568 .finish(),
569 }
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 fn principal(issuer: &str, tenant: &str, expires_at: DateTime<Utc>) -> AuthenticatedPrincipal {
578 AuthenticatedPrincipal {
579 subject: "shared-subject".into(),
580 tenant: Some(tenant.into()),
581 scopes: vec!["calls:read".into()],
582 issuer: Some(issuer.into()),
583 expires_at: Some(expires_at),
584 method: AuthenticationMethod::Jwt,
585 assurance: IdentityAssurance::Anonymous,
586 }
587 }
588
589 #[test]
590 fn ownership_includes_issuer_tenant_and_subject() {
591 let expiry = Utc::now() + chrono::Duration::minutes(5);
592 let original = principal("https://issuer-a.example", "tenant-a", expiry);
593 let same = principal("https://issuer-a.example", "tenant-a", expiry);
594 let other_tenant = principal("https://issuer-a.example", "tenant-b", expiry);
595 let other_issuer = principal("https://issuer-b.example", "tenant-a", expiry);
596
597 assert!(original.has_same_owner(&same));
598 assert!(!original.has_same_owner(&other_tenant));
599 assert!(!original.has_same_owner(&other_issuer));
600 }
601
602 #[test]
603 fn expiry_boundary_is_inactive() {
604 let now = Utc::now();
605 let expired = principal("issuer", "tenant", now);
606 let active = principal("issuer", "tenant", now + chrono::Duration::milliseconds(1));
607
608 assert!(expired.is_expired_at(now));
609 assert!(!active.is_expired_at(now));
610 }
611
612 #[test]
613 fn diagnostic_names_are_stable_and_do_not_expose_assurance_payloads() {
614 let assurance = IdentityAssurance::DtlsFingerprint {
615 algorithm: "sha-256".into(),
616 value: "secret-fingerprint-value".into(),
617 };
618 assert_eq!(assurance.kind(), "dtls-fingerprint");
619 assert!(!assurance.kind().contains("secret-fingerprint-value"));
620 assert_eq!(
621 AuthenticationMethod::OAuth2Introspection.as_str(),
622 "oauth2-introspection"
623 );
624 assert_eq!(AuthenticationMethod::MutualTls.to_string(), "mutual-tls");
625 }
626
627 #[test]
628 fn assurance_principal_subjects_are_typed_stable_and_credential_free() {
629 const KEY_CANARY: &str = "private-jwk-material-canary";
630 const FINGERPRINT_CANARY: &str = "AA:BB:CC:credential-canary";
631
632 let key_a = Jwk(serde_json::json!({
633 "kty": "OKP",
634 "x": KEY_CANARY,
635 "nested": {"b": 2, "a": 1}
636 }));
637 let key_a_reordered = Jwk(serde_json::json!({
638 "nested": {"a": 1, "b": 2},
639 "x": KEY_CANARY,
640 "kty": "OKP"
641 }));
642 let key_b = Jwk(serde_json::json!({"kty": "OKP", "x": "other-key"}));
643
644 let pseudo_a = AuthenticatedPrincipal::from_assurance(IdentityAssurance::Pseudonymous {
645 ephemeral_key: key_a,
646 });
647 let pseudo_a_reordered =
648 AuthenticatedPrincipal::from_assurance(IdentityAssurance::Pseudonymous {
649 ephemeral_key: key_a_reordered,
650 });
651 let pseudo_b = AuthenticatedPrincipal::from_assurance(IdentityAssurance::Pseudonymous {
652 ephemeral_key: key_b,
653 });
654 assert_eq!(pseudo_a.subject, pseudo_a_reordered.subject);
655 assert_ne!(pseudo_a.subject, pseudo_b.subject);
656 assert!(pseudo_a.subject.starts_with("pseudonymous:sha256:"));
657 assert!(!pseudo_a.subject.contains(KEY_CANARY));
658
659 let dtls = AuthenticatedPrincipal::from_assurance(IdentityAssurance::DtlsFingerprint {
660 algorithm: "SHA-256".into(),
661 value: FINGERPRINT_CANARY.into(),
662 });
663 assert!(dtls.subject.starts_with("dtls-fingerprint:sha256:"));
664 assert!(!dtls.subject.contains(FINGERPRINT_CANARY));
665
666 let delimiter_left =
667 AuthenticatedPrincipal::from_assurance(IdentityAssurance::DtlsFingerprint {
668 algorithm: "sha".into(),
669 value: "256:AA".into(),
670 });
671 let delimiter_right =
672 AuthenticatedPrincipal::from_assurance(IdentityAssurance::DtlsFingerprint {
673 algorithm: "sha:256".into(),
674 value: "AA".into(),
675 });
676 assert_ne!(delimiter_left.subject, delimiter_right.subject);
677
678 let identified = AuthenticatedPrincipal::from_assurance(IdentityAssurance::Identified {
679 credential_kind: CredentialKind::OAuth2Dpop,
680 });
681 assert_eq!(identified.subject, "assurance:identified:oauth2-dpop");
682 }
683
684 #[test]
685 fn typed_assurance_classes_cannot_collide_on_shared_ids() {
686 let shared = IdentityId::from_string("shared-id");
687 let task = AuthenticatedPrincipal::from_assurance(IdentityAssurance::TaskScoped {
688 identity: shared.clone(),
689 task_id: "task-a".into(),
690 scopes: vec![],
691 expires_at: Utc::now() + chrono::Duration::minutes(1),
692 });
693 let other_task = AuthenticatedPrincipal::from_assurance(IdentityAssurance::TaskScoped {
694 identity: shared.clone(),
695 task_id: "task-b".into(),
696 scopes: vec![],
697 expires_at: Utc::now() + chrono::Duration::minutes(1),
698 });
699 let user = AuthenticatedPrincipal::from_assurance(IdentityAssurance::UserAuthorized {
700 identity: shared.clone(),
701 user_id: shared,
702 scopes: vec![],
703 });
704
705 assert_ne!(task.subject, other_task.subject);
706 assert_ne!(task.subject, user.subject);
707 assert!(task.subject.starts_with("task-scoped:sha256:"));
708 assert!(user.subject.starts_with("user-authorized:sha256:"));
709 }
710
711 #[test]
712 fn composite_assurance_subjects_resist_delimiter_collisions() {
713 let expires_at = Utc::now() + chrono::Duration::minutes(1);
714 let task_left = AuthenticatedPrincipal::from_assurance(IdentityAssurance::TaskScoped {
715 identity: IdentityId::from_string("a"),
716 task_id: "b:task:c".into(),
717 scopes: vec![],
718 expires_at,
719 });
720 let task_right = AuthenticatedPrincipal::from_assurance(IdentityAssurance::TaskScoped {
721 identity: IdentityId::from_string("a:task:b"),
722 task_id: "c".into(),
723 scopes: vec![],
724 expires_at,
725 });
726 assert_ne!(task_left.subject, task_right.subject);
727
728 let user_left = AuthenticatedPrincipal::from_assurance(IdentityAssurance::UserAuthorized {
729 identity: IdentityId::from_string("a"),
730 user_id: IdentityId::from_string("b:user:c"),
731 scopes: vec![],
732 });
733 let user_right =
734 AuthenticatedPrincipal::from_assurance(IdentityAssurance::UserAuthorized {
735 identity: IdentityId::from_string("a:user:b"),
736 user_id: IdentityId::from_string("c"),
737 scopes: vec![],
738 });
739 assert_ne!(user_left.subject, user_right.subject);
740 }
741
742 #[test]
743 fn principal_and_assurance_debug_are_metadata_only() {
744 const SUBJECT: &str = "principal-subject-secret-canary";
745 const TENANT: &str = "principal-tenant-secret-canary";
746 const ISSUER: &str = "principal-issuer-secret-canary";
747 const SCOPE: &str = "principal-scope-secret-canary";
748 const FINGERPRINT: &str = "principal-fingerprint-secret-canary";
749
750 let principal = AuthenticatedPrincipal {
751 subject: SUBJECT.into(),
752 tenant: Some(TENANT.into()),
753 scopes: vec![SCOPE.into()],
754 issuer: Some(ISSUER.into()),
755 expires_at: Some(Utc::now() + chrono::Duration::minutes(5)),
756 method: AuthenticationMethod::MutualTls,
757 assurance: IdentityAssurance::DtlsFingerprint {
758 algorithm: "sha-256".into(),
759 value: FINGERPRINT.into(),
760 },
761 };
762
763 let ownership = principal.ownership_key();
764 let jwk = Jwk(serde_json::json!({"secret": SUBJECT}));
765 let rendered = format!(
766 "{principal:?} {:?} {ownership:?} {jwk:?}",
767 principal.assurance
768 );
769 assert!(rendered.contains("AuthenticatedPrincipal"));
770 assert!(rendered.contains("DtlsFingerprint"));
771 assert!(rendered.contains("PrincipalOwnershipKey"));
772 assert!(rendered.contains("Jwk"));
773 assert!(rendered.contains("scope_count: 1"));
774 for secret in [SUBJECT, TENANT, ISSUER, SCOPE, FINGERPRINT] {
775 assert!(
776 !rendered.contains(secret),
777 "debug leaked {secret}: {rendered}"
778 );
779 }
780 }
781
782 #[test]
783 fn credential_debug_reports_only_variant_metadata() {
784 const TOKEN: &str = "bearer-token-secret-canary";
785 const PROOF: &str = "dpop-proof-secret-canary";
786 const USERNAME: &str = "digest-user-secret-canary";
787 const RESPONSE: &str = "digest-response-secret-canary";
788 const NONCE: &str = "digest-nonce-secret-canary";
789 const SIGNED: &[u8] = b"aauth-signed-request-secret-canary";
790
791 let credentials = [
792 Credential::Bearer(TOKEN.into()),
793 Credential::OAuth2Dpop {
794 access_token: TOKEN.into(),
795 dpop_proof: PROOF.into(),
796 },
797 Credential::Oidc {
798 id_token: TOKEN.into(),
799 key_binding: Some(Jwk(serde_json::json!({"secret": TOKEN}))),
800 },
801 Credential::Passkey {
802 challenge_response: Bytes::from_static(SIGNED),
803 attestation: Some(Bytes::from_static(SIGNED)),
804 },
805 Credential::SipDigest {
806 username: USERNAME.into(),
807 response: RESPONSE.into(),
808 nonce: NONCE.into(),
809 },
810 Credential::AAuth {
811 signed_request: Bytes::from_static(SIGNED),
812 signature_key: Jwk(serde_json::json!({"secret": TOKEN})),
813 signature_agent: Some(Jwk(serde_json::json!({"secret": PROOF}))),
814 },
815 ];
816
817 let rendered = credentials
818 .iter()
819 .map(|credential| format!("{credential:?}"))
820 .collect::<Vec<_>>()
821 .join(" ");
822 for variant in [
823 "Bearer",
824 "OAuth2Dpop",
825 "Oidc",
826 "Passkey",
827 "SipDigest",
828 "AAuth",
829 ] {
830 assert!(rendered.contains(variant));
831 }
832 for secret in [
833 TOKEN,
834 PROOF,
835 USERNAME,
836 RESPONSE,
837 NONCE,
838 "aauth-signed-request",
839 ] {
840 assert!(
841 !rendered.contains(secret),
842 "debug leaked {secret}: {rendered}"
843 );
844 }
845 }
846
847 #[test]
848 fn identity_source_keeps_payload_containers_on_manual_debug() {
849 let source = include_str!("identity.rs");
850 for declaration in [
851 "pub struct Jwk",
852 "pub enum IdentityAssurance",
853 "pub struct PrincipalOwnershipKey",
854 "pub struct AuthenticatedPrincipal",
855 "pub enum Credential {",
856 ] {
857 let declaration_offset = source
858 .find(declaration)
859 .unwrap_or_else(|| panic!("missing declaration {declaration}"));
860 let prefix = &source[..declaration_offset];
861 let derive_offset = prefix
862 .rfind("#[derive(")
863 .unwrap_or_else(|| panic!("missing derive for {declaration}"));
864 assert!(
865 !prefix[derive_offset..].contains("Debug"),
866 "{declaration} regained derived Debug"
867 );
868 }
869 }
870}