1use crate::{
16 access::AccessError,
17 cdk::{
18 api::{canister_self, is_controller as caller_is_controller, msg_arg_data},
19 candid::de::IDLDeserialize,
20 types::Principal,
21 },
22 config::Config,
23 dto::auth::DelegatedToken,
24 ids::CanisterRole,
25 ops::{
26 auth::{DelegatedTokenOps, VerifiedDelegatedToken},
27 ic::IcOps,
28 runtime::env::EnvOps,
29 runtime::metrics::auth::{
30 record_session_fallback_invalid_subject, record_session_fallback_raw_caller,
31 },
32 storage::{
33 auth::DelegationStateOps, children::CanisterChildrenOps,
34 registry::subnet::SubnetRegistryOps,
35 },
36 },
37};
38use std::fmt;
39
40const MAX_INGRESS_BYTES: usize = 64 * 1024; pub type Role = CanisterRole;
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum AuthenticatedIdentitySource {
50 RawCaller,
51 DelegatedSession,
52}
53
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub struct ResolvedAuthenticatedIdentity {
60 pub transport_caller: Principal,
61 pub authenticated_subject: Principal,
62 pub identity_source: AuthenticatedIdentitySource,
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum DelegatedSessionSubjectRejection {
71 Anonymous,
72 ManagementCanister,
73 LocalCanister,
74 RootCanister,
75 ParentCanister,
76 SubnetCanister,
77 PrimeRootCanister,
78 RegisteredCanister,
79}
80
81impl fmt::Display for DelegatedSessionSubjectRejection {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 let reason = match self {
84 Self::Anonymous => "anonymous principals are not allowed",
85 Self::ManagementCanister => "management canister principal is not allowed",
86 Self::LocalCanister => "current canister principal is not allowed",
87 Self::RootCanister => "root canister principal is not allowed",
88 Self::ParentCanister => "parent canister principal is not allowed",
89 Self::SubnetCanister => "subnet principal is not allowed",
90 Self::PrimeRootCanister => "prime root principal is not allowed",
91 Self::RegisteredCanister => "subnet-registered canister principal is not allowed",
92 };
93 f.write_str(reason)
94 }
95}
96
97struct CallerBoundToken {
102 verified: VerifiedDelegatedToken,
103}
104
105impl CallerBoundToken {
106 fn bind_to_caller(
110 verified: VerifiedDelegatedToken,
111 caller: Principal,
112 ) -> Result<Self, AccessError> {
113 enforce_subject_binding(verified.claims.subject(), caller)?;
114 Ok(Self { verified })
115 }
116
117 fn scopes(&self) -> &[String] {
121 self.verified.claims.scopes()
122 }
123
124 fn into_verified(self) -> VerifiedDelegatedToken {
128 self.verified
129 }
130}
131
132#[must_use]
136pub fn resolve_authenticated_identity(
137 transport_caller: Principal,
138) -> ResolvedAuthenticatedIdentity {
139 resolve_authenticated_identity_at(transport_caller, IcOps::now_secs())
140}
141
142pub(crate) fn resolve_authenticated_identity_at(
143 transport_caller: Principal,
144 now_secs: u64,
145) -> ResolvedAuthenticatedIdentity {
146 if let Some(session) = DelegationStateOps::delegated_session(transport_caller, now_secs) {
147 if validate_delegated_session_subject(session.delegated_pid).is_ok() {
148 return ResolvedAuthenticatedIdentity {
149 transport_caller,
150 authenticated_subject: session.delegated_pid,
151 identity_source: AuthenticatedIdentitySource::DelegatedSession,
152 };
153 }
154
155 DelegationStateOps::clear_delegated_session(transport_caller);
156 record_session_fallback_invalid_subject();
157 }
158
159 record_session_fallback_raw_caller();
160 ResolvedAuthenticatedIdentity {
161 transport_caller,
162 authenticated_subject: transport_caller,
163 identity_source: AuthenticatedIdentitySource::RawCaller,
164 }
165}
166
167pub fn validate_delegated_session_subject(
171 subject: Principal,
172) -> Result<(), DelegatedSessionSubjectRejection> {
173 if subject == Principal::anonymous() {
174 return Err(DelegatedSessionSubjectRejection::Anonymous);
175 }
176
177 if subject == Principal::management_canister() {
178 return Err(DelegatedSessionSubjectRejection::ManagementCanister);
179 }
180
181 if try_canister_self().is_some_and(|pid| pid == subject) {
182 return Err(DelegatedSessionSubjectRejection::LocalCanister);
183 }
184
185 let env = EnvOps::snapshot();
186 if env.root_pid.is_some_and(|pid| pid == subject) {
187 return Err(DelegatedSessionSubjectRejection::RootCanister);
188 }
189 if env.parent_pid.is_some_and(|pid| pid == subject) {
190 return Err(DelegatedSessionSubjectRejection::ParentCanister);
191 }
192 if env.subnet_pid.is_some_and(|pid| pid == subject) {
193 return Err(DelegatedSessionSubjectRejection::SubnetCanister);
194 }
195 if env.prime_root_pid.is_some_and(|pid| pid == subject) {
196 return Err(DelegatedSessionSubjectRejection::PrimeRootCanister);
197 }
198 if SubnetRegistryOps::is_registered(subject) {
199 return Err(DelegatedSessionSubjectRejection::RegisteredCanister);
200 }
201
202 Ok(())
203}
204
205#[cfg(target_arch = "wasm32")]
206#[expect(clippy::unnecessary_wraps)]
207fn try_canister_self() -> Option<Principal> {
208 Some(IcOps::canister_self())
209}
210
211#[cfg(not(target_arch = "wasm32"))]
212const fn try_canister_self() -> Option<Principal> {
213 None
214}
215
216pub(crate) async fn delegated_token_verified(
217 authenticated_subject: Principal,
218 required_scope: Option<&str>,
219) -> Result<VerifiedDelegatedToken, AccessError> {
220 let token = delegated_token_from_args()?;
221
222 let authority_pid =
223 EnvOps::root_pid().map_err(|_| dependency_unavailable("root pid unavailable"))?;
224
225 let now_secs = IcOps::now_secs();
226 let self_pid = IcOps::canister_self();
227
228 verify_token(
229 token,
230 authenticated_subject,
231 authority_pid,
232 now_secs,
233 self_pid,
234 required_scope,
235 )
236 .await
237}
238
239#[expect(clippy::unused_async)]
241async fn verify_token(
242 token: DelegatedToken,
243 caller: Principal,
244 authority_pid: Principal,
245 now_secs: u64,
246 self_pid: Principal,
247 required_scope: Option<&str>,
248) -> Result<VerifiedDelegatedToken, AccessError> {
249 let verified = DelegatedTokenOps::verify_token(&token, authority_pid, now_secs, self_pid)
250 .map_err(|err| AccessError::Denied(err.to_string()))?;
251
252 let caller_bound = CallerBoundToken::bind_to_caller(verified, caller)?;
253 enforce_required_scope(required_scope, caller_bound.scopes())?;
254
255 Ok(caller_bound.into_verified())
256}
257
258fn enforce_subject_binding(sub: Principal, caller: Principal) -> Result<(), AccessError> {
259 if sub == caller {
260 Ok(())
261 } else {
262 Err(AccessError::Denied(format!(
263 "delegated token subject '{sub}' does not match caller '{caller}'"
264 )))
265 }
266}
267
268fn enforce_required_scope(
269 required_scope: Option<&str>,
270 token_scopes: &[String],
271) -> Result<(), AccessError> {
272 let Some(required_scope) = required_scope else {
273 return Ok(());
274 };
275
276 if token_scopes.iter().any(|scope| scope == required_scope) {
277 Ok(())
278 } else {
279 Err(AccessError::Denied(format!(
280 "delegated token missing required scope '{required_scope}'"
281 )))
282 }
283}
284
285#[expect(clippy::unused_async)]
292pub async fn is_controller(caller: Principal) -> Result<(), AccessError> {
293 if caller_is_controller(&caller) {
294 Ok(())
295 } else {
296 Err(AccessError::Denied(format!(
297 "caller '{caller}' is not a controller of this canister"
298 )))
299 }
300}
301
302#[expect(clippy::unused_async)]
305pub async fn is_whitelisted(caller: Principal) -> Result<(), AccessError> {
306 let cfg = Config::try_get().ok_or_else(|| dependency_unavailable("config not initialized"))?;
307
308 if !cfg.is_whitelisted(&caller) {
309 return Err(AccessError::Denied(format!(
310 "caller '{caller}' is not on the whitelist"
311 )));
312 }
313
314 Ok(())
315}
316
317#[expect(clippy::unused_async)]
319pub async fn is_child(caller: Principal) -> Result<(), AccessError> {
320 if CanisterChildrenOps::contains_pid(&caller) {
321 Ok(())
322 } else {
323 Err(AccessError::Denied(format!(
324 "caller '{caller}' is not a child of this canister"
325 )))
326 }
327}
328
329#[expect(clippy::unused_async)]
331pub async fn is_parent(caller: Principal) -> Result<(), AccessError> {
332 let snapshot = EnvOps::snapshot();
333 let parent_pid = snapshot
334 .parent_pid
335 .ok_or_else(|| dependency_unavailable("parent pid unavailable"))?;
336
337 if parent_pid == caller {
338 Ok(())
339 } else {
340 Err(AccessError::Denied(format!(
341 "caller '{caller}' is not the parent of this canister"
342 )))
343 }
344}
345
346#[expect(clippy::unused_async)]
348pub async fn is_root(caller: Principal) -> Result<(), AccessError> {
349 let root_pid =
350 EnvOps::root_pid().map_err(|_| dependency_unavailable("root pid unavailable"))?;
351
352 if caller == root_pid {
353 Ok(())
354 } else {
355 Err(AccessError::Denied(format!(
356 "caller '{caller}' is not root"
357 )))
358 }
359}
360
361#[expect(clippy::unused_async)]
363pub async fn is_same_canister(caller: Principal) -> Result<(), AccessError> {
364 if caller == canister_self() {
365 Ok(())
366 } else {
367 Err(AccessError::Denied(format!(
368 "caller '{caller}' is not the current canister"
369 )))
370 }
371}
372
373#[expect(clippy::unused_async)]
379pub async fn has_role(caller: Principal, role: Role) -> Result<(), AccessError> {
380 if !EnvOps::is_root() {
381 return Err(non_root_subnet_registry_predicate_denial());
382 }
383
384 let record =
385 SubnetRegistryOps::get(caller).ok_or_else(|| caller_not_registered_denial(caller))?;
386
387 if record.role == role {
388 Ok(())
389 } else {
390 Err(AccessError::Denied(format!(
391 "authentication error: caller '{caller}' does not have role '{role}'"
392 )))
393 }
394}
395
396#[expect(clippy::unused_async)]
399pub async fn is_registered_to_subnet(caller: Principal) -> Result<(), AccessError> {
400 if !EnvOps::is_root() {
401 return Err(non_root_subnet_registry_predicate_denial());
402 }
403
404 if SubnetRegistryOps::is_registered(caller) {
405 Ok(())
406 } else {
407 Err(caller_not_registered_denial(caller))
408 }
409}
410
411fn delegated_token_from_args() -> Result<DelegatedToken, AccessError> {
412 let bytes = msg_arg_data();
413
414 if bytes.len() > MAX_INGRESS_BYTES {
415 return Err(AccessError::Denied(
416 "delegated token payload exceeds size limit".to_string(),
417 ));
418 }
419
420 let mut decoder = IDLDeserialize::new(&bytes)
421 .map_err(|err| AccessError::Denied(format!("failed to decode ingress arguments: {err}")))?;
422
423 decoder.get_value::<DelegatedToken>().map_err(|err| {
424 AccessError::Denied(format!(
425 "failed to decode delegated token as first argument: {err}"
426 ))
427 })
428}
429
430fn dependency_unavailable(detail: &str) -> AccessError {
431 AccessError::Denied(format!("access dependency unavailable: {detail}"))
432}
433
434fn non_root_subnet_registry_predicate_denial() -> AccessError {
435 AccessError::Denied(
436 "authentication error: illegal access to subnet registry predicate from non-root canister"
437 .to_string(),
438 )
439}
440
441fn caller_not_registered_denial(caller: Principal) -> AccessError {
442 let root = EnvOps::root_pid().map_or_else(|_| "unavailable".to_string(), |pid| pid.to_string());
443 let registry_count = SubnetRegistryOps::data().entries.len();
444 AccessError::Denied(format!(
445 "authentication error: caller '{caller}' is not registered on the subnet registry \
446 (root='{root}', registry_entries={registry_count}); verify caller root routing and \
447 canic_subnet_registry state"
448 ))
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::{
455 ids::{AccessMetricKind, cap},
456 ops::runtime::metrics::access::AccessMetrics,
457 test::seams,
458 };
459
460 fn p(id: u8) -> Principal {
461 Principal::from_slice(&[id; 29])
462 }
463
464 fn auth_session_metric_count(predicate: &str) -> u64 {
465 AccessMetrics::snapshot()
466 .entries
467 .into_iter()
468 .find_map(|(key, count)| {
469 if key.endpoint == "auth_session"
470 && key.kind == AccessMetricKind::Auth
471 && key.predicate == predicate
472 {
473 Some(count)
474 } else {
475 None
476 }
477 })
478 .unwrap_or(0)
479 }
480
481 #[test]
482 fn subject_binding_allows_matching_subject_and_caller() {
483 let sub = p(1);
484 let caller = p(1);
485 assert!(enforce_subject_binding(sub, caller).is_ok());
486 }
487
488 #[test]
489 fn subject_binding_rejects_mismatched_subject_and_caller() {
490 let sub = p(1);
491 let caller = p(2);
492 let err = enforce_subject_binding(sub, caller).expect_err("expected subject mismatch");
493 assert!(err.to_string().contains("does not match caller"));
494 }
495
496 #[test]
497 fn required_scope_allows_when_scope_present() {
498 let scopes = vec![cap::READ.to_string(), cap::VERIFY.to_string()];
499 assert!(enforce_required_scope(Some(cap::VERIFY), &scopes).is_ok());
500 }
501
502 #[test]
503 fn required_scope_rejects_when_scope_missing() {
504 let scopes = vec![cap::READ.to_string()];
505 let err = enforce_required_scope(Some(cap::VERIFY), &scopes).expect_err("expected denial");
506 assert!(err.to_string().contains("missing required scope"));
507 }
508
509 #[test]
510 fn required_scope_none_is_allowed() {
511 let scopes = vec![cap::READ.to_string()];
512 assert!(enforce_required_scope(None, &scopes).is_ok());
513 }
514
515 #[test]
516 fn resolve_authenticated_identity_defaults_to_wallet_when_no_override_exists() {
517 let _guard = seams::lock();
518 AccessMetrics::reset();
519 let wallet = p(9);
520 DelegationStateOps::clear_delegated_session(wallet);
521 let resolved = resolve_authenticated_identity(wallet);
522 assert_eq!(resolved.authenticated_subject, wallet);
523 assert_eq!(
524 auth_session_metric_count("session_fallback_raw_caller"),
525 1,
526 "missing delegated session should record raw-caller fallback"
527 );
528 }
529
530 #[test]
531 fn resolve_authenticated_identity_prefers_active_delegated_session() {
532 let _guard = seams::lock();
533 AccessMetrics::reset();
534 let wallet = p(8);
535 let delegated = p(7);
536 DelegationStateOps::upsert_delegated_session(
537 crate::ops::storage::auth::DelegatedSession {
538 wallet_pid: wallet,
539 delegated_pid: delegated,
540 issued_at: 100,
541 expires_at: 200,
542 bootstrap_token_fingerprint: None,
543 },
544 100,
545 );
546
547 let resolved = resolve_authenticated_identity_at(wallet, 150);
548 assert_eq!(resolved.transport_caller, wallet);
549 assert_eq!(resolved.authenticated_subject, delegated);
550 assert_eq!(
551 resolved.identity_source,
552 AuthenticatedIdentitySource::DelegatedSession
553 );
554 assert_eq!(
555 auth_session_metric_count("session_fallback_raw_caller"),
556 0,
557 "active delegated session should not fallback to raw caller"
558 );
559
560 DelegationStateOps::clear_delegated_session(wallet);
561 }
562
563 #[test]
564 fn resolve_authenticated_identity_falls_back_when_session_expired() {
565 let _guard = seams::lock();
566 AccessMetrics::reset();
567 let wallet = p(6);
568 let delegated = p(5);
569 DelegationStateOps::upsert_delegated_session(
570 crate::ops::storage::auth::DelegatedSession {
571 wallet_pid: wallet,
572 delegated_pid: delegated,
573 issued_at: 100,
574 expires_at: 120,
575 bootstrap_token_fingerprint: None,
576 },
577 100,
578 );
579
580 let resolved = resolve_authenticated_identity_at(wallet, 121);
581 assert_eq!(resolved.authenticated_subject, wallet);
582 assert_eq!(
583 resolved.identity_source,
584 AuthenticatedIdentitySource::RawCaller
585 );
586 assert_eq!(
587 auth_session_metric_count("session_fallback_raw_caller"),
588 1,
589 "expired delegated session should fallback to raw caller"
590 );
591
592 DelegationStateOps::clear_delegated_session(wallet);
593 }
594
595 #[test]
596 fn resolve_authenticated_identity_falls_back_after_clear() {
597 let _guard = seams::lock();
598 AccessMetrics::reset();
599 let wallet = p(4);
600 let delegated = p(3);
601 DelegationStateOps::upsert_delegated_session(
602 crate::ops::storage::auth::DelegatedSession {
603 wallet_pid: wallet,
604 delegated_pid: delegated,
605 issued_at: 50,
606 expires_at: 500,
607 bootstrap_token_fingerprint: None,
608 },
609 50,
610 );
611 DelegationStateOps::clear_delegated_session(wallet);
612
613 let resolved = resolve_authenticated_identity_at(wallet, 100);
614 assert_eq!(resolved.authenticated_subject, wallet);
615 assert_eq!(
616 resolved.identity_source,
617 AuthenticatedIdentitySource::RawCaller
618 );
619 assert_eq!(auth_session_metric_count("session_fallback_raw_caller"), 1);
620 }
621
622 #[test]
623 fn resolve_authenticated_identity_records_invalid_subject_fallback() {
624 let _guard = seams::lock();
625 AccessMetrics::reset();
626 let wallet = p(23);
627 DelegationStateOps::upsert_delegated_session(
628 crate::ops::storage::auth::DelegatedSession {
629 wallet_pid: wallet,
630 delegated_pid: Principal::management_canister(),
631 issued_at: 10,
632 expires_at: 100,
633 bootstrap_token_fingerprint: None,
634 },
635 10,
636 );
637
638 let resolved = resolve_authenticated_identity_at(wallet, 20);
639 assert_eq!(resolved.authenticated_subject, wallet);
640 assert_eq!(
641 resolved.identity_source,
642 AuthenticatedIdentitySource::RawCaller
643 );
644 assert_eq!(
645 auth_session_metric_count("session_fallback_invalid_subject"),
646 1
647 );
648 assert_eq!(auth_session_metric_count("session_fallback_raw_caller"), 1);
649 assert!(
650 DelegationStateOps::delegated_session(wallet, 20).is_none(),
651 "invalid delegated session should be cleared"
652 );
653 }
654
655 #[test]
656 fn validate_delegated_session_subject_rejects_anonymous() {
657 let _guard = seams::lock();
658 let err = validate_delegated_session_subject(Principal::anonymous())
659 .expect_err("anonymous must be rejected");
660 assert_eq!(err, DelegatedSessionSubjectRejection::Anonymous);
661 }
662
663 #[test]
664 fn validate_delegated_session_subject_rejects_management_canister() {
665 let _guard = seams::lock();
666 let err = validate_delegated_session_subject(Principal::management_canister())
667 .expect_err("management canister must be rejected");
668 assert_eq!(err, DelegatedSessionSubjectRejection::ManagementCanister);
669 }
670}