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