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}