Skip to main content

rvoip_core_traits/
identity.rs

1//! Identity *data* types — moved here (V2.A.1) so consumer crates
2//! like `rvoip-auth-core` and `rvoip-vcon` can depend on
3//! `rvoip-core-traits` instead of `rvoip-core`, breaking the dep
4//! cycle.
5//!
6//! The `IdentityProvider` trait and the structs that reference
7//! rvoip-core's `Result` type (`Identity`, `Device`, `ReachabilityHint`,
8//! `ReachabilityChange`, `DtlsFingerprint`) stay in
9//! `rvoip-core::identity` — that's the broader move scope listed in
10//! GAP_PLAN.md V2.A.4–6 and isn't required for the v2.A cycle break.
11
12use crate::ids::IdentityId;
13use bytes::Bytes;
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17use std::fmt;
18
19/// Opaque JWK placeholder. Real shape lives in `rvoip-identity`.
20#[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/// IdentityAssurance gradient per CONVERSATION_PROTOCOL.md §5.6.
59///
60/// The `DtlsFingerprint` variant is always compiled (downstream
61/// crates like rvoip-auth-core match on it); the
62/// `identity-fingerprint-binding` feature flag in rvoip-core controls
63/// whether production fingerprint *verification* is wired by
64/// default. See INTERFACE_DESIGN.md §8.4.
65#[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    /// D2 — DTLS-SRTP fingerprint binding (RFC 8122 §5).
86    /// `algorithm` is the IANA hash name (e.g. `"sha-256"`);
87    /// `value` is the colon-separated hex digest as it appears in
88    /// the SDP `a=fingerprint:` attribute.
89    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    /// Stable, credential-free assurance class for policy diagnostics and
129    /// cross-crate events. This deliberately does not format embedded keys,
130    /// fingerprints, user IDs, or scopes.
131    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/// Authentication mechanism that established an [`AuthenticatedPrincipal`].
144///
145/// This describes the credential family rather than a concrete provider so
146/// signaling and media adapters can apply policy without depending on an
147/// authentication implementation crate.
148#[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/// Stable authorization ownership boundary for a principal-owned resource.
187///
188/// Subject alone is not sufficient: two issuers can use the same `sub`, and
189/// the same issuer can reuse a subject in different tenants. All three values
190/// therefore participate in equality and hashing. Missing issuer or tenant
191/// values remain explicit rather than acting as wildcards.
192#[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/// Transport-neutral result of successful authentication.
211///
212/// This type lives in `rvoip-core-traits` so auth implementations and protocol
213/// adapters can carry the same complete identity without creating a dependency
214/// cycle. Resource authorization should compare [`Self::ownership_key`], not
215/// `subject` by itself.
216#[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
242/// Bearer validation failure retained in the dependency-cycle-free trait
243/// surface so [`AuthenticatedPrincipal::require_scope`] remains source
244/// compatible when the principal type is re-exported by auth-core.
245pub 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    /// Whether this principal is expired at `now`.
326    ///
327    /// An expiry equal to `now` is already expired. Principals without an
328    /// expiry remain active until their backing authentication policy says
329    /// otherwise.
330    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    /// Compatibility mapping for authentication providers that currently
339    /// produce only an [`IdentityAssurance`]. Validators with issuer, tenant,
340    /// or credential-expiry information should construct a full principal.
341    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}