Skip to main content

plexus_auth_core/
auth.rs

1//! AuthContext and SessionValidator — relocated from plexus-core.
2//!
3//! Per AUTHZ-CORE-CRATE-1, the public type surface (fields, methods,
4//! constructors) is preserved verbatim from plexus-core's previous home at
5//! `plexus_core::plexus::auth`. The migration is mechanical; behavior is
6//! unchanged. plexus-core re-exports these from here with a `#[deprecated]`
7//! pointer at the new path so existing callers compile during the
8//! deprecation window.
9//!
10//! Future work tightens the seal on `AuthContext::new` and the field
11//! visibility (per AUTHZ-0 §"Crate-level isolation amplifies the seal").
12//! That tightening is intentionally NOT in this ticket because it would
13//! break ~30 direct construction sites across plexus-trak, plexus-transport
14//! tests, and others — a workspace-wide blast radius outside this ticket's
15//! scope. See `plans/AUTHZ/AUTHZ-CORE-CRATE-1-RUN-NOTES.md`.
16
17use async_trait::async_trait;
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22/// Per-connection authentication context, populated during WS upgrade.
23///
24/// This context is extracted from HTTP cookies (or other auth mechanisms) during
25/// the WebSocket handshake and attached to the connection. Every RPC call on that
26/// connection has access to this context.
27///
28/// # Multi-tenancy with Keycloak
29///
30/// When using Keycloak for multi-tenancy, the `AuthContext` typically contains:
31/// - `user_id`: Keycloak user ID (sub claim from JWT)
32/// - `session_id`: Keycloak session ID
33/// - `roles`: User roles within the tenant/realm
34/// - `metadata`: Additional JWT claims (realm, tenant ID, custom attributes)
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
36pub struct AuthContext {
37    /// User identifier (e.g., Keycloak sub claim, user UUID)
38    pub user_id: String,
39
40    /// Session identifier (e.g., Keycloak session ID)
41    pub session_id: String,
42
43    /// User roles (e.g., ["user", "admin"], Keycloak realm roles)
44    pub roles: Vec<String>,
45
46    /// Additional metadata (e.g., JWT claims, tenant/realm info, custom attributes)
47    /// For Keycloak multi-tenancy, this typically includes:
48    /// - `realm`: Keycloak realm name
49    /// - `tenant_id`: Organization/tenant identifier
50    /// - Any custom claims from the JWT token
51    pub metadata: Value,
52}
53
54impl AuthContext {
55    /// Create a new AuthContext.
56    ///
57    /// # Note
58    ///
59    /// This constructor is `pub` to preserve the existing public API across
60    /// the workspace. AUTHZ-0's structural-defense vision calls for this
61    /// constructor to be `pub(crate)`; tightening that seal lands in a
62    /// follow-up ticket because of the workspace-wide blast radius. See
63    /// `plans/AUTHZ/AUTHZ-CORE-CRATE-1-RUN-NOTES.md`.
64    pub fn new(user_id: String, session_id: String, roles: Vec<String>, metadata: Value) -> Self {
65        Self {
66            user_id,
67            session_id,
68            roles,
69            metadata,
70        }
71    }
72
73    /// Create an anonymous/unauthenticated context.
74    ///
75    /// This can be used as a fallback when methods accept `Option<&AuthContext>`
76    /// and no authentication was provided.
77    pub fn anonymous() -> Self {
78        Self {
79            user_id: "anonymous".to_string(),
80            session_id: String::new(),
81            roles: vec![],
82            metadata: Value::Null,
83        }
84    }
85
86    /// Check if this context represents an authenticated user.
87    pub fn is_authenticated(&self) -> bool {
88        self.user_id != "anonymous" && !self.session_id.is_empty()
89    }
90
91    /// Check if the user has a specific role.
92    pub fn has_role(&self, role: &str) -> bool {
93        self.roles.iter().any(|r| r == role)
94    }
95
96    /// Get a metadata field as a string.
97    pub fn get_metadata_string(&self, key: &str) -> Option<String> {
98        self.metadata
99            .get(key)
100            .and_then(|v| v.as_str())
101            .map(String::from)
102    }
103
104    /// Get the tenant/realm from metadata (Keycloak multi-tenancy).
105    pub fn tenant(&self) -> Option<String> {
106        self.get_metadata_string("tenant_id")
107            .or_else(|| self.get_metadata_string("realm"))
108    }
109
110    /// Framework-only constructor: derive a callee `AuthContext` from a
111    /// caller context and a [`ForwardDerivation`].
112    ///
113    /// This is the **only** path that mints a callee context from a
114    /// caller's. It is `pub(crate)` to `plexus-auth-core` — no downstream
115    /// crate can reach it. A [`ForwardPolicy`] impl returns a
116    /// `ForwardDerivation` (parameters); the framework dispatch path
117    /// (AUTHLANG-3 wires this into `plexus_core::route_to_child`) is the
118    /// only caller of this constructor. Per AUTHZ-0 §"The sealed-type
119    /// pattern": the policy proposes; the framework disposes.
120    ///
121    /// The `_immediate_caller_stamp` parameter is reserved for the
122    /// principal-chain extension that AUTHLANG-3 lands (it will append the
123    /// stamp to the callee's invocation chain when `AuthContext` grows the
124    /// chain field — today's `AuthContext` does not carry one, so the
125    /// parameter is bound for forward compatibility and accepted but
126    /// otherwise ignored). Surfacing it now keeps the constructor's
127    /// signature stable across the AUTHZ-0 sealed-context migration.
128    ///
129    /// # Derivation semantics
130    ///
131    /// Each flag on the derivation maps to a logical field group on the
132    /// current `AuthContext`:
133    ///
134    /// - `keep_verified_user` — retains `user_id` and `session_id` (the
135    ///   identity of the originator). When `false`, both are reset to
136    ///   `AuthContext::anonymous`'s values (`"anonymous"` / empty).
137    /// - `keep_roles` — retains the role vector. When `false`, the
138    ///   callee's `roles` is empty.
139    /// - `keep_capabilities` — reserved for the AUTHZ-DATA / AUTHZ-CRED
140    ///   migration; today's `AuthContext` carries no capabilities field, so
141    ///   this flag is a no-op. Surfacing it keeps the v1 policy shape
142    ///   forward-compatible.
143    /// - `keep_metadata` — retains the metadata bag. When `false`, the
144    ///   callee's `metadata` is `Value::Null`.
145    ///
146    /// The constructor never grows the context: every field in the
147    /// returned `AuthContext` is either copied from `caller_ctx` or reset
148    /// to the corresponding empty value. There is no path through this
149    /// function that adds an unrelated role or fabricates a user_id.
150    ///
151    /// [`ForwardDerivation`]: crate::forward::ForwardDerivation
152    /// [`ForwardPolicy`]: crate::forward::ForwardPolicy
153    ///
154    pub(crate) fn derive_callee_context(
155        caller_ctx: &AuthContext,
156        derivation: &crate::forward::ForwardDerivation,
157        _immediate_caller_stamp: &crate::principal::Principal,
158    ) -> AuthContext {
159        let (user_id, session_id) = if derivation.keep_verified_user {
160            (caller_ctx.user_id.clone(), caller_ctx.session_id.clone())
161        } else {
162            ("anonymous".to_string(), String::new())
163        };
164        let roles = if derivation.keep_roles {
165            caller_ctx.roles.clone()
166        } else {
167            Vec::new()
168        };
169        // `keep_capabilities` is a no-op today: the current AuthContext has no
170        // capabilities field. The flag is preserved on `ForwardDerivation` for
171        // forward compatibility with the AUTHZ-DATA / AUTHZ-CRED migration.
172        let metadata = if derivation.keep_metadata {
173            caller_ctx.metadata.clone()
174        } else {
175            Value::Null
176        };
177        AuthContext {
178            user_id,
179            session_id,
180            roles,
181            metadata,
182        }
183    }
184
185    /// Scoped-callback API for deriving a callee context.
186    ///
187    /// The framework's dispatch path (plexus-core `route_to_child`) calls
188    /// this with a `ForwardDerivation` and a caller-principal stamp; the
189    /// closure receives the derived callee `AuthContext` by value and
190    /// returns whatever the dispatch yields (typically a `Future`).
191    /// Passing by value rather than reference is intentional: it allows
192    /// the closure to move the callee into an async block so dispatch can
193    /// await the child call while the callee lives inside the future's
194    /// state machine.
195    ///
196    /// This is the public entry point for AUTHLANG-3. The underlying
197    /// constructor [`derive_callee_context`] remains `pub(crate)` so the
198    /// raw "mint a callee from a caller" symbol is not callable from
199    /// outside `plexus-auth-core`. Anyone can still call `AuthContext::new`
200    /// and craft their own context from scratch — what they cannot do is
201    /// obtain one through the framework-blessed derivation path except
202    /// inside this callback, where the lifetime is scoped to the dispatch
203    /// invocation.
204    ///
205    /// Per AUTHZ-0 §"The sealed-type pattern": the policy proposes (via
206    /// `ForwardDerivation`); the framework disposes (via this callback).
207    pub fn with_callee_context<F, R>(
208        &self,
209        derivation: &crate::forward::ForwardDerivation,
210        immediate_caller_stamp: &crate::principal::Principal,
211        f: F,
212    ) -> R
213    where
214        F: FnOnce(AuthContext) -> R,
215    {
216        f(Self::derive_callee_context(self, derivation, immediate_caller_stamp))
217    }
218}
219
220/// Backends implement this trait to validate cookies/tokens during WS upgrade.
221///
222/// This trait is designed to be object-safe and work with async/await, allowing
223/// backends to use any authentication mechanism:
224/// - JWT validation (e.g., Keycloak tokens)
225/// - Database session lookups
226/// - Redis session stores
227/// - OAuth token introspection
228///
229/// # Example: Keycloak JWT Validation
230///
231/// ```rust,ignore
232/// use plexus_auth_core::{AuthContext, SessionValidator};
233/// use async_trait::async_trait;
234///
235/// struct KeycloakValidator {
236///     jwks_client: JwksClient,
237///     realm: String,
238/// }
239///
240/// #[async_trait]
241/// impl SessionValidator for KeycloakValidator {
242///     async fn validate(&self, cookie_value: &str) -> Option<AuthContext> {
243///         // Parse JWT from cookie
244///         let token = parse_jwt_from_cookie(cookie_value)?;
245///
246///         // Validate signature and claims
247///         let claims = self.jwks_client.verify(&token).await.ok()?;
248///
249///         // Extract user info and tenant from JWT claims
250///         Some(AuthContext::new(
251///             claims.sub,
252///             claims.sid.unwrap_or_default(),
253///             claims.realm_access.roles,
254///             serde_json::json!({
255///                 "realm": self.realm,
256///                 "tenant_id": claims.get("tenant_id"),
257///                 "email": claims.email,
258///             }),
259///         ))
260///     }
261/// }
262/// ```
263#[async_trait]
264pub trait SessionValidator: Send + Sync + 'static {
265    /// Validate a cookie header value and return an AuthContext if valid.
266    ///
267    /// # Arguments
268    ///
269    /// * `cookie_value` - The raw Cookie header value (e.g., "session=abc123; path=/")
270    ///
271    /// # Returns
272    ///
273    /// - `Some(AuthContext)` if the cookie is valid and represents an authenticated session
274    /// - `None` if the cookie is invalid, expired, or represents an anonymous session
275    ///
276    /// # Implementation Notes
277    ///
278    /// - This is called during the WebSocket handshake (HTTP upgrade)
279    /// - Validation should be fast to avoid blocking the connection
280    /// - For JWT: verify signature, check expiration, extract claims
281    /// - For session-based auth: lookup session in DB/Redis
282    /// - Return None for invalid/expired credentials (connection proceeds as anonymous)
283    async fn validate(&self, cookie_value: &str) -> Option<AuthContext>;
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_auth_context_creation() {
292        let ctx = AuthContext::new(
293            "user-123".to_string(),
294            "sess-456".to_string(),
295            vec!["admin".to_string()],
296            serde_json::json!({"tenant_id": "acme"}),
297        );
298
299        assert_eq!(ctx.user_id, "user-123");
300        assert_eq!(ctx.session_id, "sess-456");
301        assert!(ctx.has_role("admin"));
302        assert!(!ctx.has_role("user"));
303        assert_eq!(ctx.tenant(), Some("acme".to_string()));
304        assert!(ctx.is_authenticated());
305    }
306
307    #[test]
308    fn test_auth_context_clone() {
309        let ctx = AuthContext::new(
310            "alice".to_string(),
311            "sess-1".to_string(),
312            vec!["admin".to_string()],
313            serde_json::json!({"org": "acme"}),
314        );
315
316        let cloned = ctx.clone();
317        assert_eq!(ctx.user_id, cloned.user_id);
318        assert_eq!(ctx.session_id, cloned.session_id);
319        assert_eq!(ctx.roles, cloned.roles);
320    }
321
322    #[test]
323    fn test_anonymous_context() {
324        let ctx = AuthContext::anonymous();
325        assert_eq!(ctx.user_id, "anonymous");
326        assert!(!ctx.is_authenticated());
327        assert!(ctx.roles.is_empty());
328    }
329
330    #[test]
331    fn test_role_checking() {
332        let ctx = AuthContext::new(
333            "user-1".to_string(),
334            "sess-1".to_string(),
335            vec!["user".to_string(), "editor".to_string()],
336            Value::Null,
337        );
338
339        assert!(ctx.has_role("user"));
340        assert!(ctx.has_role("editor"));
341        assert!(!ctx.has_role("admin"));
342    }
343
344    #[test]
345    fn test_metadata_access() {
346        let ctx = AuthContext::new(
347            "user-1".to_string(),
348            "sess-1".to_string(),
349            vec![],
350            serde_json::json!({
351                "tenant_id": "org-123",
352                "realm": "production",
353                "email": "user@example.com"
354            }),
355        );
356
357        assert_eq!(
358            ctx.get_metadata_string("tenant_id"),
359            Some("org-123".to_string())
360        );
361        assert_eq!(
362            ctx.get_metadata_string("realm"),
363            Some("production".to_string())
364        );
365        assert_eq!(
366            ctx.get_metadata_string("email"),
367            Some("user@example.com".to_string())
368        );
369        assert_eq!(ctx.get_metadata_string("nonexistent"), None);
370    }
371
372
373    #[test]
374    fn derive_callee_context_identity_only_strips_roles_and_metadata() {
375        use crate::forward::ForwardDerivation;
376        use crate::principal::Principal;
377
378        let caller = AuthContext::new(
379            "alice".to_string(),
380            "sess-1".to_string(),
381            vec!["admin".to_string(), "editor".to_string()],
382            serde_json::json!({"tenant_id": "acme"}),
383        );
384        let stamp = Principal::anonymous_sealed();
385        let callee = AuthContext::derive_callee_context(
386            &caller,
387            &ForwardDerivation::IDENTITY_ONLY,
388            &stamp,
389        );
390        assert_eq!(callee.user_id, "alice");
391        assert_eq!(callee.session_id, "sess-1");
392        assert!(callee.roles.is_empty());
393        assert_eq!(callee.metadata, Value::Null);
394    }
395
396    #[test]
397    fn derive_callee_context_pass_through_retains_all_fields() {
398        use crate::forward::ForwardDerivation;
399        use crate::principal::Principal;
400
401        let caller = AuthContext::new(
402            "alice".to_string(),
403            "sess-1".to_string(),
404            vec!["admin".to_string()],
405            serde_json::json!({"tenant_id": "acme", "k": "v"}),
406        );
407        let stamp = Principal::anonymous_sealed();
408        let callee = AuthContext::derive_callee_context(
409            &caller,
410            &ForwardDerivation::PASS_THROUGH,
411            &stamp,
412        );
413        assert_eq!(callee.user_id, caller.user_id);
414        assert_eq!(callee.session_id, caller.session_id);
415        assert_eq!(callee.roles, caller.roles);
416        assert_eq!(callee.metadata, caller.metadata);
417    }
418
419    #[test]
420    fn derive_callee_context_anonymous_drops_everything() {
421        use crate::forward::ForwardDerivation;
422        use crate::principal::Principal;
423
424        let caller = AuthContext::new(
425            "alice".to_string(),
426            "sess-1".to_string(),
427            vec!["admin".to_string()],
428            serde_json::json!({"tenant_id": "acme"}),
429        );
430        let stamp = Principal::anonymous_sealed();
431        let callee = AuthContext::derive_callee_context(
432            &caller,
433            &ForwardDerivation::ANONYMOUS,
434            &stamp,
435        );
436        assert_eq!(callee.user_id, "anonymous");
437        assert_eq!(callee.session_id, "");
438        assert!(callee.roles.is_empty());
439        assert_eq!(callee.metadata, Value::Null);
440        assert!(!callee.is_authenticated());
441    }
442
443    #[test]
444    fn with_callee_context_invokes_closure_with_derived_callee() {
445        use crate::forward::ForwardDerivation;
446        use crate::principal::Principal;
447
448        let caller = AuthContext::new(
449            "alice".to_string(),
450            "sess-1".to_string(),
451            vec!["admin".to_string()],
452            serde_json::json!({"tenant_id": "org-1"}),
453        );
454        let stamp = Principal::anonymous_sealed();
455
456        let observed_user_id =
457            caller.with_callee_context(&ForwardDerivation::IDENTITY_ONLY, &stamp, |callee| {
458                assert!(callee.roles.is_empty());
459                assert_eq!(callee.metadata, Value::Null);
460                callee.user_id
461            });
462
463        assert_eq!(observed_user_id, "alice");
464    }
465
466    #[test]
467    fn with_callee_context_returns_closure_value() {
468        use crate::forward::ForwardDerivation;
469        use crate::principal::Principal;
470
471        let caller = AuthContext::anonymous();
472        let stamp = Principal::anonymous_sealed();
473        let answer =
474            caller.with_callee_context(&ForwardDerivation::PASS_THROUGH, &stamp, |_| 42_u32);
475        assert_eq!(answer, 42);
476    }
477
478    #[test]
479    fn derive_callee_context_never_grows_context() {
480        // Sanity: the constructor cannot fabricate fields that did not
481        // exist on the caller. Starting from anonymous, even pass_through
482        // produces anonymous — the derivation can keep what the caller
483        // had, but never add what the caller lacked.
484        use crate::forward::ForwardDerivation;
485        use crate::principal::Principal;
486
487        let caller = AuthContext::anonymous();
488        let stamp = Principal::anonymous_sealed();
489        let callee = AuthContext::derive_callee_context(
490            &caller,
491            &ForwardDerivation::PASS_THROUGH,
492            &stamp,
493        );
494        assert_eq!(callee.user_id, "anonymous");
495        assert!(callee.roles.is_empty());
496        assert_eq!(callee.metadata, Value::Null);
497        assert!(!callee.is_authenticated());
498    }
499
500    #[test]
501    fn test_tenant_from_metadata() {
502        // tenant_id takes precedence
503        let ctx1 = AuthContext::new(
504            "user-1".to_string(),
505            "sess-1".to_string(),
506            vec![],
507            serde_json::json!({"tenant_id": "org-123", "realm": "prod"}),
508        );
509        assert_eq!(ctx1.tenant(), Some("org-123".to_string()));
510
511        // Falls back to realm if no tenant_id
512        let ctx2 = AuthContext::new(
513            "user-1".to_string(),
514            "sess-1".to_string(),
515            vec![],
516            serde_json::json!({"realm": "prod"}),
517        );
518        assert_eq!(ctx2.tenant(), Some("prod".to_string()));
519
520        // None if neither present
521        let ctx3 = AuthContext::new(
522            "user-1".to_string(),
523            "sess-1".to_string(),
524            vec![],
525            Value::Null,
526        );
527        assert_eq!(ctx3.tenant(), None);
528    }
529}