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 issued_at_ns = issued_at
60            .checked_mul(1_000_000_000)
61            .ok_or_else(|| Error::invalid("current time overflows nanoseconds"))?;
62        let max_ttl_ns = Self::delegated_token_max_ttl_ns()?;
63        let verified_subject = Self::verify_token_material(
64            &bootstrap_token,
65            max_ttl_ns,
66            max_ttl_ns,
67            &[],
68            issued_at_ns,
69        )
70        .inspect_err(|_| record_session_bootstrap_rejected_token_invalid())?;
71
72        if verified_subject != delegated_subject {
73            record_session_bootstrap_rejected_subject_mismatch();
74            return Err(Error::forbidden(format!(
75                "delegated session subject mismatch: requested={delegated_subject} token_subject={verified_subject}"
76            )));
77        }
78
79        let configured_max_ttl_secs = cfg
80            .max_ttl_secs
81            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
82        let token_expires_at_secs = bootstrap_token.claims.expires_at_ns / 1_000_000_000;
83        let expires_at = Self::clamp_delegated_session_expires_at(
84            issued_at,
85            token_expires_at_secs,
86            configured_max_ttl_secs,
87            requested_ttl_secs,
88        )
89        .inspect_err(|_| record_session_bootstrap_rejected_ttl_invalid())?;
90
91        let token_fingerprint =
92            Self::delegated_session_bootstrap_token_fingerprint(&bootstrap_token)
93                .inspect_err(|_| record_session_bootstrap_rejected_token_invalid())?;
94
95        if Self::enforce_bootstrap_replay_policy(
96            wallet_caller,
97            delegated_subject,
98            token_fingerprint,
99            issued_at,
100        )? {
101            return Ok(());
102        }
103
104        let had_active_session =
105            AuthStateOps::delegated_session(wallet_caller, issued_at).is_some();
106
107        let upsert_result = AuthStateOps::upsert_delegated_session_with_bootstrap_binding(
108            DelegatedSession {
109                wallet_pid: wallet_caller,
110                delegated_pid: delegated_subject,
111                issued_at,
112                expires_at,
113                bootstrap_token_fingerprint: Some(token_fingerprint),
114            },
115            DelegatedSessionBootstrapBinding {
116                wallet_pid: wallet_caller,
117                delegated_pid: delegated_subject,
118                token_fingerprint,
119                bound_at: issued_at,
120                expires_at: token_expires_at_secs,
121            },
122            issued_at,
123        );
124        if !matches!(upsert_result, DelegatedSessionUpsertResult::Upserted) {
125            record_session_bootstrap_rejected_capacity();
126            return Err(Error::exhausted(delegated_session_capacity_message(
127                upsert_result,
128            )));
129        }
130
131        if had_active_session {
132            record_session_replaced();
133        } else {
134            record_session_created();
135        }
136
137        Ok(())
138    }
139
140    /// Remove the caller's delegated session subject.
141    pub fn clear_delegated_session() {
142        let wallet_caller = IcOps::msg_caller();
143        let had_active_session =
144            AuthStateOps::delegated_session(wallet_caller, IcOps::now_secs()).is_some();
145        AuthStateOps::clear_delegated_session(wallet_caller);
146        if had_active_session {
147            record_session_cleared();
148        }
149    }
150
151    /// Read the caller's active delegated session subject, if configured.
152    #[must_use]
153    pub fn delegated_session_subject() -> Option<Principal> {
154        let wallet_caller = IcOps::msg_caller();
155        AuthStateOps::delegated_session_subject(wallet_caller, IcOps::now_secs())
156    }
157
158    /// Prune all currently expired delegated sessions.
159    #[must_use]
160    pub fn prune_expired_delegated_sessions() -> usize {
161        let now_secs = IcOps::now_secs();
162        let removed = AuthStateOps::prune_expired_delegated_sessions(now_secs);
163        let _ = AuthStateOps::prune_expired_delegated_session_bootstrap_bindings(now_secs);
164        if removed > 0 {
165            record_session_pruned(removed);
166        }
167        removed
168    }
169
170    // Fingerprint a bootstrap token for replay protection and idempotence checks.
171    fn delegated_session_bootstrap_token_fingerprint(
172        token: &DelegatedToken,
173    ) -> Result<[u8; 32], Error> {
174        let token_bytes = crate::cdk::candid::encode_one(token).map_err(|err| {
175            Error::internal(format!("bootstrap token fingerprint encode failed: {err}"))
176        })?;
177        let mut hasher = Sha256::new();
178        hasher.update(Self::SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN);
179        hasher.update(token_bytes);
180        Ok(hasher.finalize().into())
181    }
182
183    // Enforce replay policy for delegated-session bootstrap by token fingerprint.
184    fn enforce_bootstrap_replay_policy(
185        wallet_caller: Principal,
186        delegated_subject: Principal,
187        token_fingerprint: [u8; 32],
188        issued_at: u64,
189    ) -> Result<bool, Error> {
190        let Some(binding) =
191            AuthStateOps::delegated_session_bootstrap_binding(token_fingerprint, issued_at)
192        else {
193            return Ok(false);
194        };
195
196        if binding.wallet_pid == wallet_caller && binding.delegated_pid == delegated_subject {
197            let active_same_session = AuthStateOps::delegated_session(wallet_caller, issued_at)
198                .is_some_and(|session| {
199                    session.delegated_pid == delegated_subject
200                        && session.bootstrap_token_fingerprint == Some(token_fingerprint)
201                });
202
203            if active_same_session {
204                record_session_bootstrap_replay_idempotent();
205                return Ok(true);
206            }
207
208            record_session_bootstrap_rejected_replay_reused();
209            return Err(Error::forbidden(
210                "delegated session bootstrap token replay rejected; use a fresh token",
211            ));
212        }
213
214        record_session_bootstrap_rejected_replay_conflict();
215        Err(Error::forbidden(format!(
216            "delegated session bootstrap token already bound (wallet={} delegated_subject={})",
217            binding.wallet_pid, binding.delegated_pid
218        )))
219    }
220
221    // Clamp delegated-session lifetime against token expiry, config, and request TTL.
222    pub(super) fn clamp_delegated_session_expires_at(
223        now_secs: u64,
224        token_expires_at: u64,
225        configured_max_ttl_secs: u64,
226        requested_ttl_secs: Option<u64>,
227    ) -> Result<u64, Error> {
228        match AuthOps::clamp_delegated_session_expires_at(
229            now_secs,
230            token_expires_at,
231            configured_max_ttl_secs,
232            requested_ttl_secs,
233        ) {
234            DelegatedSessionExpiryClamp::Accepted(expires_at) => Ok(expires_at),
235            DelegatedSessionExpiryClamp::InvalidConfiguredMaxTtl => Err(Error::invariant(
236                "delegated session configured max ttl_secs must be greater than zero",
237            )),
238            DelegatedSessionExpiryClamp::InvalidRequestedTtl => Err(Error::invalid(
239                "delegated session requested ttl_secs must be greater than zero",
240            )),
241            DelegatedSessionExpiryClamp::ExpiredToken => Err(Error::forbidden(
242                "delegated session bootstrap token is expired",
243            )),
244        }
245    }
246}
247
248fn delegated_session_capacity_message(result: DelegatedSessionUpsertResult) -> String {
249    match result {
250        DelegatedSessionUpsertResult::Upserted => {
251            "delegated session state was already updated".to_string()
252        }
253        DelegatedSessionUpsertResult::SessionCapacityReached { capacity } => {
254            format!("delegated session capacity reached ({capacity})")
255        }
256        DelegatedSessionUpsertResult::SessionSubjectCapacityReached {
257            delegated_pid,
258            capacity,
259        } => format!("delegated session subject capacity reached for {delegated_pid} ({capacity})"),
260        DelegatedSessionUpsertResult::BootstrapBindingCapacityReached { capacity } => {
261            format!("delegated session bootstrap binding capacity reached ({capacity})")
262        }
263        DelegatedSessionUpsertResult::BootstrapBindingSubjectCapacityReached {
264            delegated_pid,
265            capacity,
266        } => format!(
267            "delegated session bootstrap binding subject capacity reached for {delegated_pid} ({capacity})"
268        ),
269    }
270}