Skip to main content

camel_auth/
kernel.rs

1//! Authentication kernel: the sealed principal, minting, and the dispatch guard.
2//!
3//! This module is the trust boundary between transport-layer token extraction
4//! and route authorization. [`AuthenticatedPrincipal`] is a sealed type: it is
5//! nameable (so `Exchange::get_extension::<AuthenticatedPrincipal>` downcasts
6//! across crates) but not constructible outside this crate — all fields are
7//! private and there is no public constructor. A principal can therefore only
8//! come from [`kernel_authenticate`], which mints it after a real token passes
9//! a registered provider's authenticator.
10
11use std::sync::Arc;
12
13use camel_api::security_policy::{AccessMode, AuthPrincipal, Principal, RouteSecurityPlan};
14use camel_api::{CamelError, Exchange};
15
16use crate::authn_cache::AuthnCacheKey;
17use crate::credential_source::ExtractedToken;
18use crate::registry::ProviderRegistry;
19use crate::token_authenticator::AuthnRequest;
20
21/// The typed, unforgeable authentication identity stored on an [`Exchange`].
22///
23/// # Sealing
24///
25/// This type is intentionally sealed: the fields are private and there is no
26/// public constructor (no `__mint`, no doc-hidden escape, no feature-gated
27/// test constructor — Cargo feature unification would make those unsound). The
28/// only sound seal is same-crate construction, so the only way any code —
29/// including transports and authorization policies — can obtain an
30/// `AuthenticatedPrincipal` is through [`kernel_authenticate`], which verifies
31/// a credential against a registered provider first. This guards against
32/// accidental construction and, combined with [`enforce_dispatch`]'s
33/// route-binding, against cross-provider principal spoofing. A hostile actor
34/// editing this crate itself is the only remaining spoofing vector, and that
35/// is a review-level concern, not a type-system one.
36#[derive(Clone)]
37pub struct AuthenticatedPrincipal {
38    principal: Principal,
39    provider_id: String,
40}
41
42impl AuthenticatedPrincipal {
43    /// Private mint path. The only caller is [`kernel_authenticate`], after a
44    /// credential has passed a provider's authenticator.
45    fn mint(principal: Principal, provider_id: String) -> Self {
46        Self {
47            principal,
48            provider_id,
49        }
50    }
51}
52
53impl AuthPrincipal for AuthenticatedPrincipal {
54    fn principal(&self) -> &Principal {
55        &self.principal
56    }
57
58    fn provider_id(&self) -> &str {
59        &self.provider_id
60    }
61}
62
63/// Exchange extension key under which [`install_carrier`] stores the
64/// authenticated principal.
65///
66/// Values stored under this key are unforgeable: producing one requires an
67/// [`AuthenticatedPrincipal`], which no external code can construct. A
68/// wrong-type value fails the `get_extension::<AuthenticatedPrincipal>`
69/// downcast and is treated as absent.
70pub const KERNEL_PRINCIPAL_KEY: &str = "camel.auth.principal.typed";
71
72/// Resolve the route's provider and authenticate the extracted token.
73///
74/// Returns an [`AuthenticatedPrincipal`] minted by the provider named in
75/// `plan.provider_ref`. An unresolved provider is an [`CamelError::Unauthenticated`]
76/// whose message names the missing provider (fail-closed).
77pub async fn kernel_authenticate(
78    plan: &RouteSecurityPlan,
79    providers: &ProviderRegistry,
80    credentials: &ExtractedToken,
81) -> Result<AuthenticatedPrincipal, CamelError> {
82    let provider_ref = plan.provider_ref.as_deref().ok_or_else(|| {
83        CamelError::Unauthenticated("route has no provider_ref; cannot authenticate".to_string())
84    })?;
85
86    let entry = providers.resolve(provider_ref).ok_or_else(|| {
87        CamelError::Unauthenticated(format!("unknown auth provider: {provider_ref}"))
88    })?;
89
90    // Build the per-request authn context from the plan's audience binding
91    // (route-level precedence already merged in Task 1.8), falling back to the
92    // resolved provider's binding when the plan carries none.
93    let binding = plan
94        .audience_binding
95        .as_ref()
96        .or(entry.audience_binding.as_ref());
97    let audiences: &[String] = binding.map(|b| b.audiences.as_slice()).unwrap_or(&[]);
98    let issuers: &[String] = binding.map(|b| b.issuers.as_slice()).unwrap_or(&[]);
99
100    // Task 3.2: consult the authn result cache before hitting the provider. A
101    // hit returns the cached minted principal; denials are never cached.
102    if let Some(cache) = providers.authn_cache() {
103        let key = AuthnCacheKey::new(
104            provider_ref,
105            audiences,
106            issuers,
107            plan.transport,
108            &credentials.token,
109        );
110        if let Some(principal) = cache.get(&key) {
111            tracing::debug!(target: "camel_auth::authn_cache", cache_outcome = "hit");
112            return Ok(principal);
113        }
114    }
115
116    let req = AuthnRequest {
117        token: &credentials.token,
118        audiences,
119        accepted_issuers: issuers,
120        transport: plan.transport,
121    };
122
123    let principal = entry.authenticator.authenticate(req).await?;
124
125    let minted = AuthenticatedPrincipal::mint(principal, provider_ref.to_string());
126
127    // Cache the minted principal (denials returned Err above and are never
128    // inserted). The entry never outlives the token's exp.
129    if let Some(cache) = providers.authn_cache() {
130        let key = AuthnCacheKey::new(
131            provider_ref,
132            audiences,
133            issuers,
134            plan.transport,
135            &credentials.token,
136        );
137        cache.insert(key, minted.clone());
138    }
139
140    Ok(minted)
141}
142
143/// Guard a dispatch against the route's security plan.
144///
145/// A [`AccessMode::Public`] route passes through with no extraction and no
146/// carrier requirement. Any non-Public route requires the carrier to be present
147/// AND the carrier's `provider_id()` to equal `plan.provider_ref` — a principal
148/// minted for provider A does not satisfy provider B's route (route-bound,
149/// cross-provider replay denied). Otherwise the guard fails closed with
150/// [`CamelError::Unauthenticated`].
151pub fn enforce_dispatch(plan: &RouteSecurityPlan, exchange: &Exchange) -> Result<(), CamelError> {
152    if matches!(&plan.access_mode, AccessMode::Public) {
153        return Ok(());
154    }
155
156    let carrier = read_carrier(exchange).ok_or_else(|| {
157        CamelError::Unauthenticated("no authenticated principal present".to_string())
158    })?;
159
160    if plan.provider_ref.as_deref() == Some(carrier.provider_id()) {
161        Ok(())
162    } else {
163        Err(CamelError::Unauthenticated(format!(
164            "principal from provider {:?} does not satisfy route provider {:?}",
165            carrier.provider_id(),
166            plan.provider_ref
167        )))
168    }
169}
170
171/// Install the authenticated principal as the exchange's typed carrier.
172///
173/// Stores an `Arc`'d clone under [`KERNEL_PRINCIPAL_KEY`] via
174/// `Exchange::set_extension`.
175pub fn install_carrier(exchange: &mut Exchange, principal: &AuthenticatedPrincipal) {
176    exchange.set_extension(KERNEL_PRINCIPAL_KEY, Arc::new(principal.clone()));
177}
178
179/// Read the typed carrier back off the exchange, cloning it out.
180///
181/// Returns `None` when the key is absent or the stored value is not an
182/// [`AuthenticatedPrincipal`] (wrong-type values fail the downcast).
183pub fn read_carrier(exchange: &Exchange) -> Option<AuthenticatedPrincipal> {
184    exchange
185        .get_extension::<AuthenticatedPrincipal>(KERNEL_PRINCIPAL_KEY)
186        .cloned()
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::credential_source::CredentialSource;
193    use crate::native_auth::{NativeCredential, NativeCredentialSecret, StaticTokenAuthenticator};
194    use crate::registry::ProviderEntry;
195    use camel_api::Message;
196    use camel_api::security_policy::TransportId;
197    use zeroize::Zeroizing;
198
199    fn test_principal() -> Principal {
200        Principal {
201            subject: "svc-user".into(),
202            issuer: "test".into(),
203            audience: vec![],
204            scopes: vec![],
205            roles: vec![],
206            claims: serde_json::Value::Null,
207        }
208    }
209
210    /// A registry holding a single static provider whose token is `token`.
211    fn static_provider(id: &str, token: &str) -> ProviderRegistry {
212        let store = crate::native_auth::NativeCredentialStore::try_new(vec![NativeCredential {
213            secret: NativeCredentialSecret::Plaintext {
214                value: Zeroizing::new(token.to_string()),
215            },
216            principal: test_principal(),
217        }])
218        .unwrap();
219        let registry = ProviderRegistry::new();
220        registry.register(
221            id,
222            ProviderEntry {
223                authenticator: Arc::new(StaticTokenAuthenticator::new(store)),
224                audience_binding: None,
225            },
226        );
227        registry
228    }
229
230    fn authenticated_plan(provider_ref: &str) -> RouteSecurityPlan {
231        RouteSecurityPlan {
232            access_mode: AccessMode::Authenticated,
233            provider_ref: Some(provider_ref.to_string()),
234            transport: TransportId::Http,
235            credential_sources: vec![CredentialSource::AuthorizationHeader],
236            audience_binding: None,
237        }
238    }
239
240    fn public_plan() -> RouteSecurityPlan {
241        RouteSecurityPlan {
242            access_mode: AccessMode::Public,
243            provider_ref: None,
244            transport: TransportId::Http,
245            credential_sources: vec![],
246            audience_binding: None,
247        }
248    }
249
250    fn credentials(token: &str) -> ExtractedToken {
251        ExtractedToken {
252            token: token.to_string(),
253            source: CredentialSource::AuthorizationHeader,
254        }
255    }
256
257    fn empty_exchange() -> Exchange {
258        Exchange::new(Message::default())
259    }
260
261    #[tokio::test]
262    async fn kernel_authenticate_mints_with_provider() {
263        let providers = static_provider("idp-a", "t-a");
264        let plan = authenticated_plan("idp-a");
265        let principal = kernel_authenticate(&plan, &providers, &credentials("t-a"))
266            .await
267            .unwrap();
268        assert_eq!(principal.provider_id(), "idp-a");
269        assert_eq!(principal.principal().subject, "svc-user");
270    }
271
272    #[tokio::test]
273    async fn kernel_authenticate_denies_wrong_token() {
274        let providers = static_provider("idp-a", "t-a");
275        let plan = authenticated_plan("idp-a");
276        let result = kernel_authenticate(&plan, &providers, &credentials("wrong")).await;
277        assert!(matches!(result, Err(CamelError::Unauthenticated(_))));
278    }
279
280    #[tokio::test]
281    async fn kernel_authenticate_names_unresolved_provider() {
282        let providers = static_provider("idp-a", "t-a");
283        let plan = authenticated_plan("idp-ghost");
284        match kernel_authenticate(&plan, &providers, &credentials("t-a")).await {
285            Ok(_) => panic!("expected Unauthenticated"),
286            Err(CamelError::Unauthenticated(msg)) => assert!(msg.contains("idp-ghost")),
287            Err(other) => panic!("expected Unauthenticated, got: {other}"),
288        }
289    }
290
291    #[test]
292    fn enforce_dispatch_public_passes_without_carrier() {
293        let plan = public_plan();
294        let exchange = empty_exchange();
295        assert!(enforce_dispatch(&plan, &exchange).is_ok());
296    }
297
298    #[tokio::test]
299    async fn enforce_dispatch_nonpublic_requires_carrier() {
300        let providers = static_provider("idp-a", "t-a");
301        let plan = authenticated_plan("idp-a");
302        let mut exchange = empty_exchange();
303
304        // No carrier yet: fail closed.
305        assert!(matches!(
306            enforce_dispatch(&plan, &exchange),
307            Err(CamelError::Unauthenticated(_))
308        ));
309
310        let principal = kernel_authenticate(&plan, &providers, &credentials("t-a"))
311            .await
312            .unwrap();
313        install_carrier(&mut exchange, &principal);
314
315        assert!(enforce_dispatch(&plan, &exchange).is_ok());
316    }
317
318    #[tokio::test]
319    async fn enforce_dispatch_rejects_cross_provider_carrier() {
320        let providers = static_provider("idp-a", "t-a");
321        let mint_plan = authenticated_plan("idp-a");
322        let principal = kernel_authenticate(&mint_plan, &providers, &credentials("t-a"))
323            .await
324            .unwrap();
325
326        let mut exchange = empty_exchange();
327        install_carrier(&mut exchange, &principal);
328
329        let target_plan = authenticated_plan("idp-b");
330        assert!(matches!(
331            enforce_dispatch(&target_plan, &exchange),
332            Err(CamelError::Unauthenticated(_))
333        ));
334    }
335
336    #[test]
337    fn read_carrier_returns_none_when_absent() {
338        let exchange = empty_exchange();
339        assert!(read_carrier(&exchange).is_none());
340    }
341
342    #[tokio::test]
343    async fn enforce_dispatch_fails_closed_when_provider_ref_none() {
344        // Missing wiring yields deny, not bypass: a non-Public plan compiled
345        // without a provider_ref can never be satisfied by any carrier.
346        let providers = static_provider("idp-a", "t-a");
347        let mint_plan = authenticated_plan("idp-a");
348        let principal = kernel_authenticate(&mint_plan, &providers, &credentials("t-a"))
349            .await
350            .unwrap();
351
352        let mut exchange = empty_exchange();
353        install_carrier(&mut exchange, &principal);
354
355        let mut unwired_plan = authenticated_plan("idp-a");
356        unwired_plan.provider_ref = None;
357        assert!(matches!(
358            enforce_dispatch(&unwired_plan, &exchange),
359            Err(CamelError::Unauthenticated(_))
360        ));
361    }
362
363    #[test]
364    fn wrong_type_value_under_carrier_key_does_not_authorize() {
365        // Spoof resistance: a forged marker stored under KERNEL_PRINCIPAL_KEY
366        // fails the downcast and is treated as absent.
367        let mut exchange = empty_exchange();
368        exchange.set_extension(
369            KERNEL_PRINCIPAL_KEY,
370            std::sync::Arc::new("forged".to_string()),
371        );
372
373        assert!(read_carrier(&exchange).is_none());
374        let plan = authenticated_plan("idp-a");
375        assert!(matches!(
376            enforce_dispatch(&plan, &exchange),
377            Err(CamelError::Unauthenticated(_))
378        ));
379    }
380}