Skip to main content

canic_core/api/auth/
application_session.rs

1//! Module: api::auth::application_session
2//!
3//! Responsibility: adapt managed application-session commands and caller-self status.
4//! Does not own: proof cryptography, canonical persistence, or role-surface selection.
5//! Boundary: cfg-pruned endpoint macros call this adapter with ambient caller/time authority.
6
7use super::AuthApi;
8use crate::{
9    InternalError,
10    access::auth::validate_application_subject,
11    config::schema::LocalApplicationAuthorizationConfig,
12    diagnostics::codes,
13    domain::policy::pure::auth::application_authorization::{
14        ApplicationCapacityLimit, ApplicationProofEligibilityError,
15        ApplicationSessionAdmissionError, ApplicationSessionTtlError,
16        resolve_application_session_expiry,
17    },
18    dto::{
19        auth::{
20            ApplicationSessionAuditEntry, ApplicationSessionAuditResponse,
21            ApplicationSessionCommandResponse, ApplicationSessionPolicyView,
22            ApplicationSessionRequest, ApplicationSessionStatus,
23            ApplicationSessionVerifierPolicyView, ApplicationSessionView,
24            InactiveApplicationSession,
25        },
26        error::Error,
27        page::{Page, PageRequest},
28    },
29    model::auth::application_authorization::{
30        ApplicationScope, CanonicalApplicationScopes, LocalApplicationAuthoritySnapshot,
31        LocalApplicationSession, MAX_APPLICATION_PROOF_LIFETIME_NS,
32    },
33    ops::{
34        auth::{
35            AuthOps, VerifyDelegatedTokenRuntimeInput,
36            application_authorization::LocalApplicationAuthorizationAuthority,
37        },
38        ic::IcOps,
39        runtime::metrics::auth::{
40            AuthMetricReason, record_application_session_clear, record_application_session_created,
41            record_application_session_establishment_started,
42            record_application_session_expired_observation, record_application_session_idempotent,
43            record_application_session_rejected, record_application_session_replaced,
44        },
45        storage::auth::{
46            AuthStateOps,
47            application_sessions::{ApplicationReplayResolution, ApplicationSessionStateError},
48        },
49    },
50    workflow::{
51        auth::application_sessions::{
52            ApplicationSessionEstablishInput, ApplicationSessionEstablishResult,
53            ApplicationSessionWorkflow, ApplicationSessionWorkflowError,
54        },
55        runtime::intent::IntentCleanupWorkflow,
56    },
57};
58
59const NS_PER_SEC: u64 = 1_000_000_000;
60
61impl AuthApi {
62    /// Establish one caller-bound scoped session from a current delegated proof.
63    pub fn establish_application_session(
64        request: ApplicationSessionRequest,
65    ) -> Result<ApplicationSessionCommandResponse, Error> {
66        record_application_session_establishment_started();
67        let result = Self::establish_application_session_inner(request);
68        if let Err(error) = &result {
69            record_application_session_rejected(establishment_rejection_reason(*error));
70        }
71        result
72    }
73
74    fn establish_application_session_inner(
75        request: ApplicationSessionRequest,
76    ) -> Result<ApplicationSessionCommandResponse, Error> {
77        let caller = IcOps::msg_caller();
78        require_application_caller(caller)?;
79        let authority = application_session_authority()?;
80        let now_ns = IcOps::now_nanos();
81        let requested_ttl_secs = request.requested_ttl_secs;
82        let requested_scopes = canonical_requested_scopes(request.requested_scopes)?;
83        require_configured_scopes(&authority.config, &requested_scopes)?;
84        let expires_at_ns = session_expiry(now_ns, requested_ttl_secs, &authority.config)?;
85        let proof_fingerprint =
86            AuthOps::delegated_token_claims_fingerprint(&request.delegated_token)
87                .map_err(Error::from)?;
88        let request_hash = ApplicationSessionWorkflow::establishment_request_hash(
89            &requested_scopes,
90            requested_ttl_secs,
91        );
92
93        match ApplicationSessionWorkflow::resolve_retry(
94            proof_fingerprint,
95            caller,
96            request_hash,
97            now_ns,
98        )
99        .map_err(map_state_error)?
100        {
101            ApplicationReplayResolution::ExactActive(session) => {
102                require_current_session(&session, &authority.snapshot, now_ns)?;
103                record_application_session_idempotent();
104                IntentCleanupWorkflow::reconcile_after_terminal();
105                return Ok(ApplicationSessionCommandResponse::Established(
106                    session_view(&session),
107                ));
108            }
109            ApplicationReplayResolution::Conflict => {
110                return Err(Error::from_registered(codes::STATE_CONFLICT));
111            }
112            ApplicationReplayResolution::Absent => {}
113        }
114
115        let max_ttl_ns = AuthOps::delegated_token_max_ttl_ns().map_err(Error::from)?;
116        let verified = AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
117            token: &request.delegated_token,
118            caller,
119            max_cert_ttl_ns: max_ttl_ns,
120            max_token_ttl_ns: max_ttl_ns,
121            required_scopes: &[],
122            now_ns,
123        })
124        .map_err(Error::from)?;
125        if verified.fleet() != authority.snapshot.fleet()
126            || verified.role() != authority.snapshot.role()
127        {
128            return Err(Error::from_registered(codes::AUTHORITY_CONFLICT));
129        }
130
131        let established =
132            ApplicationSessionWorkflow::establish_verified(ApplicationSessionEstablishInput {
133                authority: verified,
134                requested_scopes,
135                authority_generation: authority.snapshot.generation(),
136                established_at_ns: now_ns,
137                expires_at_ns,
138                establishment_request_hash: request_hash,
139            })
140            .map_err(map_workflow_error)?;
141        let session = match established {
142            ApplicationSessionEstablishResult::Created(session) => {
143                record_application_session_created();
144                session
145            }
146            ApplicationSessionEstablishResult::ExactRetry(session) => {
147                record_application_session_idempotent();
148                session
149            }
150            ApplicationSessionEstablishResult::Replaced(session) => {
151                record_application_session_replaced();
152                session
153            }
154        };
155        IntentCleanupWorkflow::reconcile_after_terminal();
156        Ok(ApplicationSessionCommandResponse::Established(
157            session_view(&session),
158        ))
159    }
160
161    /// Remove only the current caller's retained session and keep replay tombstones.
162    pub fn clear_application_session() -> Result<ApplicationSessionCommandResponse, Error> {
163        let caller = IcOps::msg_caller();
164        require_application_caller(caller)?;
165        application_session_authority()?;
166        let removed = AuthStateOps::clear_application_session(caller).map_err(map_state_error)?;
167        record_application_session_clear(removed);
168        IntentCleanupWorkflow::reconcile_after_terminal();
169        Ok(ApplicationSessionCommandResponse::Cleared)
170    }
171
172    /// Return the current caller's read-only application-session classification.
173    pub fn application_session_status() -> Result<ApplicationSessionStatus, Error> {
174        let caller = IcOps::msg_caller();
175        require_application_caller(caller)?;
176        let authority = application_session_authority()?;
177        let Some(session) = AuthStateOps::application_session(caller).map_err(map_state_error)?
178        else {
179            return Ok(ApplicationSessionStatus::Inactive(
180                InactiveApplicationSession::Missing,
181            ));
182        };
183        let now_ns = IcOps::now_nanos();
184        let inactive = classify_inactive_session(&session, &authority.snapshot, now_ns);
185        if matches!(inactive, Some(InactiveApplicationSession::Expired { .. })) {
186            record_application_session_expired_observation();
187        }
188        Ok(match inactive {
189            Some(reason) => ApplicationSessionStatus::Inactive(reason),
190            None => ApplicationSessionStatus::Active(session_view(&session)),
191        })
192    }
193
194    /// Return one root-authorized bounded audit page of retained application sessions.
195    pub fn application_session_audit(
196        page: PageRequest,
197    ) -> Result<ApplicationSessionAuditResponse, Error> {
198        let authority = application_session_authority()?;
199        let verifier = AuthOps::auth_proof_verifier_config().map_err(Error::from)?;
200        let sessions = AuthStateOps::application_session_page(page.offset, page.limit)
201            .map_err(map_state_error)?;
202        let now_ns = IcOps::now_nanos();
203        let entries = sessions
204            .entries
205            .into_iter()
206            .map(|session| ApplicationSessionAuditEntry {
207                transport_caller: session.transport_caller(),
208                status: match classify_inactive_session(&session, &authority.snapshot, now_ns) {
209                    Some(reason) => ApplicationSessionStatus::Inactive(reason),
210                    None => ApplicationSessionStatus::Active(session_view(&session)),
211                },
212            })
213            .collect();
214        let minimum_accepted_registry_epoch = verifier
215            .chain_key_root
216            .as_ref()
217            .map(|chain_key| chain_key.policy.min_accepted_registry_epoch);
218        Ok(ApplicationSessionAuditResponse {
219            policy: ApplicationSessionPolicyView {
220                fleet: authority.snapshot.fleet(),
221                role: authority.snapshot.role().clone(),
222                authority_generation: authority.snapshot.generation(),
223                allowed_scopes: authority.config.allowed_scopes,
224                default_session_ttl_secs: authority.config.default_session_ttl_secs,
225                maximum_session_ttl_secs: authority.config.maximum_session_ttl_secs,
226                proof_lifetime_ceiling_ns: MAX_APPLICATION_PROOF_LIFETIME_NS,
227                verifier: ApplicationSessionVerifierPolicyView {
228                    root_canister_id: verifier.root_canister_id,
229                    minimum_accepted_registry_epoch,
230                },
231            },
232            sessions: Page {
233                entries,
234                total: u64::try_from(sessions.total).unwrap_or(u64::MAX),
235            },
236        })
237    }
238}
239
240fn classify_inactive_session(
241    session: &LocalApplicationSession,
242    authority: &LocalApplicationAuthoritySnapshot,
243    now_ns: u64,
244) -> Option<InactiveApplicationSession> {
245    if now_ns >= session.expires_at_ns() {
246        Some(InactiveApplicationSession::Expired {
247            expired_at_ns: session.expires_at_ns(),
248        })
249    } else if session.fleet() != authority.fleet() {
250        Some(InactiveApplicationSession::StaleFleet)
251    } else if session.role() != authority.role() {
252        Some(InactiveApplicationSession::StaleRole)
253    } else if session.authority_generation() != authority.generation() {
254        Some(InactiveApplicationSession::StaleGeneration {
255            session_generation: session.authority_generation(),
256            current_generation: authority.generation(),
257        })
258    } else if validate_application_subject(session.authenticated_subject()).is_err() {
259        Some(InactiveApplicationSession::InadmissibleSubject)
260    } else {
261        None
262    }
263}
264
265fn require_application_caller(caller: crate::cdk::types::Principal) -> Result<(), Error> {
266    validate_application_subject(caller)
267        .map_err(|_| Error::from_registered(codes::AUTHORITY_UNAUTHORIZED))
268}
269
270fn application_session_authority() -> Result<LocalApplicationAuthorizationAuthority, Error> {
271    AuthOps::local_application_authorization_authority()
272        .map_err(Error::from)?
273        .ok_or_else(|| Error::from_registered(codes::SECURITY_INACTIVE))
274}
275
276fn canonical_requested_scopes(scopes: Vec<String>) -> Result<CanonicalApplicationScopes, Error> {
277    let scopes = scopes
278        .into_iter()
279        .map(ApplicationScope::parse)
280        .collect::<Result<Vec<_>, _>>()
281        .map_err(|_| Error::from_registered(codes::REQUEST_INVALID))?;
282    CanonicalApplicationScopes::for_session(scopes)
283        .map_err(|_| Error::from_registered(codes::REQUEST_INVALID))
284}
285
286fn require_configured_scopes(
287    config: &LocalApplicationAuthorizationConfig,
288    requested: &CanonicalApplicationScopes,
289) -> Result<(), Error> {
290    if requested.as_slice().iter().all(|scope| {
291        config
292            .allowed_scopes
293            .binary_search_by(|configured| configured.as_str().cmp(scope.as_str()))
294            .is_ok()
295    }) {
296        Ok(())
297    } else {
298        Err(Error::from_registered(codes::AUTHORITY_CONFLICT))
299    }
300}
301
302fn session_expiry(
303    now_ns: u64,
304    requested_ttl_secs: Option<u64>,
305    config: &LocalApplicationAuthorizationConfig,
306) -> Result<u64, Error> {
307    let default_ttl_ns = config
308        .default_session_ttl_secs
309        .checked_mul(NS_PER_SEC)
310        .ok_or_else(|| Error::from_registered(codes::TIME_CAPACITY))?;
311    let maximum_ttl_ns = config
312        .maximum_session_ttl_secs
313        .checked_mul(NS_PER_SEC)
314        .ok_or_else(|| Error::from_registered(codes::TIME_CAPACITY))?;
315    let requested_ttl_ns = match requested_ttl_secs {
316        Some(ttl) => Some(
317            ttl.checked_mul(NS_PER_SEC)
318                .ok_or_else(|| Error::from_registered(codes::TIME_CAPACITY))?,
319        ),
320        None => None,
321    };
322    resolve_application_session_expiry(now_ns, default_ttl_ns, maximum_ttl_ns, requested_ttl_ns)
323        .map_err(map_ttl_error)
324}
325
326fn require_current_session(
327    session: &LocalApplicationSession,
328    authority: &LocalApplicationAuthoritySnapshot,
329    now_ns: u64,
330) -> Result<(), Error> {
331    if classify_inactive_session(session, authority, now_ns).is_some() {
332        return Err(Error::from_registered(codes::STATE_CONFLICT));
333    }
334    Ok(())
335}
336
337fn session_view(session: &LocalApplicationSession) -> ApplicationSessionView {
338    ApplicationSessionView {
339        authenticated_subject: session.authenticated_subject(),
340        issuer: session.issuer(),
341        scopes: session
342            .scopes()
343            .as_slice()
344            .iter()
345            .map(ToString::to_string)
346            .collect(),
347        established_at_ns: session.established_at_ns(),
348        expires_at_ns: session.expires_at_ns(),
349        authority_generation: session.authority_generation(),
350    }
351}
352
353fn establishment_rejection_reason(error: Error) -> AuthMetricReason {
354    let code = error.code();
355    if code == codes::CAPACITY_LIMIT.raw_code() {
356        AuthMetricReason::Capacity
357    } else if code == codes::STATE_CONFLICT.raw_code() {
358        AuthMetricReason::ReplayConflict
359    } else if code == codes::AUTHORITY_CONFLICT.raw_code()
360        || code == codes::AUTHORITY_UNAUTHORIZED.raw_code()
361        || code == codes::SECURITY_INACTIVE.raw_code()
362    {
363        AuthMetricReason::AuthorityConflict
364    } else if code == codes::REQUEST_INVALID.raw_code() || code == codes::TIME_CAPACITY.raw_code() {
365        AuthMetricReason::InvalidRequest
366    } else if code == codes::SECURITY_INVALID.raw_code()
367        || code == codes::AUTH_CERT_EXPIRED.raw_code()
368        || code == codes::AUTH_TOKEN_EXPIRED.raw_code()
369    {
370        AuthMetricReason::ProofInvalid
371    } else {
372        AuthMetricReason::StateUnavailable
373    }
374}
375
376const fn map_ttl_error(error: ApplicationSessionTtlError) -> Error {
377    match error {
378        ApplicationSessionTtlError::RequestedZero
379        | ApplicationSessionTtlError::RequestedExceedsMaximum => {
380            Error::from_registered(codes::REQUEST_INVALID)
381        }
382        ApplicationSessionTtlError::DefaultZero
383        | ApplicationSessionTtlError::MaximumZero
384        | ApplicationSessionTtlError::DefaultExceedsMaximum
385        | ApplicationSessionTtlError::MaximumExceedsHardLimit { .. } => {
386            Error::from_registered(codes::CONFIGURATION_INVALID)
387        }
388        ApplicationSessionTtlError::ExpiryOverflow => Error::from_registered(codes::TIME_CAPACITY),
389    }
390}
391
392fn map_workflow_error(error: ApplicationSessionWorkflowError) -> Error {
393    match error {
394        ApplicationSessionWorkflowError::ProofIneligible(error) => match error {
395            ApplicationProofEligibilityError::LifetimeTooLong { .. } => {
396                Error::from_registered(codes::TIME_CAPACITY)
397            }
398            ApplicationProofEligibilityError::CallerMismatch
399            | ApplicationProofEligibilityError::InvalidWindow
400            | ApplicationProofEligibilityError::NotCurrentlyValid => {
401                Error::from_registered(codes::SECURITY_INVALID)
402            }
403        },
404        ApplicationSessionWorkflowError::AdmissionDenied(error) => match error {
405            ApplicationSessionAdmissionError::Capacity(limit) => map_capacity_error(limit),
406            ApplicationSessionAdmissionError::ReplayConflict => {
407                Error::from_registered(codes::STATE_CONFLICT)
408            }
409            ApplicationSessionAdmissionError::ScopeNotGranted { .. } => {
410                Error::from_registered(codes::AUTHORITY_CONFLICT)
411            }
412        },
413        ApplicationSessionWorkflowError::ModelInvalid(_) => {
414            Error::from_registered(codes::REQUEST_INVALID)
415        }
416        ApplicationSessionWorkflowError::State(error) => map_state_error(error),
417    }
418}
419
420const fn map_capacity_error(_limit: ApplicationCapacityLimit) -> Error {
421    Error::from_registered(codes::CAPACITY_LIMIT)
422}
423
424fn map_state_error(error: ApplicationSessionStateError) -> Error {
425    match error {
426        ApplicationSessionStateError::ActiveGlobalCapacity
427        | ApplicationSessionStateError::ActiveSubjectCapacity
428        | ApplicationSessionStateError::ReplayGlobalCapacity
429        | ApplicationSessionStateError::ReplaySubjectCapacity
430        | ApplicationSessionStateError::SessionRecordTooLarge
431        | ApplicationSessionStateError::StableStateTooLarge
432        | ApplicationSessionStateError::IndexStateTooLarge => {
433            Error::from_registered(codes::CAPACITY_LIMIT)
434        }
435        ApplicationSessionStateError::AuthorityGenerationMismatch
436        | ApplicationSessionStateError::ReplayAlreadyExists => {
437            Error::from_registered(codes::STATE_CONFLICT)
438        }
439        ApplicationSessionStateError::AuthorityGenerationExhausted => {
440            Error::from_registered(codes::VERSION_CAPACITY)
441        }
442        ApplicationSessionStateError::IndexesUnavailable
443        | ApplicationSessionStateError::DuplicateCaller
444        | ApplicationSessionStateError::DuplicateProofFingerprint
445        | ApplicationSessionStateError::InvalidAuthorityBinding
446        | ApplicationSessionStateError::InvalidSessionRecord
447        | ApplicationSessionStateError::InvalidReplayRecord
448        | ApplicationSessionStateError::SessionReplayMismatch
449        | ApplicationSessionStateError::FutureAuthorityGeneration
450        | ApplicationSessionStateError::EncodingFailed => Error::from(InternalError::unavailable()),
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::{
458        cdk::types::Principal,
459        ids::{CanisterRole, FleetKey},
460        model::auth::application_authorization::ApplicationScope,
461        test::support::fleet_key,
462    };
463
464    fn p(id: u8) -> Principal {
465        Principal::from_slice(&[id; 29])
466    }
467
468    fn session(
469        subject: Principal,
470        fleet: FleetKey,
471        role: CanisterRole,
472        generation: u64,
473        expires_at_ns: u64,
474    ) -> LocalApplicationSession {
475        LocalApplicationSession::new(
476            subject,
477            subject,
478            p(9),
479            fleet,
480            role,
481            CanonicalApplicationScopes::for_session(vec![
482                ApplicationScope::parse("app:read").unwrap(),
483            ])
484            .unwrap(),
485            generation,
486            10,
487            expires_at_ns,
488            [1; 32],
489            [2; 32],
490        )
491        .unwrap()
492    }
493
494    #[test]
495    fn inactive_status_uses_the_frozen_denial_precedence() {
496        let fleet = fleet_key(1);
497        let role = CanisterRole::new("component");
498        let authority = LocalApplicationAuthoritySnapshot::new(fleet, role.clone(), 7);
499
500        assert_eq!(
501            classify_inactive_session(
502                &session(
503                    Principal::anonymous(),
504                    fleet_key(2),
505                    CanisterRole::new("other"),
506                    6,
507                    20,
508                ),
509                &authority,
510                20,
511            ),
512            Some(InactiveApplicationSession::Expired { expired_at_ns: 20 })
513        );
514        assert_eq!(
515            classify_inactive_session(
516                &session(p(1), fleet_key(2), CanisterRole::new("other"), 6, 100),
517                &authority,
518                20,
519            ),
520            Some(InactiveApplicationSession::StaleFleet)
521        );
522        assert_eq!(
523            classify_inactive_session(
524                &session(p(1), fleet, CanisterRole::new("other"), 6, 100),
525                &authority,
526                20,
527            ),
528            Some(InactiveApplicationSession::StaleRole)
529        );
530        assert_eq!(
531            classify_inactive_session(&session(p(1), fleet, role.clone(), 6, 100), &authority, 20),
532            Some(InactiveApplicationSession::StaleGeneration {
533                session_generation: 6,
534                current_generation: 7,
535            })
536        );
537        assert_eq!(
538            classify_inactive_session(
539                &session(Principal::anonymous(), fleet, role.clone(), 7, 100),
540                &authority,
541                20,
542            ),
543            Some(InactiveApplicationSession::InadmissibleSubject)
544        );
545        assert_eq!(
546            classify_inactive_session(&session(p(1), fleet, role, 7, 100), &authority, 20),
547            None
548        );
549    }
550
551    #[test]
552    fn configured_scope_and_ttl_policy_are_enforced_at_the_adapter_boundary() {
553        let config = LocalApplicationAuthorizationConfig {
554            allowed_scopes: vec!["app:read".to_string(), "app:write".to_string()],
555            default_session_ttl_secs: 10,
556            maximum_session_ttl_secs: 20,
557        };
558        let read = CanonicalApplicationScopes::for_session(vec![
559            ApplicationScope::parse("app:read").unwrap(),
560        ])
561        .unwrap();
562        let admin = CanonicalApplicationScopes::for_session(vec![
563            ApplicationScope::parse("app:admin").unwrap(),
564        ])
565        .unwrap();
566
567        assert!(require_configured_scopes(&config, &read).is_ok());
568        assert!(require_configured_scopes(&config, &admin).is_err());
569        assert_eq!(session_expiry(100, None, &config).unwrap(), 10_000_000_100);
570        assert_eq!(
571            session_expiry(100, Some(20), &config).unwrap(),
572            20_000_000_100
573        );
574        assert!(session_expiry(100, Some(0), &config).is_err());
575        assert!(session_expiry(100, Some(21), &config).is_err());
576    }
577}