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 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 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 #[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 #[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 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 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 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}