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}