Skip to main content

mcpkit_core/auth/
identity.rs

1//! Verified user identity and session-to-user binding.
2//!
3//! The MCP security best practices require that per-user state is not associated
4//! with a session id alone — user identity should derive from the validated
5//! access token (its `sub`, scoped by `iss`). [`VerifiedUser`] is that identity,
6//! and [`check_session_binding`] enforces that a session bound to a user is only
7//! ever used by that same user.
8
9use serde::{Deserialize, Serialize};
10
11/// A user identity verified from an access token.
12///
13/// Identity is the `(issuer, subject)` pair: a `subject` (`sub`) is only
14/// globally meaningful within its `issuer` (`iss`). `audience` is recorded for
15/// context but is deliberately *not* part of identity equality — validating the
16/// audience is a token-validation concern (is this token meant for this
17/// resource?), and a returning user's token may legitimately be re-issued with a
18/// different audience.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct VerifiedUser {
21    /// The token subject (`sub`).
22    pub subject: String,
23    /// The token issuer (`iss`), if present.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub issuer: Option<String>,
26    /// The token audience(s) (`aud`). Context only; not used for binding.
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub audience: Vec<String>,
29}
30
31impl VerifiedUser {
32    /// Create a verified user from a subject.
33    #[must_use]
34    pub fn new(subject: impl Into<String>) -> Self {
35        Self {
36            subject: subject.into(),
37            issuer: None,
38            audience: Vec::new(),
39        }
40    }
41
42    /// Set the issuer (`iss`).
43    #[must_use]
44    pub fn issuer(mut self, issuer: impl Into<String>) -> Self {
45        self.issuer = Some(issuer.into());
46        self
47    }
48
49    /// Set the audience(s) (`aud`).
50    #[must_use]
51    pub fn audience<I, S>(mut self, audience: I) -> Self
52    where
53        I: IntoIterator<Item = S>,
54        S: Into<String>,
55    {
56        self.audience = audience.into_iter().map(Into::into).collect();
57        self
58    }
59
60    /// Whether two identities are the same user — equal `(issuer, subject)`.
61    ///
62    /// Audience is intentionally not compared.
63    #[must_use]
64    pub fn is_same_user(&self, other: &Self) -> bool {
65        self.subject == other.subject && self.issuer == other.issuer
66    }
67
68    /// Build a verified user from validated JWT claims, or `None` if the token
69    /// has no `sub`.
70    ///
71    /// The caller is responsible for having *validated* the token first; this
72    /// only projects the claims into an identity.
73    #[cfg(feature = "jwt")]
74    #[must_use]
75    pub fn from_claims(claims: &crate::auth::jwt::TokenClaims) -> Option<Self> {
76        use crate::auth::jwt::Audience;
77        let subject = claims.sub.clone()?;
78        let audience = match &claims.aud {
79            Some(Audience::Single(a)) => vec![a.clone()],
80            Some(Audience::Multiple(v)) => v.clone(),
81            None => Vec::new(),
82        };
83        Some(Self {
84            subject,
85            issuer: claims.iss.clone(),
86            audience,
87        })
88    }
89}
90
91/// Why a request was refused against a session's bound identity.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum SessionBindingError {
94    /// The session is bound to a user, but the request presented no identity.
95    IdentityRequired,
96    /// The session is bound to a different user than the request presented.
97    IdentityMismatch,
98    /// The session is anonymous, but the request presented a verified identity.
99    /// A user-bound session must be created up front rather than silently
100    /// upgraded from an anonymous one.
101    UnexpectedIdentity,
102}
103
104impl std::fmt::Display for SessionBindingError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        let msg = match self {
107            Self::IdentityRequired => "session requires a verified identity",
108            Self::IdentityMismatch => "session is bound to a different user",
109            Self::UnexpectedIdentity => "anonymous session cannot be used with a verified identity",
110        };
111        f.write_str(msg)
112    }
113}
114
115impl std::error::Error for SessionBindingError {}
116
117/// Enforce the session-to-user binding rule for a request.
118///
119/// - anonymous session + anonymous request → `Ok`
120/// - user-bound session + same user → `Ok`
121/// - user-bound session + no identity → [`SessionBindingError::IdentityRequired`]
122/// - user-bound session + different user → [`SessionBindingError::IdentityMismatch`]
123/// - anonymous session + verified identity → [`SessionBindingError::UnexpectedIdentity`]
124///   (no silent upgrade)
125pub fn check_session_binding(
126    bound: Option<&VerifiedUser>,
127    presenting: Option<&VerifiedUser>,
128) -> Result<(), SessionBindingError> {
129    match (bound, presenting) {
130        (None, None) => Ok(()),
131        (None, Some(_)) => Err(SessionBindingError::UnexpectedIdentity),
132        (Some(_), None) => Err(SessionBindingError::IdentityRequired),
133        (Some(b), Some(p)) if b.is_same_user(p) => Ok(()),
134        (Some(_), Some(_)) => Err(SessionBindingError::IdentityMismatch),
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn same_user_compares_issuer_and_subject_not_audience() {
144        let a = VerifiedUser::new("alice")
145            .issuer("https://idp")
146            .audience(["res-1"]);
147        let b = VerifiedUser::new("alice")
148            .issuer("https://idp")
149            .audience(["res-2"]);
150        assert!(a.is_same_user(&b), "audience must not affect identity");
151
152        let c = VerifiedUser::new("alice").issuer("https://other");
153        assert!(!a.is_same_user(&c), "different issuer is a different user");
154
155        let d = VerifiedUser::new("bob").issuer("https://idp");
156        assert!(!a.is_same_user(&d), "different subject is a different user");
157    }
158
159    #[test]
160    fn binding_rules() {
161        let alice = VerifiedUser::new("alice").issuer("https://idp");
162        let bob = VerifiedUser::new("bob").issuer("https://idp");
163
164        // anonymous session + anonymous request
165        assert!(check_session_binding(None, None).is_ok());
166        // bound + same user
167        assert!(check_session_binding(Some(&alice), Some(&alice)).is_ok());
168        // bound + missing identity
169        assert_eq!(
170            check_session_binding(Some(&alice), None),
171            Err(SessionBindingError::IdentityRequired)
172        );
173        // bound + different user
174        assert_eq!(
175            check_session_binding(Some(&alice), Some(&bob)),
176            Err(SessionBindingError::IdentityMismatch)
177        );
178        // anonymous session + verified identity (no silent upgrade)
179        assert_eq!(
180            check_session_binding(None, Some(&alice)),
181            Err(SessionBindingError::UnexpectedIdentity)
182        );
183    }
184}