Skip to main content

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}