Skip to main content

quarlus_security/
identity.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::SecurityError;
4
5/// Trait for building an identity from validated JWT claims.
6///
7/// Implement this trait to customize how JWT claims are mapped to your
8/// identity type. The `build` method is async, allowing database lookups
9/// or other I/O during identity construction.
10///
11/// The default implementation ([`DefaultIdentityBuilder`]) produces
12/// [`AuthenticatedUser`] synchronously from the claims.
13///
14/// # Example — sync (pure claims mapping)
15///
16/// ```ignore
17/// struct MyIdentityBuilder;
18///
19/// impl IdentityBuilder for MyIdentityBuilder {
20///     type Identity = MyUser;
21///     fn build(&self, claims: serde_json::Value)
22///         -> impl Future<Output = Result<MyUser, SecurityError>> + Send
23///     {
24///         let sub = claims.get("sub").and_then(|v| v.as_str()).unwrap_or_default().to_owned();
25///         let tenant = claims.get("tenant_id").and_then(|v| v.as_str()).unwrap_or_default().to_owned();
26///         std::future::ready(Ok(MyUser { sub, tenant_id: tenant }))
27///     }
28/// }
29/// ```
30///
31/// # Example — async (database lookup)
32///
33/// ```ignore
34/// struct DbIdentityBuilder { pool: SqlitePool }
35///
36/// impl IdentityBuilder for DbIdentityBuilder {
37///     type Identity = DbUser;
38///     fn build(&self, claims: serde_json::Value)
39///         -> impl Future<Output = Result<DbUser, SecurityError>> + Send
40///     {
41///         let pool = self.pool.clone();
42///         async move {
43///             let sub = claims.get("sub").and_then(|v| v.as_str()).unwrap_or_default();
44///             sqlx::query_as("SELECT * FROM users WHERE sub = ?")
45///                 .bind(sub)
46///                 .fetch_one(&pool)
47///                 .await
48///                 .map_err(|e| SecurityError::ValidationFailed(e.to_string()))
49///         }
50///     }
51/// }
52/// ```
53pub trait IdentityBuilder: Send + Sync {
54    type Identity: Clone + Send + Sync;
55    fn build(
56        &self,
57        claims: serde_json::Value,
58    ) -> impl std::future::Future<Output = Result<Self::Identity, SecurityError>> + Send;
59}
60
61/// Default identity builder that produces [`AuthenticatedUser`].
62///
63/// Uses a [`RoleExtractor`] to extract roles from JWT claims.
64pub struct DefaultIdentityBuilder {
65    role_extractor: Box<dyn RoleExtractor>,
66}
67
68impl DefaultIdentityBuilder {
69    /// Create a new builder with the [`DefaultRoleExtractor`].
70    pub fn new() -> Self {
71        Self {
72            role_extractor: Box::new(DefaultRoleExtractor),
73        }
74    }
75
76    /// Create a new builder with a custom role extractor.
77    pub fn with_extractor(role_extractor: Box<dyn RoleExtractor>) -> Self {
78        Self { role_extractor }
79    }
80}
81
82impl Default for DefaultIdentityBuilder {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl IdentityBuilder for DefaultIdentityBuilder {
89    type Identity = AuthenticatedUser;
90
91    fn build(
92        &self,
93        claims: serde_json::Value,
94    ) -> impl std::future::Future<Output = Result<AuthenticatedUser, SecurityError>> + Send {
95        let user = build_authenticated_user(claims, &*self.role_extractor);
96        std::future::ready(Ok(user))
97    }
98}
99
100/// Represents an authenticated user extracted from a validated JWT token.
101#[derive(Clone, Debug, Serialize, Deserialize)]
102pub struct AuthenticatedUser {
103    /// Subject claim ("sub") - unique user identifier.
104    pub sub: String,
105
106    /// Email claim ("email"), if present in the token.
107    pub email: Option<String>,
108
109    /// Roles extracted from the token claims.
110    pub roles: Vec<String>,
111
112    /// Raw claims for advanced access.
113    pub claims: serde_json::Value,
114}
115
116impl quarlus_core::Identity for AuthenticatedUser {
117    fn sub(&self) -> &str {
118        &self.sub
119    }
120    fn roles(&self) -> &[String] {
121        &self.roles
122    }
123}
124
125impl AuthenticatedUser {
126    /// Check whether the user has a specific role.
127    pub fn has_role(&self, role: &str) -> bool {
128        self.roles.iter().any(|r| r == role)
129    }
130
131    /// Check whether the user has any of the specified roles.
132    pub fn has_any_role(&self, roles: &[&str]) -> bool {
133        roles.iter().any(|role| self.has_role(role))
134    }
135}
136
137/// Trait for extracting roles from JWT claims.
138///
139/// Different OIDC providers store roles in different claim locations.
140/// Implement this trait to customize role extraction for your provider.
141pub trait RoleExtractor: Send + Sync {
142    fn extract_roles(&self, claims: &serde_json::Value) -> Vec<String>;
143}
144
145/// Default role extractor that checks common locations:
146/// - `roles` (top-level array)
147/// - `realm_access.roles` (Keycloak)
148pub struct DefaultRoleExtractor;
149
150impl RoleExtractor for DefaultRoleExtractor {
151    fn extract_roles(&self, claims: &serde_json::Value) -> Vec<String> {
152        // Try top-level "roles" claim
153        if let Some(roles) = claims.get("roles").and_then(|v| v.as_array()) {
154            let extracted: Vec<String> = roles
155                .iter()
156                .filter_map(|r| r.as_str().map(String::from))
157                .collect();
158            if !extracted.is_empty() {
159                return extracted;
160            }
161        }
162
163        // Try Keycloak "realm_access.roles"
164        if let Some(roles) = claims
165            .get("realm_access")
166            .and_then(|v| v.get("roles"))
167            .and_then(|v| v.as_array())
168        {
169            let extracted: Vec<String> = roles
170                .iter()
171                .filter_map(|r| r.as_str().map(String::from))
172                .collect();
173            if !extracted.is_empty() {
174                return extracted;
175            }
176        }
177
178        Vec::new()
179    }
180}
181
182/// Build an `AuthenticatedUser` from validated JWT claims using the given role extractor.
183pub fn build_authenticated_user(
184    claims: serde_json::Value,
185    role_extractor: &dyn RoleExtractor,
186) -> AuthenticatedUser {
187    let sub = claims
188        .get("sub")
189        .and_then(|v| v.as_str())
190        .unwrap_or_default()
191        .to_string();
192
193    let email = claims
194        .get("email")
195        .and_then(|v| v.as_str())
196        .map(String::from);
197
198    let roles = role_extractor.extract_roles(&claims);
199
200    AuthenticatedUser {
201        sub,
202        email,
203        roles,
204        claims,
205    }
206}