Skip to main content

canic_core/api/auth/
mod.rs

1use crate::{
2    access::auth::validate_delegated_session_subject,
3    cdk::types::Principal,
4    dto::{
5        auth::{
6            AttestationKeySet, DelegatedToken, DelegatedTokenClaims, DelegationCert,
7            DelegationProof, DelegationProvisionResponse, DelegationProvisionTargetKind,
8            DelegationRequest, RoleAttestationRequest, SignedRoleAttestation,
9        },
10        error::{Error, ErrorCode},
11        rpc::{Request as RootRequest, Response as RootCapabilityResponse},
12    },
13    error::InternalErrorClass,
14    log,
15    log::Topic,
16    ops::{
17        auth::DelegatedTokenOps,
18        config::ConfigOps,
19        ic::IcOps,
20        rpc::RpcOps,
21        runtime::env::EnvOps,
22        runtime::metrics::auth::{
23            record_attestation_refresh_failed, record_session_bootstrap_rejected_disabled,
24            record_session_bootstrap_rejected_replay_conflict,
25            record_session_bootstrap_rejected_replay_reused,
26            record_session_bootstrap_rejected_subject_mismatch,
27            record_session_bootstrap_rejected_subject_rejected,
28            record_session_bootstrap_rejected_token_invalid,
29            record_session_bootstrap_rejected_ttl_invalid,
30            record_session_bootstrap_rejected_wallet_caller_rejected,
31            record_session_bootstrap_replay_idempotent, record_session_cleared,
32            record_session_created, record_session_pruned, record_session_replaced,
33            record_signer_issue_without_proof,
34        },
35        storage::auth::{DelegatedSession, DelegatedSessionBootstrapBinding, DelegationStateOps},
36    },
37    protocol,
38    workflow::rpc::request::handler::RootResponseWorkflow,
39};
40use sha2::{Digest, Sha256};
41
42mod metadata;
43mod verify_flow;
44
45///
46/// DelegationApi
47///
48/// Requires auth.delegated_tokens.enabled = true in config.
49///
50
51pub struct DelegationApi;
52
53impl DelegationApi {
54    const DELEGATED_TOKENS_DISABLED: &str =
55        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
56    const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
57    const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
58        b"canic-session-bootstrap-token-fingerprint:v1";
59
60    fn map_delegation_error(err: crate::InternalError) -> Error {
61        match err.class() {
62            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
63                Error::internal(err.to_string())
64            }
65            _ => Error::from(err),
66        }
67    }
68
69    /// Full delegation proof verification (structure + signature).
70    ///
71    /// Purely local verification; does not read certified data or require a
72    /// query context.
73    pub fn verify_delegation_proof(
74        proof: &DelegationProof,
75        authority_pid: Principal,
76    ) -> Result<(), Error> {
77        DelegatedTokenOps::verify_delegation_proof(proof, authority_pid)
78            .map_err(Self::map_delegation_error)
79    }
80
81    async fn sign_token(
82        claims: DelegatedTokenClaims,
83        proof: DelegationProof,
84    ) -> Result<DelegatedToken, Error> {
85        DelegatedTokenOps::sign_token(claims, proof)
86            .await
87            .map_err(Self::map_delegation_error)
88    }
89
90    /// Issue a delegated token using a reusable local proof when possible.
91    ///
92    /// If the proof is missing or no longer valid for the requested claims, this
93    /// performs canonical shard-initiated setup and retries with the refreshed proof.
94    pub async fn issue_token(claims: DelegatedTokenClaims) -> Result<DelegatedToken, Error> {
95        let proof = Self::ensure_signing_proof(&claims).await?;
96        Self::sign_token(claims, proof).await
97    }
98
99    /// Full delegated token verification (structure + signature).
100    ///
101    /// Purely local verification; does not read certified data or require a
102    /// query context.
103    pub fn verify_token(
104        token: &DelegatedToken,
105        authority_pid: Principal,
106        now_secs: u64,
107    ) -> Result<(), Error> {
108        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
109            .map(|_| ())
110            .map_err(Self::map_delegation_error)
111    }
112
113    /// Verify a delegated token and return verified contents.
114    ///
115    /// This is intended for application-layer session construction.
116    /// It performs full verification and returns verified claims and cert.
117    pub fn verify_token_verified(
118        token: &DelegatedToken,
119        authority_pid: Principal,
120        now_secs: u64,
121    ) -> Result<(DelegatedTokenClaims, DelegationCert), Error> {
122        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
123            .map(|verified| (verified.claims, verified.cert))
124            .map_err(Self::map_delegation_error)
125    }
126
127    /// Canonical shard-initiated delegation request (user_shard -> root).
128    ///
129    /// Caller must match shard_pid and be registered to the subnet.
130    pub async fn request_delegation(
131        request: DelegationRequest,
132    ) -> Result<DelegationProvisionResponse, Error> {
133        let request = metadata::with_root_request_metadata(request);
134        if EnvOps::is_root() {
135            let response = RootResponseWorkflow::response(RootRequest::issue_delegation(request))
136                .await
137                .map_err(Self::map_delegation_error)?;
138
139            return match response {
140                RootCapabilityResponse::DelegationIssued(response) => Ok(response),
141                _ => Err(Error::internal(
142                    "invalid root response type for delegation request",
143                )),
144            };
145        }
146
147        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
148        RpcOps::call_rpc_result(root_pid, protocol::CANIC_REQUEST_DELEGATION, request)
149            .await
150            .map_err(Self::map_delegation_error)
151    }
152
153    pub async fn request_role_attestation(
154        request: RoleAttestationRequest,
155    ) -> Result<SignedRoleAttestation, Error> {
156        let request = metadata::with_root_attestation_request_metadata(request);
157        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
158            .await
159            .map_err(Self::map_delegation_error)?;
160
161        match response {
162            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
163            _ => Err(Error::internal(
164                "invalid root response type for role attestation request",
165            )),
166        }
167    }
168
169    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
170        DelegatedTokenOps::attestation_key_set()
171            .await
172            .map_err(Self::map_delegation_error)
173    }
174
175    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
176        DelegatedTokenOps::replace_attestation_key_set(key_set);
177    }
178
179    pub async fn verify_role_attestation(
180        attestation: &SignedRoleAttestation,
181        min_accepted_epoch: u64,
182    ) -> Result<(), Error> {
183        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
184            .map_err(Error::from)?
185            .min_accepted_epoch_by_role
186            .get(attestation.payload.role.as_str())
187            .copied();
188        let min_accepted_epoch = verify_flow::resolve_min_accepted_epoch(
189            min_accepted_epoch,
190            configured_min_accepted_epoch,
191        );
192
193        let caller = IcOps::msg_caller();
194        let self_pid = IcOps::canister_self();
195        let now_secs = IcOps::now_secs();
196        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
197        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
198
199        let verify = || {
200            DelegatedTokenOps::verify_role_attestation_cached(
201                attestation,
202                caller,
203                self_pid,
204                verifier_subnet,
205                now_secs,
206                min_accepted_epoch,
207            )
208            .map(|_| ())
209        };
210        let refresh = || async {
211            let key_set: AttestationKeySet =
212                RpcOps::call_rpc_result(root_pid, protocol::CANIC_ATTESTATION_KEY_SET, ()).await?;
213            DelegatedTokenOps::replace_attestation_key_set(key_set);
214            Ok(())
215        };
216
217        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
218            Ok(()) => Ok(()),
219            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
220                verify_flow::record_attestation_verifier_rejection(&err);
221                verify_flow::log_attestation_verifier_rejection(
222                    &err,
223                    attestation,
224                    caller,
225                    self_pid,
226                    "cached",
227                );
228                Err(Self::map_delegation_error(err.into()))
229            }
230            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
231                verify_flow::record_attestation_verifier_rejection(&trigger);
232                verify_flow::log_attestation_verifier_rejection(
233                    &trigger,
234                    attestation,
235                    caller,
236                    self_pid,
237                    "cache_miss_refresh",
238                );
239                record_attestation_refresh_failed();
240                log!(
241                    Topic::Auth,
242                    Warn,
243                    "role attestation refresh failed local={} caller={} key_id={} error={}",
244                    self_pid,
245                    caller,
246                    attestation.key_id,
247                    source
248                );
249                Err(Self::map_delegation_error(source))
250            }
251            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
252                verify_flow::record_attestation_verifier_rejection(&err);
253                verify_flow::log_attestation_verifier_rejection(
254                    &err,
255                    attestation,
256                    caller,
257                    self_pid,
258                    "post_refresh",
259                );
260                Err(Self::map_delegation_error(err.into()))
261            }
262        }
263    }
264
265    /// Persist a temporary delegated session subject for the caller wallet.
266    pub fn set_delegated_session_subject(
267        delegated_subject: Principal,
268        bootstrap_token: DelegatedToken,
269        requested_ttl_secs: Option<u64>,
270    ) -> Result<(), Error> {
271        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
272        if !cfg.enabled {
273            record_session_bootstrap_rejected_disabled();
274            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
275        }
276
277        let wallet_caller = IcOps::msg_caller();
278        if let Err(reason) = validate_delegated_session_subject(wallet_caller) {
279            record_session_bootstrap_rejected_wallet_caller_rejected();
280            return Err(Error::forbidden(format!(
281                "delegated session wallet caller rejected: {reason}"
282            )));
283        }
284
285        if let Err(reason) = validate_delegated_session_subject(delegated_subject) {
286            record_session_bootstrap_rejected_subject_rejected();
287            return Err(Error::forbidden(format!(
288                "delegated session subject rejected: {reason}"
289            )));
290        }
291
292        let issued_at = IcOps::now_secs();
293        let authority_pid = EnvOps::root_pid().map_err(Error::from)?;
294        let self_pid = IcOps::canister_self();
295        let verified =
296            DelegatedTokenOps::verify_token(&bootstrap_token, authority_pid, issued_at, self_pid)
297                .map_err(|err| {
298                record_session_bootstrap_rejected_token_invalid();
299                Self::map_delegation_error(err)
300            })?;
301
302        if verified.claims.sub != delegated_subject {
303            record_session_bootstrap_rejected_subject_mismatch();
304            return Err(Error::forbidden(format!(
305                "delegated session subject mismatch: requested={} token_subject={}",
306                delegated_subject, verified.claims.sub
307            )));
308        }
309
310        let configured_max_ttl_secs = cfg
311            .max_ttl_secs
312            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
313        let expires_at = Self::clamp_delegated_session_expires_at(
314            issued_at,
315            verified.claims.exp,
316            configured_max_ttl_secs,
317            requested_ttl_secs,
318        )
319        .inspect_err(|_| record_session_bootstrap_rejected_ttl_invalid())?;
320
321        let token_fingerprint =
322            Self::delegated_session_bootstrap_token_fingerprint(&bootstrap_token)
323                .inspect_err(|_| record_session_bootstrap_rejected_token_invalid())?;
324
325        if Self::enforce_bootstrap_replay_policy(
326            wallet_caller,
327            delegated_subject,
328            token_fingerprint,
329            issued_at,
330        )? {
331            return Ok(());
332        }
333
334        let had_active_session =
335            DelegationStateOps::delegated_session(wallet_caller, issued_at).is_some();
336
337        DelegationStateOps::upsert_delegated_session(
338            DelegatedSession {
339                wallet_pid: wallet_caller,
340                delegated_pid: delegated_subject,
341                issued_at,
342                expires_at,
343                bootstrap_token_fingerprint: Some(token_fingerprint),
344            },
345            issued_at,
346        );
347        DelegationStateOps::upsert_delegated_session_bootstrap_binding(
348            DelegatedSessionBootstrapBinding {
349                wallet_pid: wallet_caller,
350                delegated_pid: delegated_subject,
351                token_fingerprint,
352                bound_at: issued_at,
353                expires_at: verified.claims.exp,
354            },
355            issued_at,
356        );
357
358        if had_active_session {
359            record_session_replaced();
360        } else {
361            record_session_created();
362        }
363
364        Ok(())
365    }
366
367    /// Remove the caller's delegated session subject.
368    pub fn clear_delegated_session() {
369        let wallet_caller = IcOps::msg_caller();
370        let had_active_session =
371            DelegationStateOps::delegated_session(wallet_caller, IcOps::now_secs()).is_some();
372        DelegationStateOps::clear_delegated_session(wallet_caller);
373        if had_active_session {
374            record_session_cleared();
375        }
376    }
377
378    /// Read the caller's active delegated session subject, if configured.
379    #[must_use]
380    pub fn delegated_session_subject() -> Option<Principal> {
381        let wallet_caller = IcOps::msg_caller();
382        DelegationStateOps::delegated_session_subject(wallet_caller, IcOps::now_secs())
383    }
384
385    /// Prune all currently expired delegated sessions.
386    #[must_use]
387    pub fn prune_expired_delegated_sessions() -> usize {
388        let now_secs = IcOps::now_secs();
389        let removed = DelegationStateOps::prune_expired_delegated_sessions(now_secs);
390        let _ = DelegationStateOps::prune_expired_delegated_session_bootstrap_bindings(now_secs);
391        if removed > 0 {
392            record_session_pruned(removed);
393        }
394        removed
395    }
396
397    /// Compatibility helper for the legacy delegated-caller API.
398    pub fn set_delegated_caller(
399        delegated_caller: Principal,
400        bootstrap_token: DelegatedToken,
401        requested_ttl_secs: Option<u64>,
402    ) -> Result<(), Error> {
403        Self::set_delegated_session_subject(delegated_caller, bootstrap_token, requested_ttl_secs)
404    }
405
406    /// Compatibility helper for the legacy delegated-caller API.
407    pub fn clear_delegated_caller() {
408        Self::clear_delegated_session();
409    }
410
411    /// Compatibility helper for the legacy delegated-caller API.
412    #[must_use]
413    pub fn delegated_caller() -> Option<Principal> {
414        Self::delegated_session_subject()
415    }
416
417    pub async fn store_proof(
418        proof: DelegationProof,
419        kind: DelegationProvisionTargetKind,
420    ) -> Result<(), Error> {
421        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
422        if !cfg.enabled {
423            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
424        }
425
426        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
427        let caller = IcOps::msg_caller();
428        if caller != root_pid {
429            return Err(Error::forbidden(
430                "delegation proof store requires root caller",
431            ));
432        }
433
434        DelegatedTokenOps::cache_public_keys_for_cert(&proof.cert)
435            .await
436            .map_err(Self::map_delegation_error)?;
437        if let Err(err) = DelegatedTokenOps::verify_delegation_proof(&proof, root_pid) {
438            let local = IcOps::canister_self();
439            log!(
440                Topic::Auth,
441                Warn,
442                "delegation proof rejected kind={:?} local={} shard={} issued_at={} expires_at={} error={}",
443                kind,
444                local,
445                proof.cert.shard_pid,
446                proof.cert.issued_at,
447                proof.cert.expires_at,
448                err
449            );
450            return Err(Self::map_delegation_error(err));
451        }
452
453        DelegationStateOps::set_proof_from_dto(proof);
454        let local = IcOps::canister_self();
455        let stored = DelegationStateOps::proof_dto()
456            .ok_or_else(|| Error::invariant("delegation proof missing after store"))?;
457        log!(
458            Topic::Auth,
459            Info,
460            "delegation proof stored kind={:?} local={} shard={} issued_at={} expires_at={}",
461            kind,
462            local,
463            stored.cert.shard_pid,
464            stored.cert.issued_at,
465            stored.cert.expires_at
466        );
467
468        Ok(())
469    }
470
471    /// Install delegation proof and key material directly, bypassing management-key lookups.
472    ///
473    /// This is intended for controlled root-driven test flows where deterministic
474    /// key material is used instead of chain-key ECDSA.
475    // Compiled only for controlled test canister builds.
476    #[cfg(canic_test_delegation_material)]
477    pub fn install_test_delegation_material(
478        proof: DelegationProof,
479        root_public_key: Vec<u8>,
480        shard_public_key: Vec<u8>,
481    ) -> Result<(), Error> {
482        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
483        let caller = IcOps::msg_caller();
484        if caller != root_pid {
485            return Err(Error::forbidden(
486                "test delegation material install requires root caller",
487            ));
488        }
489
490        if proof.cert.root_pid != root_pid {
491            return Err(Error::invalid(format!(
492                "delegation proof root mismatch: expected={} found={}",
493                root_pid, proof.cert.root_pid
494            )));
495        }
496
497        if root_public_key.is_empty() || shard_public_key.is_empty() {
498            return Err(Error::invalid("delegation public keys must not be empty"));
499        }
500
501        DelegationStateOps::set_root_public_key(root_public_key);
502        DelegationStateOps::set_shard_public_key(proof.cert.shard_pid, shard_public_key);
503        DelegationStateOps::set_proof_from_dto(proof);
504        Ok(())
505    }
506
507    fn require_proof() -> Result<DelegationProof, Error> {
508        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
509        if !cfg.enabled {
510            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
511        }
512
513        DelegationStateOps::proof_dto().ok_or_else(|| {
514            record_signer_issue_without_proof();
515            Error::not_found("delegation proof not set")
516        })
517    }
518
519    // Resolve a proof that is currently usable for token issuance.
520    async fn ensure_signing_proof(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
521        let now_secs = IcOps::now_secs();
522
523        match Self::require_proof() {
524            Ok(proof) if !Self::proof_is_reusable_for_claims(&proof, claims, now_secs) => {
525                Self::setup_delegation(claims).await
526            }
527            Ok(proof) => Ok(proof),
528            Err(err) if err.code == ErrorCode::NotFound => Self::setup_delegation(claims).await,
529            Err(err) => Err(err),
530        }
531    }
532
533    // Provision a fresh delegation from root, then load locally stored proof.
534    async fn setup_delegation(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
535        let request = Self::delegation_request_from_claims(claims)?;
536        let _ = Self::request_delegation(request).await?;
537        Self::require_proof()
538    }
539
540    // Build a canonical delegation request from token claims.
541    fn delegation_request_from_claims(
542        claims: &DelegatedTokenClaims,
543    ) -> Result<DelegationRequest, Error> {
544        let ttl_secs = claims.exp.saturating_sub(claims.iat);
545        if ttl_secs == 0 {
546            return Err(Error::invalid(
547                "delegation ttl_secs must be greater than zero",
548            ));
549        }
550
551        Ok(DelegationRequest {
552            shard_pid: IcOps::canister_self(),
553            scopes: claims.scopes.clone(),
554            aud: claims.aud.clone(),
555            ttl_secs,
556            verifier_targets: Vec::new(),
557            include_root_verifier: true,
558            metadata: None,
559        })
560    }
561
562    // Check whether a proof can be reused safely for the requested claims.
563    fn proof_is_reusable_for_claims(
564        proof: &DelegationProof,
565        claims: &DelegatedTokenClaims,
566        now_secs: u64,
567    ) -> bool {
568        if now_secs > proof.cert.expires_at {
569            return false;
570        }
571
572        if claims.shard_pid != proof.cert.shard_pid {
573            return false;
574        }
575
576        if claims.iat < proof.cert.issued_at || claims.exp > proof.cert.expires_at {
577            return false;
578        }
579
580        Self::is_principal_subset(&claims.aud, &proof.cert.aud)
581            && Self::is_string_subset(&claims.scopes, &proof.cert.scopes)
582    }
583
584    // Return true when every principal in `subset` is present in `superset`.
585    fn is_principal_subset(
586        subset: &[crate::cdk::types::Principal],
587        superset: &[crate::cdk::types::Principal],
588    ) -> bool {
589        subset.iter().all(|item| superset.contains(item))
590    }
591
592    // Return true when every scope in `subset` is present in `superset`.
593    fn is_string_subset(subset: &[String], superset: &[String]) -> bool {
594        subset.iter().all(|item| superset.contains(item))
595    }
596
597    fn delegated_session_bootstrap_token_fingerprint(
598        token: &DelegatedToken,
599    ) -> Result<[u8; 32], Error> {
600        let token_bytes = crate::cdk::candid::encode_one(token).map_err(|err| {
601            Error::internal(format!("bootstrap token fingerprint encode failed: {err}"))
602        })?;
603        let mut hasher = Sha256::new();
604        hasher.update(Self::SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN);
605        hasher.update(token_bytes);
606        Ok(hasher.finalize().into())
607    }
608
609    // Enforce replay policy for delegated-session bootstrap by token fingerprint.
610    fn enforce_bootstrap_replay_policy(
611        wallet_caller: Principal,
612        delegated_subject: Principal,
613        token_fingerprint: [u8; 32],
614        issued_at: u64,
615    ) -> Result<bool, Error> {
616        let Some(binding) =
617            DelegationStateOps::delegated_session_bootstrap_binding(token_fingerprint, issued_at)
618        else {
619            return Ok(false);
620        };
621
622        if binding.wallet_pid == wallet_caller && binding.delegated_pid == delegated_subject {
623            let active_same_session =
624                DelegationStateOps::delegated_session(wallet_caller, issued_at).is_some_and(
625                    |session| {
626                        session.delegated_pid == delegated_subject
627                            && session.bootstrap_token_fingerprint == Some(token_fingerprint)
628                    },
629                );
630
631            if active_same_session {
632                record_session_bootstrap_replay_idempotent();
633                return Ok(true);
634            }
635
636            record_session_bootstrap_rejected_replay_reused();
637            return Err(Error::forbidden(
638                "delegated session bootstrap token replay rejected; use a fresh token",
639            ));
640        }
641
642        record_session_bootstrap_rejected_replay_conflict();
643        Err(Error::forbidden(format!(
644            "delegated session bootstrap token already bound (wallet={} delegated_subject={})",
645            binding.wallet_pid, binding.delegated_pid
646        )))
647    }
648
649    fn clamp_delegated_session_expires_at(
650        now_secs: u64,
651        token_expires_at: u64,
652        configured_max_ttl_secs: u64,
653        requested_ttl_secs: Option<u64>,
654    ) -> Result<u64, Error> {
655        if configured_max_ttl_secs == 0 {
656            return Err(Error::invariant(
657                "delegated session configured max ttl_secs must be greater than zero",
658            ));
659        }
660
661        if let Some(ttl_secs) = requested_ttl_secs
662            && ttl_secs == 0
663        {
664            return Err(Error::invalid(
665                "delegated session requested ttl_secs must be greater than zero",
666            ));
667        }
668
669        let mut expires_at = token_expires_at;
670        expires_at = expires_at.min(now_secs.saturating_add(configured_max_ttl_secs));
671        if let Some(ttl_secs) = requested_ttl_secs {
672            expires_at = expires_at.min(now_secs.saturating_add(ttl_secs));
673        }
674
675        if expires_at <= now_secs {
676            return Err(Error::forbidden(
677                "delegated session bootstrap token is expired",
678            ));
679        }
680
681        Ok(expires_at)
682    }
683}
684
685#[cfg(test)]
686mod tests;