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