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            LocalApplicationAuthorizationStateOps,
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 = LocalApplicationAuthorizationStateOps::clear_application_session(caller)
167            .map_err(map_state_error)?;
168        record_application_session_clear(removed);
169        IntentCleanupWorkflow::reconcile_after_terminal();
170        Ok(ApplicationSessionCommandResponse::Cleared)
171    }
172
173    /// Return the current caller's read-only application-session classification.
174    pub fn application_session_status() -> Result<ApplicationSessionStatus, Error> {
175        let caller = IcOps::msg_caller();
176        require_application_caller(caller)?;
177        let authority = application_session_authority()?;
178        let Some(session) = LocalApplicationAuthorizationStateOps::application_session(caller)
179            .map_err(map_state_error)?
180        else {
181            return Ok(ApplicationSessionStatus::Inactive(
182                InactiveApplicationSession::Missing,
183            ));
184        };
185        let now_ns = IcOps::now_nanos();
186        let inactive = classify_inactive_session(&session, &authority.snapshot, now_ns);
187        if matches!(inactive, Some(InactiveApplicationSession::Expired { .. })) {
188            record_application_session_expired_observation();
189        }
190        Ok(match inactive {
191            Some(reason) => ApplicationSessionStatus::Inactive(reason),
192            None => ApplicationSessionStatus::Active(session_view(&session)),
193        })
194    }
195
196    /// Return one root-authorized bounded audit page of retained application sessions.
197    pub fn application_session_audit(
198        page: PageRequest,
199    ) -> Result<ApplicationSessionAuditResponse, Error> {
200        let authority = application_session_authority()?;
201        let verifier = AuthOps::auth_proof_verifier_config().map_err(Error::from)?;
202        let sessions = LocalApplicationAuthorizationStateOps::application_session_page(
203            page.offset,
204            page.limit,
205        )
206        .map_err(map_state_error)?;
207        let now_ns = IcOps::now_nanos();
208        let entries = sessions
209            .entries
210            .into_iter()
211            .map(|session| ApplicationSessionAuditEntry {
212                transport_caller: session.transport_caller(),
213                status: match classify_inactive_session(&session, &authority.snapshot, now_ns) {
214                    Some(reason) => ApplicationSessionStatus::Inactive(reason),
215                    None => ApplicationSessionStatus::Active(session_view(&session)),
216                },
217            })
218            .collect();
219        let minimum_accepted_registry_epoch = verifier
220            .chain_key_root
221            .as_ref()
222            .map(|chain_key| chain_key.policy.min_accepted_registry_epoch);
223        Ok(ApplicationSessionAuditResponse {
224            policy: ApplicationSessionPolicyView {
225                fleet: authority.snapshot.fleet(),
226                role: authority.snapshot.role().clone(),
227                authority_generation: authority.snapshot.generation(),
228                allowed_scopes: authority.config.allowed_scopes,
229                default_session_ttl_secs: authority.config.default_session_ttl_secs,
230                maximum_session_ttl_secs: authority.config.maximum_session_ttl_secs,
231                proof_lifetime_ceiling_ns: MAX_APPLICATION_PROOF_LIFETIME_NS,
232                verifier: ApplicationSessionVerifierPolicyView {
233                    root_canister_id: verifier.root_canister_id,
234                    minimum_accepted_registry_epoch,
235                },
236            },
237            sessions: Page {
238                entries,
239                total: u64::try_from(sessions.total).unwrap_or(u64::MAX),
240            },
241        })
242    }
243}
244
245fn classify_inactive_session(
246    session: &LocalApplicationSession,
247    authority: &LocalApplicationAuthoritySnapshot,
248    now_ns: u64,
249) -> Option<InactiveApplicationSession> {
250    if now_ns >= session.expires_at_ns() {
251        Some(InactiveApplicationSession::Expired {
252            expired_at_ns: session.expires_at_ns(),
253        })
254    } else if session.fleet() != authority.fleet() {
255        Some(InactiveApplicationSession::StaleFleet)
256    } else if session.role() != authority.role() {
257        Some(InactiveApplicationSession::StaleRole)
258    } else if session.authority_generation() != authority.generation() {
259        Some(InactiveApplicationSession::StaleGeneration {
260            session_generation: session.authority_generation(),
261            current_generation: authority.generation(),
262        })
263    } else if validate_application_subject(session.authenticated_subject()).is_err() {
264        Some(InactiveApplicationSession::InadmissibleSubject)
265    } else {
266        None
267    }
268}
269
270fn require_application_caller(caller: crate::cdk::types::Principal) -> Result<(), Error> {
271    validate_application_subject(caller)
272        .map_err(|_| Error::from_registered(codes::AUTHORITY_UNAUTHORIZED))
273}
274
275fn application_session_authority() -> Result<LocalApplicationAuthorizationAuthority, Error> {
276    AuthOps::local_application_authorization_authority()
277        .map_err(Error::from)?
278        .ok_or_else(|| Error::from_registered(codes::SECURITY_INACTIVE))
279}
280
281fn canonical_requested_scopes(scopes: Vec<String>) -> Result<CanonicalApplicationScopes, Error> {
282    let scopes = scopes
283        .into_iter()
284        .map(ApplicationScope::parse)
285        .collect::<Result<Vec<_>, _>>()
286        .map_err(|_| Error::from_registered(codes::REQUEST_INVALID))?;
287    CanonicalApplicationScopes::for_session(scopes)
288        .map_err(|_| Error::from_registered(codes::REQUEST_INVALID))
289}
290
291fn require_configured_scopes(
292    config: &LocalApplicationAuthorizationConfig,
293    requested: &CanonicalApplicationScopes,
294) -> Result<(), Error> {
295    if requested.as_slice().iter().all(|scope| {
296        config
297            .allowed_scopes
298            .binary_search_by(|configured| configured.as_str().cmp(scope.as_str()))
299            .is_ok()
300    }) {
301        Ok(())
302    } else {
303        Err(Error::from_registered(codes::AUTHORITY_CONFLICT))
304    }
305}
306
307fn session_expiry(
308    now_ns: u64,
309    requested_ttl_secs: Option<u64>,
310    config: &LocalApplicationAuthorizationConfig,
311) -> Result<u64, Error> {
312    let default_ttl_ns = config
313        .default_session_ttl_secs
314        .checked_mul(NS_PER_SEC)
315        .ok_or_else(|| Error::from_registered(codes::TIME_CAPACITY))?;
316    let maximum_ttl_ns = config
317        .maximum_session_ttl_secs
318        .checked_mul(NS_PER_SEC)
319        .ok_or_else(|| Error::from_registered(codes::TIME_CAPACITY))?;
320    let requested_ttl_ns = match requested_ttl_secs {
321        Some(ttl) => Some(
322            ttl.checked_mul(NS_PER_SEC)
323                .ok_or_else(|| Error::from_registered(codes::TIME_CAPACITY))?,
324        ),
325        None => None,
326    };
327    resolve_application_session_expiry(now_ns, default_ttl_ns, maximum_ttl_ns, requested_ttl_ns)
328        .map_err(map_ttl_error)
329}
330
331fn require_current_session(
332    session: &LocalApplicationSession,
333    authority: &LocalApplicationAuthoritySnapshot,
334    now_ns: u64,
335) -> Result<(), Error> {
336    if classify_inactive_session(session, authority, now_ns).is_some() {
337        return Err(Error::from_registered(codes::STATE_CONFLICT));
338    }
339    Ok(())
340}
341
342fn session_view(session: &LocalApplicationSession) -> ApplicationSessionView {
343    ApplicationSessionView {
344        authenticated_subject: session.authenticated_subject(),
345        issuer: session.issuer(),
346        scopes: session
347            .scopes()
348            .as_slice()
349            .iter()
350            .map(ToString::to_string)
351            .collect(),
352        established_at_ns: session.established_at_ns(),
353        expires_at_ns: session.expires_at_ns(),
354        authority_generation: session.authority_generation(),
355    }
356}
357
358fn establishment_rejection_reason(error: Error) -> AuthMetricReason {
359    let code = error.code();
360    if code == codes::CAPACITY_LIMIT.raw_code() {
361        AuthMetricReason::Capacity
362    } else if code == codes::STATE_CONFLICT.raw_code() {
363        AuthMetricReason::ReplayConflict
364    } else if code == codes::AUTHORITY_CONFLICT.raw_code()
365        || code == codes::AUTHORITY_UNAUTHORIZED.raw_code()
366        || code == codes::SECURITY_INACTIVE.raw_code()
367    {
368        AuthMetricReason::AuthorityConflict
369    } else if code == codes::REQUEST_INVALID.raw_code() || code == codes::TIME_CAPACITY.raw_code() {
370        AuthMetricReason::InvalidRequest
371    } else if code == codes::SECURITY_INVALID.raw_code()
372        || code == codes::AUTH_CERT_EXPIRED.raw_code()
373        || code == codes::AUTH_TOKEN_EXPIRED.raw_code()
374    {
375        AuthMetricReason::ProofInvalid
376    } else {
377        AuthMetricReason::StateUnavailable
378    }
379}
380
381const fn map_ttl_error(error: ApplicationSessionTtlError) -> Error {
382    match error {
383        ApplicationSessionTtlError::RequestedZero
384        | ApplicationSessionTtlError::RequestedExceedsMaximum => {
385            Error::from_registered(codes::REQUEST_INVALID)
386        }
387        ApplicationSessionTtlError::DefaultZero
388        | ApplicationSessionTtlError::MaximumZero
389        | ApplicationSessionTtlError::DefaultExceedsMaximum
390        | ApplicationSessionTtlError::MaximumExceedsHardLimit { .. } => {
391            Error::from_registered(codes::CONFIGURATION_INVALID)
392        }
393        ApplicationSessionTtlError::ExpiryOverflow => Error::from_registered(codes::TIME_CAPACITY),
394    }
395}
396
397fn map_workflow_error(error: ApplicationSessionWorkflowError) -> Error {
398    match error {
399        ApplicationSessionWorkflowError::ProofIneligible(error) => match error {
400            ApplicationProofEligibilityError::LifetimeTooLong { .. } => {
401                Error::from_registered(codes::TIME_CAPACITY)
402            }
403            ApplicationProofEligibilityError::CallerMismatch
404            | ApplicationProofEligibilityError::InvalidWindow
405            | ApplicationProofEligibilityError::NotCurrentlyValid => {
406                Error::from_registered(codes::SECURITY_INVALID)
407            }
408        },
409        ApplicationSessionWorkflowError::AdmissionDenied(error) => match error {
410            ApplicationSessionAdmissionError::Capacity(limit) => map_capacity_error(limit),
411            ApplicationSessionAdmissionError::ReplayConflict => {
412                Error::from_registered(codes::STATE_CONFLICT)
413            }
414            ApplicationSessionAdmissionError::ScopeNotGranted { .. } => {
415                Error::from_registered(codes::AUTHORITY_CONFLICT)
416            }
417        },
418        ApplicationSessionWorkflowError::ModelInvalid(_) => {
419            Error::from_registered(codes::REQUEST_INVALID)
420        }
421        ApplicationSessionWorkflowError::State(error) => map_state_error(error),
422    }
423}
424
425const fn map_capacity_error(_limit: ApplicationCapacityLimit) -> Error {
426    Error::from_registered(codes::CAPACITY_LIMIT)
427}
428
429fn map_state_error(error: ApplicationSessionStateError) -> Error {
430    match error {
431        ApplicationSessionStateError::ActiveGlobalCapacity
432        | ApplicationSessionStateError::ActiveSubjectCapacity
433        | ApplicationSessionStateError::ReplayGlobalCapacity
434        | ApplicationSessionStateError::ReplaySubjectCapacity
435        | ApplicationSessionStateError::SessionRecordTooLarge
436        | ApplicationSessionStateError::StableStateTooLarge
437        | ApplicationSessionStateError::IndexStateTooLarge => {
438            Error::from_registered(codes::CAPACITY_LIMIT)
439        }
440        ApplicationSessionStateError::AuthorityGenerationMismatch
441        | ApplicationSessionStateError::ReplayAlreadyExists => {
442            Error::from_registered(codes::STATE_CONFLICT)
443        }
444        ApplicationSessionStateError::AuthorityGenerationExhausted => {
445            Error::from_registered(codes::VERSION_CAPACITY)
446        }
447        ApplicationSessionStateError::IndexesUnavailable
448        | ApplicationSessionStateError::DuplicateCaller
449        | ApplicationSessionStateError::DuplicateProofFingerprint
450        | ApplicationSessionStateError::InvalidAuthorityBinding
451        | ApplicationSessionStateError::InvalidSessionRecord
452        | ApplicationSessionStateError::InvalidReplayRecord
453        | ApplicationSessionStateError::SessionReplayMismatch
454        | ApplicationSessionStateError::FutureAuthorityGeneration
455        | ApplicationSessionStateError::EncodingFailed => Error::from(InternalError::unavailable()),
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::{
463        cdk::types::Principal,
464        ids::{CanisterRole, FleetKey},
465        model::auth::application_authorization::ApplicationScope,
466        test::support::fleet_key,
467    };
468
469    fn p(id: u8) -> Principal {
470        Principal::from_slice(&[id; 29])
471    }
472
473    fn session(
474        subject: Principal,
475        fleet: FleetKey,
476        role: CanisterRole,
477        generation: u64,
478        expires_at_ns: u64,
479    ) -> LocalApplicationSession {
480        LocalApplicationSession::new(
481            subject,
482            subject,
483            p(9),
484            fleet,
485            role,
486            CanonicalApplicationScopes::for_session(vec![
487                ApplicationScope::parse("app:read").unwrap(),
488            ])
489            .unwrap(),
490            generation,
491            10,
492            expires_at_ns,
493            [1; 32],
494            [2; 32],
495        )
496        .unwrap()
497    }
498
499    #[test]
500    fn inactive_status_uses_the_frozen_denial_precedence() {
501        let fleet = fleet_key(1);
502        let role = CanisterRole::new("component");
503        let authority = LocalApplicationAuthoritySnapshot::new(fleet, role.clone(), 7);
504
505        assert_eq!(
506            classify_inactive_session(
507                &session(
508                    Principal::anonymous(),
509                    fleet_key(2),
510                    CanisterRole::new("other"),
511                    6,
512                    20,
513                ),
514                &authority,
515                20,
516            ),
517            Some(InactiveApplicationSession::Expired { expired_at_ns: 20 })
518        );
519        assert_eq!(
520            classify_inactive_session(
521                &session(p(1), fleet_key(2), CanisterRole::new("other"), 6, 100),
522                &authority,
523                20,
524            ),
525            Some(InactiveApplicationSession::StaleFleet)
526        );
527        assert_eq!(
528            classify_inactive_session(
529                &session(p(1), fleet, CanisterRole::new("other"), 6, 100),
530                &authority,
531                20,
532            ),
533            Some(InactiveApplicationSession::StaleRole)
534        );
535        assert_eq!(
536            classify_inactive_session(&session(p(1), fleet, role.clone(), 6, 100), &authority, 20),
537            Some(InactiveApplicationSession::StaleGeneration {
538                session_generation: 6,
539                current_generation: 7,
540            })
541        );
542        assert_eq!(
543            classify_inactive_session(
544                &session(Principal::anonymous(), fleet, role.clone(), 7, 100),
545                &authority,
546                20,
547            ),
548            Some(InactiveApplicationSession::InadmissibleSubject)
549        );
550        assert_eq!(
551            classify_inactive_session(&session(p(1), fleet, role, 7, 100), &authority, 20),
552            None
553        );
554    }
555
556    #[test]
557    fn configured_scope_and_ttl_policy_are_enforced_at_the_adapter_boundary() {
558        let config = LocalApplicationAuthorizationConfig {
559            allowed_scopes: vec!["app:read".to_string(), "app:write".to_string()],
560            default_session_ttl_secs: 10,
561            maximum_session_ttl_secs: 20,
562        };
563        let read = CanonicalApplicationScopes::for_session(vec![
564            ApplicationScope::parse("app:read").unwrap(),
565        ])
566        .unwrap();
567        let admin = CanonicalApplicationScopes::for_session(vec![
568            ApplicationScope::parse("app:admin").unwrap(),
569        ])
570        .unwrap();
571
572        assert!(require_configured_scopes(&config, &read).is_ok());
573        assert!(require_configured_scopes(&config, &admin).is_err());
574        assert_eq!(session_expiry(100, None, &config).unwrap(), 10_000_000_100);
575        assert_eq!(
576            session_expiry(100, Some(20), &config).unwrap(),
577            20_000_000_100
578        );
579        assert!(session_expiry(100, Some(0), &config).is_err());
580        assert!(session_expiry(100, Some(21), &config).is_err());
581    }
582}