canic_core/api/auth/session/
mod.rs1use 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 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 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 #[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 #[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 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 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 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 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}