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