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