Skip to main content

canic_core/api/auth/session/
mod.rs

1use super::AuthApi;
2use crate::{
3    access::auth::validate_delegated_session_subject,
4    cdk::types::Principal,
5    dto::{auth::DelegatedToken, error::Error},
6    ops::{
7        auth::{AuthOps, DelegatedSessionExpiryClamp},
8        config::ConfigOps,
9        ic::IcOps,
10        runtime::metrics::auth::{
11            record_session_bootstrap_rejected_capacity, record_session_bootstrap_rejected_disabled,
12            record_session_bootstrap_rejected_replay_conflict,
13            record_session_bootstrap_rejected_replay_reused,
14            record_session_bootstrap_rejected_subject_mismatch,
15            record_session_bootstrap_rejected_subject_rejected,
16            record_session_bootstrap_rejected_token_invalid,
17            record_session_bootstrap_rejected_ttl_invalid,
18            record_session_bootstrap_rejected_wallet_caller_rejected,
19            record_session_bootstrap_replay_idempotent, record_session_cleared,
20            record_session_created, record_session_pruned, record_session_replaced,
21        },
22        storage::auth::{
23            AuthStateOps, DelegatedSession, DelegatedSessionBootstrapBinding,
24            DelegatedSessionUpsertResult,
25        },
26    },
27};
28use sha2::{Digest, Sha256};
29
30impl AuthApi {
31    /// Persist a temporary delegated session subject for the caller wallet.
32    pub fn set_delegated_session_subject(
33        delegated_subject: Principal,
34        bootstrap_token: DelegatedToken,
35        requested_ttl_secs: Option<u64>,
36    ) -> Result<(), Error> {
37        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
38        if !cfg.enabled {
39            record_session_bootstrap_rejected_disabled();
40            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
41        }
42
43        let wallet_caller = IcOps::msg_caller();
44        if let Err(reason) = validate_delegated_session_subject(wallet_caller) {
45            record_session_bootstrap_rejected_wallet_caller_rejected();
46            return Err(Error::forbidden(format!(
47                "delegated session wallet caller rejected: {reason}"
48            )));
49        }
50
51        if let Err(reason) = validate_delegated_session_subject(delegated_subject) {
52            record_session_bootstrap_rejected_subject_rejected();
53            return Err(Error::forbidden(format!(
54                "delegated session subject rejected: {reason}"
55            )));
56        }
57
58        let issued_at = IcOps::now_secs();
59        let max_ttl_secs = Self::delegated_token_max_ttl_secs()?;
60        let verified_subject = Self::verify_token_material(
61            &bootstrap_token,
62            max_ttl_secs,
63            max_ttl_secs,
64            &[],
65            issued_at,
66        )
67        .inspect_err(|_| record_session_bootstrap_rejected_token_invalid())?;
68
69        if verified_subject != delegated_subject {
70            record_session_bootstrap_rejected_subject_mismatch();
71            return Err(Error::forbidden(format!(
72                "delegated session subject mismatch: requested={delegated_subject} token_subject={verified_subject}"
73            )));
74        }
75
76        let configured_max_ttl_secs = cfg
77            .max_ttl_secs
78            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
79        let expires_at = Self::clamp_delegated_session_expires_at(
80            issued_at,
81            bootstrap_token.claims.expires_at,
82            configured_max_ttl_secs,
83            requested_ttl_secs,
84        )
85        .inspect_err(|_| record_session_bootstrap_rejected_ttl_invalid())?;
86
87        let token_fingerprint =
88            Self::delegated_session_bootstrap_token_fingerprint(&bootstrap_token)
89                .inspect_err(|_| record_session_bootstrap_rejected_token_invalid())?;
90
91        if Self::enforce_bootstrap_replay_policy(
92            wallet_caller,
93            delegated_subject,
94            token_fingerprint,
95            issued_at,
96        )? {
97            return Ok(());
98        }
99
100        let had_active_session =
101            AuthStateOps::delegated_session(wallet_caller, issued_at).is_some();
102
103        let upsert_result = AuthStateOps::upsert_delegated_session_with_bootstrap_binding(
104            DelegatedSession {
105                wallet_pid: wallet_caller,
106                delegated_pid: delegated_subject,
107                issued_at,
108                expires_at,
109                bootstrap_token_fingerprint: Some(token_fingerprint),
110            },
111            DelegatedSessionBootstrapBinding {
112                wallet_pid: wallet_caller,
113                delegated_pid: delegated_subject,
114                token_fingerprint,
115                bound_at: issued_at,
116                expires_at: bootstrap_token.claims.expires_at,
117            },
118            issued_at,
119        );
120        if !matches!(upsert_result, DelegatedSessionUpsertResult::Upserted) {
121            record_session_bootstrap_rejected_capacity();
122            return Err(Error::exhausted(delegated_session_capacity_message(
123                upsert_result,
124            )));
125        }
126
127        if had_active_session {
128            record_session_replaced();
129        } else {
130            record_session_created();
131        }
132
133        Ok(())
134    }
135
136    /// Remove the caller's delegated session subject.
137    pub fn clear_delegated_session() {
138        let wallet_caller = IcOps::msg_caller();
139        let had_active_session =
140            AuthStateOps::delegated_session(wallet_caller, IcOps::now_secs()).is_some();
141        AuthStateOps::clear_delegated_session(wallet_caller);
142        if had_active_session {
143            record_session_cleared();
144        }
145    }
146
147    /// Read the caller's active delegated session subject, if configured.
148    #[must_use]
149    pub fn delegated_session_subject() -> Option<Principal> {
150        let wallet_caller = IcOps::msg_caller();
151        AuthStateOps::delegated_session_subject(wallet_caller, IcOps::now_secs())
152    }
153
154    /// Prune all currently expired delegated sessions.
155    #[must_use]
156    pub fn prune_expired_delegated_sessions() -> usize {
157        let now_secs = IcOps::now_secs();
158        let removed = AuthStateOps::prune_expired_delegated_sessions(now_secs);
159        let _ = AuthStateOps::prune_expired_delegated_session_bootstrap_bindings(now_secs);
160        if removed > 0 {
161            record_session_pruned(removed);
162        }
163        removed
164    }
165
166    // Fingerprint a bootstrap token for replay protection and idempotence checks.
167    fn delegated_session_bootstrap_token_fingerprint(
168        token: &DelegatedToken,
169    ) -> Result<[u8; 32], Error> {
170        let token_bytes = crate::cdk::candid::encode_one(token).map_err(|err| {
171            Error::internal(format!("bootstrap token fingerprint encode failed: {err}"))
172        })?;
173        let mut hasher = Sha256::new();
174        hasher.update(Self::SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN);
175        hasher.update(token_bytes);
176        Ok(hasher.finalize().into())
177    }
178
179    // Enforce replay policy for delegated-session bootstrap by token fingerprint.
180    fn enforce_bootstrap_replay_policy(
181        wallet_caller: Principal,
182        delegated_subject: Principal,
183        token_fingerprint: [u8; 32],
184        issued_at: u64,
185    ) -> Result<bool, Error> {
186        let Some(binding) =
187            AuthStateOps::delegated_session_bootstrap_binding(token_fingerprint, issued_at)
188        else {
189            return Ok(false);
190        };
191
192        if binding.wallet_pid == wallet_caller && binding.delegated_pid == delegated_subject {
193            let active_same_session = AuthStateOps::delegated_session(wallet_caller, issued_at)
194                .is_some_and(|session| {
195                    session.delegated_pid == delegated_subject
196                        && session.bootstrap_token_fingerprint == Some(token_fingerprint)
197                });
198
199            if active_same_session {
200                record_session_bootstrap_replay_idempotent();
201                return Ok(true);
202            }
203
204            record_session_bootstrap_rejected_replay_reused();
205            return Err(Error::forbidden(
206                "delegated session bootstrap token replay rejected; use a fresh token",
207            ));
208        }
209
210        record_session_bootstrap_rejected_replay_conflict();
211        Err(Error::forbidden(format!(
212            "delegated session bootstrap token already bound (wallet={} delegated_subject={})",
213            binding.wallet_pid, binding.delegated_pid
214        )))
215    }
216
217    // Clamp delegated-session lifetime against token expiry, config, and request TTL.
218    pub(super) fn clamp_delegated_session_expires_at(
219        now_secs: u64,
220        token_expires_at: u64,
221        configured_max_ttl_secs: u64,
222        requested_ttl_secs: Option<u64>,
223    ) -> Result<u64, Error> {
224        match AuthOps::clamp_delegated_session_expires_at(
225            now_secs,
226            token_expires_at,
227            configured_max_ttl_secs,
228            requested_ttl_secs,
229        ) {
230            DelegatedSessionExpiryClamp::Accepted(expires_at) => Ok(expires_at),
231            DelegatedSessionExpiryClamp::InvalidConfiguredMaxTtl => Err(Error::invariant(
232                "delegated session configured max ttl_secs must be greater than zero",
233            )),
234            DelegatedSessionExpiryClamp::InvalidRequestedTtl => Err(Error::invalid(
235                "delegated session requested ttl_secs must be greater than zero",
236            )),
237            DelegatedSessionExpiryClamp::ExpiredToken => Err(Error::forbidden(
238                "delegated session bootstrap token is expired",
239            )),
240        }
241    }
242}
243
244fn delegated_session_capacity_message(result: DelegatedSessionUpsertResult) -> String {
245    match result {
246        DelegatedSessionUpsertResult::Upserted => {
247            "delegated session state was already updated".to_string()
248        }
249        DelegatedSessionUpsertResult::SessionCapacityReached { capacity } => {
250            format!("delegated session capacity reached ({capacity})")
251        }
252        DelegatedSessionUpsertResult::SessionSubjectCapacityReached {
253            delegated_pid,
254            capacity,
255        } => format!("delegated session subject capacity reached for {delegated_pid} ({capacity})"),
256        DelegatedSessionUpsertResult::BootstrapBindingCapacityReached { capacity } => {
257            format!("delegated session bootstrap binding capacity reached ({capacity})")
258        }
259        DelegatedSessionUpsertResult::BootstrapBindingSubjectCapacityReached {
260            delegated_pid,
261            capacity,
262        } => format!(
263            "delegated session bootstrap binding subject capacity reached for {delegated_pid} ({capacity})"
264        ),
265    }
266}