plexus_core/plexus/auth.rs
1//! Authentication context for Plexus RPC
2
3use async_trait::async_trait;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// Per-connection authentication context, populated during WS upgrade.
9///
10/// This context is extracted from HTTP cookies (or other auth mechanisms) during
11/// the WebSocket handshake and attached to the connection. Every RPC call on that
12/// connection has access to this context.
13///
14/// # Multi-tenancy with Keycloak
15///
16/// When using Keycloak for multi-tenancy, the `AuthContext` typically contains:
17/// - `user_id`: Keycloak user ID (sub claim from JWT)
18/// - `session_id`: Keycloak session ID
19/// - `roles`: User roles within the tenant/realm
20/// - `metadata`: Additional JWT claims (realm, tenant ID, custom attributes)
21#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
22pub struct AuthContext {
23 /// User identifier (e.g., Keycloak sub claim, user UUID)
24 pub user_id: String,
25
26 /// Session identifier (e.g., Keycloak session ID)
27 pub session_id: String,
28
29 /// User roles (e.g., ["user", "admin"], Keycloak realm roles)
30 pub roles: Vec<String>,
31
32 /// Additional metadata (e.g., JWT claims, tenant/realm info, custom attributes)
33 /// For Keycloak multi-tenancy, this typically includes:
34 /// - `realm`: Keycloak realm name
35 /// - `tenant_id`: Organization/tenant identifier
36 /// - Any custom claims from the JWT token
37 pub metadata: Value,
38}
39
40impl AuthContext {
41 /// Create a new AuthContext
42 pub fn new(user_id: String, session_id: String, roles: Vec<String>, metadata: Value) -> Self {
43 Self {
44 user_id,
45 session_id,
46 roles,
47 metadata,
48 }
49 }
50
51 /// Create an anonymous/unauthenticated context
52 ///
53 /// This can be used as a fallback when methods accept `Option<&AuthContext>`
54 /// and no authentication was provided.
55 pub fn anonymous() -> Self {
56 Self {
57 user_id: "anonymous".to_string(),
58 session_id: String::new(),
59 roles: vec![],
60 metadata: Value::Null,
61 }
62 }
63
64 /// Check if this context represents an authenticated user
65 pub fn is_authenticated(&self) -> bool {
66 self.user_id != "anonymous" && !self.session_id.is_empty()
67 }
68
69 /// Check if the user has a specific role
70 pub fn has_role(&self, role: &str) -> bool {
71 self.roles.iter().any(|r| r == role)
72 }
73
74 /// Get a metadata field as a string
75 pub fn get_metadata_string(&self, key: &str) -> Option<String> {
76 self.metadata.get(key).and_then(|v| v.as_str()).map(String::from)
77 }
78
79 /// Get the tenant/realm from metadata (Keycloak multi-tenancy)
80 pub fn tenant(&self) -> Option<String> {
81 self.get_metadata_string("tenant_id")
82 .or_else(|| self.get_metadata_string("realm"))
83 }
84}
85
86/// Backends implement this trait to validate cookies/tokens during WS upgrade.
87///
88/// This trait is designed to be object-safe and work with async/await, allowing
89/// backends to use any authentication mechanism:
90/// - JWT validation (e.g., Keycloak tokens)
91/// - Database session lookups
92/// - Redis session stores
93/// - OAuth token introspection
94///
95/// # Example: Keycloak JWT Validation
96///
97/// ```rust,ignore
98/// use plexus_core::plexus::{AuthContext, SessionValidator};
99/// use async_trait::async_trait;
100///
101/// struct KeycloakValidator {
102/// jwks_client: JwksClient,
103/// realm: String,
104/// }
105///
106/// #[async_trait]
107/// impl SessionValidator for KeycloakValidator {
108/// async fn validate(&self, cookie_value: &str) -> Option<AuthContext> {
109/// // Parse JWT from cookie
110/// let token = parse_jwt_from_cookie(cookie_value)?;
111///
112/// // Validate signature and claims
113/// let claims = self.jwks_client.verify(&token).await.ok()?;
114///
115/// // Extract user info and tenant from JWT claims
116/// Some(AuthContext {
117/// user_id: claims.sub,
118/// session_id: claims.sid.unwrap_or_default(),
119/// roles: claims.realm_access.roles,
120/// metadata: serde_json::json!({
121/// "realm": self.realm,
122/// "tenant_id": claims.get("tenant_id"),
123/// "email": claims.email,
124/// }),
125/// })
126/// }
127/// }
128/// ```
129#[async_trait]
130pub trait SessionValidator: Send + Sync + 'static {
131 /// Validate a cookie header value and return an AuthContext if valid.
132 ///
133 /// # Arguments
134 ///
135 /// * `cookie_value` - The raw Cookie header value (e.g., "session=abc123; path=/")
136 ///
137 /// # Returns
138 ///
139 /// - `Some(AuthContext)` if the cookie is valid and represents an authenticated session
140 /// - `None` if the cookie is invalid, expired, or represents an anonymous session
141 ///
142 /// # Implementation Notes
143 ///
144 /// - This is called during the WebSocket handshake (HTTP upgrade)
145 /// - Validation should be fast to avoid blocking the connection
146 /// - For JWT: verify signature, check expiration, extract claims
147 /// - For session-based auth: lookup session in DB/Redis
148 /// - Return None for invalid/expired credentials (connection proceeds as anonymous)
149 async fn validate(&self, cookie_value: &str) -> Option<AuthContext>;
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn test_auth_context_creation() {
158 let ctx = AuthContext::new(
159 "user-123".to_string(),
160 "sess-456".to_string(),
161 vec!["admin".to_string()],
162 serde_json::json!({"tenant_id": "acme"}),
163 );
164
165 assert_eq!(ctx.user_id, "user-123");
166 assert_eq!(ctx.session_id, "sess-456");
167 assert!(ctx.has_role("admin"));
168 assert!(!ctx.has_role("user"));
169 assert_eq!(ctx.tenant(), Some("acme".to_string()));
170 assert!(ctx.is_authenticated());
171 }
172
173 #[test]
174 fn test_auth_context_clone() {
175 let ctx = AuthContext::new(
176 "alice".to_string(),
177 "sess-1".to_string(),
178 vec!["admin".to_string()],
179 serde_json::json!({"org": "acme"}),
180 );
181
182 let cloned = ctx.clone();
183 assert_eq!(ctx.user_id, cloned.user_id);
184 assert_eq!(ctx.session_id, cloned.session_id);
185 assert_eq!(ctx.roles, cloned.roles);
186 }
187
188 #[test]
189 fn test_anonymous_context() {
190 let ctx = AuthContext::anonymous();
191 assert_eq!(ctx.user_id, "anonymous");
192 assert!(!ctx.is_authenticated());
193 assert!(ctx.roles.is_empty());
194 }
195
196 #[test]
197 fn test_role_checking() {
198 let ctx = AuthContext::new(
199 "user-1".to_string(),
200 "sess-1".to_string(),
201 vec!["user".to_string(), "editor".to_string()],
202 Value::Null,
203 );
204
205 assert!(ctx.has_role("user"));
206 assert!(ctx.has_role("editor"));
207 assert!(!ctx.has_role("admin"));
208 }
209
210 #[test]
211 fn test_metadata_access() {
212 let ctx = AuthContext::new(
213 "user-1".to_string(),
214 "sess-1".to_string(),
215 vec![],
216 serde_json::json!({
217 "tenant_id": "org-123",
218 "realm": "production",
219 "email": "user@example.com"
220 }),
221 );
222
223 assert_eq!(ctx.get_metadata_string("tenant_id"), Some("org-123".to_string()));
224 assert_eq!(ctx.get_metadata_string("realm"), Some("production".to_string()));
225 assert_eq!(ctx.get_metadata_string("email"), Some("user@example.com".to_string()));
226 assert_eq!(ctx.get_metadata_string("nonexistent"), None);
227 }
228
229 #[test]
230 fn test_tenant_from_metadata() {
231 // tenant_id takes precedence
232 let ctx1 = AuthContext::new(
233 "user-1".to_string(),
234 "sess-1".to_string(),
235 vec![],
236 serde_json::json!({"tenant_id": "org-123", "realm": "prod"}),
237 );
238 assert_eq!(ctx1.tenant(), Some("org-123".to_string()));
239
240 // Falls back to realm if no tenant_id
241 let ctx2 = AuthContext::new(
242 "user-1".to_string(),
243 "sess-1".to_string(),
244 vec![],
245 serde_json::json!({"realm": "prod"}),
246 );
247 assert_eq!(ctx2.tenant(), Some("prod".to_string()));
248
249 // None if neither present
250 let ctx3 = AuthContext::new(
251 "user-1".to_string(),
252 "sess-1".to_string(),
253 vec![],
254 Value::Null,
255 );
256 assert_eq!(ctx3.tenant(), None);
257 }
258}