Skip to main content

canic_core/api/auth/session/
mod.rs

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